Compare commits
82 Commits
32e91da0b2
...
master
Author | SHA1 | Date | |
---|---|---|---|
|
fb6721cb1b | ||
|
9fcb169c94 | ||
|
572874431d | ||
|
f595ac8001 | ||
|
18674e0e1d | ||
|
da4c4d3a84 | ||
|
aec01b117d | ||
|
d299c32e35 | ||
|
344007af66 | ||
|
d4de5aeac2 | ||
|
8ce5ba50f4 | ||
|
5a44952b27 | ||
|
c30946daf6 | ||
|
0221d7b294 | ||
|
c44b0b64c3 | ||
|
442ee3bcfd | ||
|
081815c512 | ||
|
eab2a388ae | ||
|
5f7ab49abb | ||
|
4ff89173b2 | ||
|
f2052410c7 | ||
|
83a49be725 | ||
|
9b205a73fd | ||
|
d5157eb7e3 | ||
|
75c92c51db | ||
|
915054fce0 | ||
|
63653680ba | ||
|
84c4df6620 | ||
|
8c748fd57a | ||
|
4684550ebf | ||
|
51db08f374 | ||
|
9f38a288b9 | ||
|
75a975049c | ||
|
f8c35c0350 | ||
|
d9a5fed77f | ||
|
7cb14940d9 | ||
|
953bf5d4de | ||
|
d9620fd6a4 | ||
|
541e2dd14c | ||
|
c7925d98c8 | ||
|
f759b19bcb | ||
|
5d7429a416 | ||
|
fb7e52d6f3 | ||
|
50e888b075 | ||
|
76c8bbf307 | ||
|
8f3825e92c | ||
|
d1c3610ec8 | ||
|
4b958a3c31 | ||
|
1f9021d459 | ||
|
7ad9deaf70 | ||
|
c1c17b5f4e | ||
|
d92220b4bc | ||
|
4d1972bc99 | ||
|
83c052ec4e | ||
|
57a75fe9e6 | ||
|
379bc37aff | ||
|
0217fbb13b | ||
|
4e9943e6a2 | ||
|
b3cc623168 | ||
|
3ee5e5367d | ||
|
85fef30c7f | ||
|
e8d8dcbb2d | ||
|
3b679d6134 | ||
|
ec44b51ab6 | ||
|
2e52a13c30 | ||
|
1e8e2e9ea7 | ||
|
9e8363c004 | ||
|
56c40ee001 | ||
|
e3dfccfee3 | ||
|
d555fcaf17 | ||
|
2fdefae718 | ||
|
e78858b7b4 | ||
|
636b674229 | ||
|
fc6cee17d7 | ||
|
7f7b47fb1c | ||
|
bf181b88ec | ||
|
c056938b6e | ||
|
66eadf96b0 | ||
|
665595b8b4 | ||
|
29550401fd | ||
|
1bb0012c40 | ||
|
2cea391ebf |
@@ -1,4 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Develop.Identity;
|
||||
using DysonNetwork.Develop.Project;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
@@ -11,8 +13,11 @@ public class AppDatabase(
|
||||
{
|
||||
public DbSet<Developer> Developers { get; set; } = null!;
|
||||
|
||||
public DbSet<DevProject> DevProjects { get; set; } = null!;
|
||||
|
||||
public DbSet<CustomApp> CustomApps { get; set; } = null!;
|
||||
public DbSet<CustomAppSecret> CustomAppSecrets { get; set; } = null!;
|
||||
public DbSet<BotAccount> BotAccounts { get; set; } = null!;
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
|
54
DysonNetwork.Develop/Identity/BotAccount.cs
Normal file
54
DysonNetwork.Develop/Identity/BotAccount.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using DysonNetwork.Develop.Project;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using NodaTime.Serialization.Protobuf;
|
||||
|
||||
namespace DysonNetwork.Develop.Identity;
|
||||
|
||||
public class BotAccount : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
[MaxLength(1024)] public string Slug { get; set; } = null!;
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
public Guid ProjectId { get; set; }
|
||||
public DevProject Project { get; set; } = null!;
|
||||
|
||||
[NotMapped] public AccountReference? Account { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// This developer field is to serve the transparent info for user to know which developer
|
||||
/// published this robot. Not for relationships usage.
|
||||
/// </summary>
|
||||
[NotMapped] public Developer? Developer { get; set; }
|
||||
|
||||
public Shared.Proto.BotAccount ToProtoValue()
|
||||
{
|
||||
var proto = new Shared.Proto.BotAccount
|
||||
{
|
||||
Slug = Slug,
|
||||
IsActive = IsActive,
|
||||
AutomatedId = Id.ToString(),
|
||||
CreatedAt = CreatedAt.ToTimestamp(),
|
||||
UpdatedAt = UpdatedAt.ToTimestamp()
|
||||
};
|
||||
|
||||
return proto;
|
||||
}
|
||||
|
||||
public static BotAccount FromProto(Shared.Proto.BotAccount proto)
|
||||
{
|
||||
var botAccount = new BotAccount
|
||||
{
|
||||
Id = Guid.Parse(proto.AutomatedId),
|
||||
Slug = proto.Slug,
|
||||
IsActive = proto.IsActive,
|
||||
CreatedAt = proto.CreatedAt.ToInstant(),
|
||||
UpdatedAt = proto.UpdatedAt.ToInstant()
|
||||
};
|
||||
|
||||
return botAccount;
|
||||
}
|
||||
}
|
460
DysonNetwork.Develop/Identity/BotAccountController.cs
Normal file
460
DysonNetwork.Develop/Identity/BotAccountController.cs
Normal file
@@ -0,0 +1,460 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Develop.Project;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using DysonNetwork.Shared.Registry;
|
||||
using Grpc.Core;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NodaTime;
|
||||
using NodaTime.Serialization.Protobuf;
|
||||
|
||||
namespace DysonNetwork.Develop.Identity;
|
||||
|
||||
[ApiController]
|
||||
[Route("/api/developers/{pubName}/projects/{projectId:guid}/bots")]
|
||||
[Authorize]
|
||||
public class BotAccountController(
|
||||
BotAccountService botService,
|
||||
DeveloperService developerService,
|
||||
DevProjectService projectService,
|
||||
ILogger<BotAccountController> logger,
|
||||
AccountClientHelper accounts,
|
||||
BotAccountReceiverService.BotAccountReceiverServiceClient accountsReceiver
|
||||
)
|
||||
: ControllerBase
|
||||
{
|
||||
public class CommonBotRequest
|
||||
{
|
||||
[MaxLength(256)] public string? FirstName { get; set; }
|
||||
[MaxLength(256)] public string? MiddleName { get; set; }
|
||||
[MaxLength(256)] public string? LastName { get; set; }
|
||||
[MaxLength(1024)] public string? Gender { get; set; }
|
||||
[MaxLength(1024)] public string? Pronouns { get; set; }
|
||||
[MaxLength(1024)] public string? TimeZone { get; set; }
|
||||
[MaxLength(1024)] public string? Location { get; set; }
|
||||
[MaxLength(4096)] public string? Bio { get; set; }
|
||||
public Instant? Birthday { get; set; }
|
||||
|
||||
[MaxLength(32)] public string? PictureId { get; set; }
|
||||
[MaxLength(32)] public string? BackgroundId { get; set; }
|
||||
}
|
||||
|
||||
public class BotCreateRequest : CommonBotRequest
|
||||
{
|
||||
[Required]
|
||||
[MinLength(2)]
|
||||
[MaxLength(256)]
|
||||
[RegularExpression(@"^[A-Za-z0-9_-]+$",
|
||||
ErrorMessage = "Name can only contain letters, numbers, underscores, and hyphens.")
|
||||
]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[Required] [MaxLength(256)] public string Nick { get; set; } = string.Empty;
|
||||
|
||||
[Required] [MaxLength(1024)] public string Slug { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(128)] public string Language { get; set; } = "en-us";
|
||||
}
|
||||
|
||||
public class UpdateBotRequest : CommonBotRequest
|
||||
{
|
||||
[MinLength(2)]
|
||||
[MaxLength(256)]
|
||||
[RegularExpression(@"^[A-Za-z0-9_-]+$",
|
||||
ErrorMessage = "Name can only contain letters, numbers, underscores, and hyphens.")
|
||||
]
|
||||
public string? Name { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(256)] public string? Nick { get; set; } = string.Empty;
|
||||
|
||||
[Required] [MaxLength(1024)] public string? Slug { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(128)] public string? Language { get; set; }
|
||||
|
||||
public bool? IsActive { get; set; }
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> ListBots(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid projectId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await developerService.GetDeveloperByName(pubName);
|
||||
if (developer is null)
|
||||
return NotFound("Developer not found");
|
||||
|
||||
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
|
||||
PublisherMemberRole.Viewer))
|
||||
return StatusCode(403, "You must be an viewer of the developer to list bots");
|
||||
|
||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||
if (project is null)
|
||||
return NotFound("Project not found or you don't have access");
|
||||
|
||||
var bots = await botService.GetBotsByProjectAsync(projectId);
|
||||
return Ok(await botService.LoadBotsAccountAsync(bots));
|
||||
}
|
||||
|
||||
[HttpGet("{botId:guid}")]
|
||||
public async Task<IActionResult> GetBot(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid projectId,
|
||||
[FromRoute] Guid botId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await developerService.GetDeveloperByName(pubName);
|
||||
if (developer is null)
|
||||
return NotFound("Developer not found");
|
||||
|
||||
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
|
||||
PublisherMemberRole.Viewer))
|
||||
return StatusCode(403, "You must be an viewer of the developer to view bot details");
|
||||
|
||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||
if (project is null)
|
||||
return NotFound("Project not found or you don't have access");
|
||||
|
||||
var bot = await botService.GetBotByIdAsync(botId);
|
||||
if (bot is null || bot.ProjectId != projectId)
|
||||
return NotFound("Bot not found");
|
||||
|
||||
return Ok(await botService.LoadBotAccountAsync(bot));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> CreateBot(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid projectId,
|
||||
[FromBody] BotCreateRequest createRequest
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await developerService.GetDeveloperByName(pubName);
|
||||
if (developer is null)
|
||||
return NotFound("Developer not found");
|
||||
|
||||
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
|
||||
PublisherMemberRole.Editor))
|
||||
return StatusCode(403, "You must be an editor of the developer to create a bot");
|
||||
|
||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||
if (project is null)
|
||||
return NotFound("Project not found or you don't have access");
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var accountId = Guid.NewGuid();
|
||||
var account = new Account()
|
||||
{
|
||||
Id = accountId.ToString(),
|
||||
Name = createRequest.Name,
|
||||
Nick = createRequest.Nick,
|
||||
Language = createRequest.Language,
|
||||
Profile = new AccountProfile()
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
Bio = createRequest.Bio,
|
||||
Gender = createRequest.Gender,
|
||||
FirstName = createRequest.FirstName,
|
||||
MiddleName = createRequest.MiddleName,
|
||||
LastName = createRequest.LastName,
|
||||
TimeZone = createRequest.TimeZone,
|
||||
Pronouns = createRequest.Pronouns,
|
||||
Location = createRequest.Location,
|
||||
Birthday = createRequest.Birthday?.ToTimestamp(),
|
||||
AccountId = accountId.ToString(),
|
||||
CreatedAt = now.ToTimestamp(),
|
||||
UpdatedAt = now.ToTimestamp()
|
||||
},
|
||||
CreatedAt = now.ToTimestamp(),
|
||||
UpdatedAt = now.ToTimestamp()
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var bot = await botService.CreateBotAsync(
|
||||
project,
|
||||
createRequest.Slug,
|
||||
account,
|
||||
createRequest.PictureId,
|
||||
createRequest.BackgroundId
|
||||
);
|
||||
return Ok(bot);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error creating bot account");
|
||||
return StatusCode(500, "An error occurred while creating the bot account");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPatch("{botId:guid}")]
|
||||
public async Task<IActionResult> UpdateBot(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid projectId,
|
||||
[FromRoute] Guid botId,
|
||||
[FromBody] UpdateBotRequest request
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await developerService.GetDeveloperByName(pubName);
|
||||
if (developer is null)
|
||||
return NotFound("Developer not found");
|
||||
|
||||
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
|
||||
PublisherMemberRole.Editor))
|
||||
return StatusCode(403, "You must be an editor of the developer to update a bot");
|
||||
|
||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||
if (project is null)
|
||||
return NotFound("Project not found or you don't have access");
|
||||
|
||||
var bot = await botService.GetBotByIdAsync(botId);
|
||||
if (bot is null || bot.ProjectId != projectId)
|
||||
return NotFound("Bot not found");
|
||||
|
||||
var botAccount = await accounts.GetBotAccount(bot.Id);
|
||||
|
||||
if (request.Name is not null) botAccount.Name = request.Name;
|
||||
if (request.Nick is not null) botAccount.Nick = request.Nick;
|
||||
if (request.Language is not null) botAccount.Language = request.Language;
|
||||
if (request.Bio is not null) botAccount.Profile.Bio = request.Bio;
|
||||
if (request.Gender is not null) botAccount.Profile.Gender = request.Gender;
|
||||
if (request.FirstName is not null) botAccount.Profile.FirstName = request.FirstName;
|
||||
if (request.MiddleName is not null) botAccount.Profile.MiddleName = request.MiddleName;
|
||||
if (request.LastName is not null) botAccount.Profile.LastName = request.LastName;
|
||||
if (request.TimeZone is not null) botAccount.Profile.TimeZone = request.TimeZone;
|
||||
if (request.Pronouns is not null) botAccount.Profile.Pronouns = request.Pronouns;
|
||||
if (request.Location is not null) botAccount.Profile.Location = request.Location;
|
||||
if (request.Birthday is not null) botAccount.Profile.Birthday = request.Birthday?.ToTimestamp();
|
||||
|
||||
if (request.Slug is not null) bot.Slug = request.Slug;
|
||||
if (request.IsActive is not null) bot.IsActive = request.IsActive.Value;
|
||||
|
||||
try
|
||||
{
|
||||
var updatedBot = await botService.UpdateBotAsync(
|
||||
bot,
|
||||
botAccount,
|
||||
request.PictureId,
|
||||
request.BackgroundId
|
||||
);
|
||||
|
||||
return Ok(updatedBot);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error updating bot account {BotId}", botId);
|
||||
return StatusCode(500, "An error occurred while updating the bot account");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{botId:guid}")]
|
||||
public async Task<IActionResult> DeleteBot(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid projectId,
|
||||
[FromRoute] Guid botId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await developerService.GetDeveloperByName(pubName);
|
||||
if (developer is null)
|
||||
return NotFound("Developer not found");
|
||||
|
||||
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
|
||||
PublisherMemberRole.Editor))
|
||||
return StatusCode(403, "You must be an editor of the developer to delete a bot");
|
||||
|
||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||
if (project is null)
|
||||
return NotFound("Project not found or you don't have access");
|
||||
|
||||
var bot = await botService.GetBotByIdAsync(botId);
|
||||
if (bot is null || bot.ProjectId != projectId)
|
||||
return NotFound("Bot not found");
|
||||
|
||||
try
|
||||
{
|
||||
await botService.DeleteBotAsync(bot);
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error deleting bot {BotId}", botId);
|
||||
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);
|
||||
}
|
||||
}
|
174
DysonNetwork.Develop/Identity/BotAccountService.cs
Normal file
174
DysonNetwork.Develop/Identity/BotAccountService.cs
Normal file
@@ -0,0 +1,174 @@
|
||||
using DysonNetwork.Develop.Project;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using DysonNetwork.Shared.Registry;
|
||||
using Grpc.Core;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime.Serialization.Protobuf;
|
||||
|
||||
namespace DysonNetwork.Develop.Identity;
|
||||
|
||||
public class BotAccountService(
|
||||
AppDatabase db,
|
||||
BotAccountReceiverService.BotAccountReceiverServiceClient accountReceiver,
|
||||
AccountClientHelper accounts
|
||||
)
|
||||
{
|
||||
public async Task<BotAccount?> GetBotByIdAsync(Guid id)
|
||||
{
|
||||
return await db.BotAccounts
|
||||
.Include(b => b.Project)
|
||||
.FirstOrDefaultAsync(b => b.Id == id);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<BotAccount>> GetBotsByProjectAsync(Guid projectId)
|
||||
{
|
||||
return await db.BotAccounts
|
||||
.Where(b => b.ProjectId == projectId)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<BotAccount> CreateBotAsync(
|
||||
DevProject project,
|
||||
string slug,
|
||||
Account account,
|
||||
string? pictureId,
|
||||
string? backgroundId
|
||||
)
|
||||
{
|
||||
// First, check if a bot with this slug already exists in this project
|
||||
var existingBot = await db.BotAccounts
|
||||
.FirstOrDefaultAsync(b => b.ProjectId == project.Id && b.Slug == slug);
|
||||
|
||||
if (existingBot != null)
|
||||
throw new InvalidOperationException("A bot with this slug already exists in this project.");
|
||||
|
||||
try
|
||||
{
|
||||
var automatedId = Guid.NewGuid();
|
||||
var createRequest = new CreateBotAccountRequest
|
||||
{
|
||||
AutomatedId = automatedId.ToString(),
|
||||
Account = account,
|
||||
PictureId = pictureId,
|
||||
BackgroundId = backgroundId
|
||||
};
|
||||
|
||||
var createResponse = await accountReceiver.CreateBotAccountAsync(createRequest);
|
||||
var botAccount = createResponse.Bot;
|
||||
|
||||
// Then create the local bot account
|
||||
var bot = new BotAccount
|
||||
{
|
||||
Id = automatedId,
|
||||
Slug = slug,
|
||||
ProjectId = project.Id,
|
||||
Project = project,
|
||||
IsActive = botAccount.IsActive,
|
||||
CreatedAt = botAccount.CreatedAt.ToInstant(),
|
||||
UpdatedAt = botAccount.UpdatedAt.ToInstant()
|
||||
};
|
||||
|
||||
db.BotAccounts.Add(bot);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return bot;
|
||||
}
|
||||
catch (RpcException ex) when (ex.StatusCode == StatusCode.AlreadyExists)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"A bot account with this ID already exists in the authentication service.", ex);
|
||||
}
|
||||
catch (RpcException ex) when (ex.StatusCode == StatusCode.InvalidArgument)
|
||||
{
|
||||
throw new ArgumentException($"Invalid bot account data: {ex.Status.Detail}", ex);
|
||||
}
|
||||
catch (RpcException ex)
|
||||
{
|
||||
throw new Exception($"Failed to create bot account: {ex.Status.Detail}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<BotAccount> UpdateBotAsync(
|
||||
BotAccount bot,
|
||||
Account account,
|
||||
string? pictureId,
|
||||
string? backgroundId
|
||||
)
|
||||
{
|
||||
db.Update(bot);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Update the bot account in the Pass service
|
||||
var updateRequest = new UpdateBotAccountRequest
|
||||
{
|
||||
AutomatedId = bot.Id.ToString(),
|
||||
Account = account,
|
||||
PictureId = pictureId,
|
||||
BackgroundId = backgroundId
|
||||
};
|
||||
|
||||
var updateResponse = await accountReceiver.UpdateBotAccountAsync(updateRequest);
|
||||
var updatedBot = updateResponse.Bot;
|
||||
|
||||
// Update local bot account
|
||||
bot.UpdatedAt = updatedBot.UpdatedAt.ToInstant();
|
||||
bot.IsActive = updatedBot.IsActive;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
catch (RpcException ex) when (ex.StatusCode == StatusCode.NotFound)
|
||||
{
|
||||
throw new Exception("Bot account not found in the authentication service", ex);
|
||||
}
|
||||
catch (RpcException ex)
|
||||
{
|
||||
throw new Exception($"Failed to update bot account: {ex.Status.Detail}", ex);
|
||||
}
|
||||
|
||||
return bot;
|
||||
}
|
||||
|
||||
public async Task DeleteBotAsync(BotAccount bot)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Delete the bot account from the Pass service
|
||||
var deleteRequest = new DeleteBotAccountRequest
|
||||
{
|
||||
AutomatedId = bot.Id.ToString(),
|
||||
Force = false
|
||||
};
|
||||
|
||||
await accountReceiver.DeleteBotAccountAsync(deleteRequest);
|
||||
}
|
||||
catch (RpcException ex) when (ex.StatusCode == StatusCode.NotFound)
|
||||
{
|
||||
// Account not found in Pass service, continue with local deletion
|
||||
}
|
||||
|
||||
// Delete the local bot account
|
||||
db.BotAccounts.Remove(bot);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<BotAccount?> LoadBotAccountAsync(BotAccount bot) =>
|
||||
(await LoadBotsAccountAsync([bot])).FirstOrDefault();
|
||||
|
||||
public async Task<List<BotAccount>> LoadBotsAccountAsync(IEnumerable<BotAccount> bots)
|
||||
{
|
||||
bots = bots.ToList();
|
||||
var automatedIds = bots.Select(b => b.Id).ToList();
|
||||
var data = await accounts.GetBotAccountBatch(automatedIds);
|
||||
|
||||
foreach (var bot in bots)
|
||||
{
|
||||
bot.Account = data
|
||||
.Select(AccountReference.FromProtoValue)
|
||||
.FirstOrDefault(e => e.AutomatedId == bot.Id);
|
||||
}
|
||||
|
||||
return bots as List<BotAccount> ?? [];
|
||||
}
|
||||
}
|
@@ -1,6 +1,7 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Develop.Project;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Google.Protobuf;
|
||||
@@ -31,14 +32,17 @@ public class CustomApp : ModelBase, IIdentifiedResource
|
||||
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; }
|
||||
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; }
|
||||
|
||||
[Column(TypeName = "jsonb")] public DysonNetwork.Shared.Data.VerificationMark? Verification { get; set; }
|
||||
[Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; }
|
||||
[Column(TypeName = "jsonb")] public CustomAppOauthConfig? OauthConfig { get; set; }
|
||||
[Column(TypeName = "jsonb")] public CustomAppLinks? Links { get; set; }
|
||||
|
||||
[JsonIgnore] public ICollection<CustomAppSecret> Secrets { get; set; } = new List<CustomAppSecret>();
|
||||
|
||||
public Guid DeveloperId { get; set; }
|
||||
public Developer Developer { get; set; } = null!;
|
||||
public Guid ProjectId { get; set; }
|
||||
public DevProject Project { get; set; } = null!;
|
||||
|
||||
[NotMapped]
|
||||
public Developer Developer => Project.Developer;
|
||||
|
||||
[NotMapped] public string ResourceIdentifier => "custom-app:" + Id;
|
||||
|
||||
@@ -58,21 +62,26 @@ public class CustomApp : ModelBase, IIdentifiedResource
|
||||
CustomAppStatus.Suspended => Shared.Proto.CustomAppStatus.Suspended,
|
||||
_ => Shared.Proto.CustomAppStatus.Unspecified
|
||||
},
|
||||
Picture = Picture is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Picture)),
|
||||
Background = Background is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Background)),
|
||||
Verification = Verification is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Verification)),
|
||||
Links = Links is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Links)),
|
||||
Picture = Picture?.ToProtoValue(),
|
||||
Background = Background?.ToProtoValue(),
|
||||
Verification = Verification?.ToProtoValue(),
|
||||
Links = Links is null ? null : new DysonNetwork.Shared.Proto.CustomAppLinks
|
||||
{
|
||||
HomePage = Links.HomePage ?? string.Empty,
|
||||
PrivacyPolicy = Links.PrivacyPolicy ?? string.Empty,
|
||||
TermsOfService = Links.TermsOfService ?? string.Empty
|
||||
},
|
||||
OauthConfig = OauthConfig is null ? null : new DysonNetwork.Shared.Proto.CustomAppOauthConfig
|
||||
{
|
||||
ClientUri = OauthConfig.ClientUri ?? string.Empty,
|
||||
RedirectUris = { OauthConfig.RedirectUris ?? Array.Empty<string>() },
|
||||
PostLogoutRedirectUris = { OauthConfig.PostLogoutRedirectUris ?? Array.Empty<string>() },
|
||||
AllowedScopes = { OauthConfig.AllowedScopes ?? Array.Empty<string>() },
|
||||
AllowedGrantTypes = { OauthConfig.AllowedGrantTypes ?? Array.Empty<string>() },
|
||||
RedirectUris = { OauthConfig.RedirectUris ?? [] },
|
||||
PostLogoutRedirectUris = { OauthConfig.PostLogoutRedirectUris ?? [] },
|
||||
AllowedScopes = { OauthConfig.AllowedScopes ?? [] },
|
||||
AllowedGrantTypes = { OauthConfig.AllowedGrantTypes ?? [] },
|
||||
RequirePkce = OauthConfig.RequirePkce,
|
||||
AllowOfflineAccess = OauthConfig.AllowOfflineAccess
|
||||
},
|
||||
DeveloperId = DeveloperId.ToString(),
|
||||
ProjectId = ProjectId.ToString(),
|
||||
CreatedAt = CreatedAt.ToTimestamp(),
|
||||
UpdatedAt = UpdatedAt.ToTimestamp()
|
||||
};
|
||||
@@ -92,13 +101,21 @@ public class CustomApp : ModelBase, IIdentifiedResource
|
||||
Shared.Proto.CustomAppStatus.Suspended => CustomAppStatus.Suspended,
|
||||
_ => CustomAppStatus.Developing
|
||||
};
|
||||
DeveloperId = string.IsNullOrEmpty(p.DeveloperId) ? Guid.Empty : Guid.Parse(p.DeveloperId);
|
||||
ProjectId = string.IsNullOrEmpty(p.ProjectId) ? Guid.Empty : Guid.Parse(p.ProjectId);
|
||||
CreatedAt = p.CreatedAt.ToInstant();
|
||||
UpdatedAt = p.UpdatedAt.ToInstant();
|
||||
if (p.Picture.Length > 0) Picture = System.Text.Json.JsonSerializer.Deserialize<CloudFileReferenceObject>(p.Picture.ToStringUtf8());
|
||||
if (p.Background.Length > 0) Background = System.Text.Json.JsonSerializer.Deserialize<CloudFileReferenceObject>(p.Background.ToStringUtf8());
|
||||
if (p.Verification.Length > 0) Verification = System.Text.Json.JsonSerializer.Deserialize<DysonNetwork.Shared.Data.VerificationMark>(p.Verification.ToStringUtf8());
|
||||
if (p.Links.Length > 0) Links = System.Text.Json.JsonSerializer.Deserialize<CustomAppLinks>(p.Links.ToStringUtf8());
|
||||
if (p.Picture is not null) Picture = CloudFileReferenceObject.FromProtoValue(p.Picture);
|
||||
if (p.Background is not null) Background = CloudFileReferenceObject.FromProtoValue(p.Background);
|
||||
if (p.Verification is not null) Verification = VerificationMark.FromProtoValue(p.Verification);
|
||||
if (p.Links is not null)
|
||||
{
|
||||
Links = new CustomAppLinks
|
||||
{
|
||||
HomePage = string.IsNullOrEmpty(p.Links.HomePage) ? null : p.Links.HomePage,
|
||||
PrivacyPolicy = string.IsNullOrEmpty(p.Links.PrivacyPolicy) ? null : p.Links.PrivacyPolicy,
|
||||
TermsOfService = string.IsNullOrEmpty(p.Links.TermsOfService) ? null : p.Links.TermsOfService
|
||||
};
|
||||
}
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
@@ -1,13 +1,16 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Develop.Project;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Develop.Identity;
|
||||
|
||||
[ApiController]
|
||||
[Route("/api/developers/{pubName}/apps")]
|
||||
public class CustomAppController(CustomAppService customApps, DeveloperService ds) : ControllerBase
|
||||
[Route("/api/developers/{pubName}/projects/{projectId:guid}/apps")]
|
||||
public class CustomAppController(CustomAppService customApps, DeveloperService ds, DevProjectService projectService)
|
||||
: ControllerBase
|
||||
{
|
||||
public record CustomAppRequest(
|
||||
[MaxLength(1024)] string? Slug,
|
||||
@@ -20,22 +23,62 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
|
||||
CustomAppOauthConfig? OauthConfig
|
||||
);
|
||||
|
||||
public record CreateSecretRequest(
|
||||
[MaxLength(4096)] string? Description,
|
||||
TimeSpan? ExpiresIn = null,
|
||||
bool IsOidc = false
|
||||
);
|
||||
|
||||
public record SecretResponse(
|
||||
string Id,
|
||||
string? Secret,
|
||||
string? Description,
|
||||
Instant? ExpiresAt,
|
||||
bool IsOidc,
|
||||
Instant CreatedAt,
|
||||
Instant UpdatedAt
|
||||
);
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> ListApps([FromRoute] string pubName)
|
||||
[Authorize]
|
||||
public async Task<IActionResult> ListApps([FromRoute] string pubName, [FromRoute] Guid projectId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await ds.GetDeveloperByName(pubName);
|
||||
if (developer is null) return NotFound();
|
||||
var apps = await customApps.GetAppsByPublisherAsync(developer.Id);
|
||||
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
if (!await ds.IsMemberWithRole(developer.PublisherId, accountId, PublisherMemberRole.Viewer))
|
||||
return StatusCode(403, "You must be a viewer of the developer to list custom apps");
|
||||
|
||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||
if (project is null) return NotFound();
|
||||
|
||||
var apps = await customApps.GetAppsByProjectAsync(projectId);
|
||||
return Ok(apps);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<IActionResult> GetApp([FromRoute] string pubName, Guid id)
|
||||
[HttpGet("{appId:guid}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> GetApp([FromRoute] string pubName, [FromRoute] Guid projectId,
|
||||
[FromRoute] Guid appId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await ds.GetDeveloperByName(pubName);
|
||||
if (developer is null) return NotFound();
|
||||
|
||||
var app = await customApps.GetAppAsync(id, developerId: developer.Id);
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
if (!await ds.IsMemberWithRole(developer.PublisherId, accountId, PublisherMemberRole.Viewer))
|
||||
return StatusCode(403, "You must be a viewer of the developer to list custom apps");
|
||||
|
||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||
if (project is null) return NotFound();
|
||||
|
||||
var app = await customApps.GetAppAsync(appId, projectId);
|
||||
if (app == null)
|
||||
return NotFound();
|
||||
|
||||
@@ -44,23 +87,39 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
|
||||
|
||||
[HttpPost]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> CreateApp([FromRoute] string pubName, [FromBody] CustomAppRequest request)
|
||||
public async Task<IActionResult> CreateApp(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid projectId,
|
||||
[FromBody] CustomAppRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.Slug))
|
||||
return BadRequest("Name and slug are required");
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await ds.GetDeveloperByName(pubName);
|
||||
if (developer is null) return NotFound();
|
||||
if (developer is null)
|
||||
return NotFound("Developer not found");
|
||||
|
||||
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
|
||||
return StatusCode(403, "You must be an editor of the developer to create a custom app");
|
||||
|
||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||
if (project is null)
|
||||
return NotFound("Project not found or you don't have access");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.Slug))
|
||||
return BadRequest("Name and slug are required");
|
||||
|
||||
try
|
||||
{
|
||||
var app = await customApps.CreateAppAsync(developer, request);
|
||||
return Ok(app);
|
||||
var app = await customApps.CreateAppAsync(projectId, request);
|
||||
if (app == null)
|
||||
return BadRequest("Failed to create app");
|
||||
|
||||
return CreatedAtAction(
|
||||
nameof(GetApp),
|
||||
new { pubName, projectId, appId = app.Id },
|
||||
app
|
||||
);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
@@ -68,23 +127,30 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPatch("{id:guid}")]
|
||||
[HttpPatch("{appId:guid}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> UpdateApp(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid id,
|
||||
[FromRoute] Guid projectId,
|
||||
[FromRoute] Guid appId,
|
||||
[FromBody] CustomAppRequest request
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await ds.GetDeveloperByName(pubName);
|
||||
if (developer is null) return NotFound();
|
||||
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 update a custom app");
|
||||
|
||||
var app = await customApps.GetAppAsync(id, developerId: developer.Id);
|
||||
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();
|
||||
|
||||
@@ -99,28 +165,267 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[HttpDelete("{appId:guid}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> DeleteApp(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid id
|
||||
[FromRoute] Guid projectId,
|
||||
[FromRoute] Guid appId
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await ds.GetDeveloperByName(pubName);
|
||||
if (developer is null) return NotFound();
|
||||
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 a custom app");
|
||||
|
||||
var app = await customApps.GetAppAsync(id, developerId: developer.Id);
|
||||
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();
|
||||
|
||||
var result = await customApps.DeleteAppAsync(id);
|
||||
var result = await customApps.DeleteAppAsync(appId);
|
||||
if (!result)
|
||||
return NotFound();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,6 +1,9 @@
|
||||
using DysonNetwork.Develop.Project;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace DysonNetwork.Develop.Identity;
|
||||
|
||||
@@ -11,10 +14,17 @@ public class CustomAppService(
|
||||
)
|
||||
{
|
||||
public async Task<CustomApp?> CreateAppAsync(
|
||||
Developer pub,
|
||||
Guid projectId,
|
||||
CustomAppController.CustomAppRequest request
|
||||
)
|
||||
{
|
||||
var project = await db.DevProjects
|
||||
.Include(p => p.Developer)
|
||||
.FirstOrDefaultAsync(p => p.Id == projectId);
|
||||
|
||||
if (project == null)
|
||||
return null;
|
||||
|
||||
var app = new CustomApp
|
||||
{
|
||||
Slug = request.Slug!,
|
||||
@@ -23,7 +33,7 @@ public class CustomAppService(
|
||||
Status = request.Status ?? CustomAppStatus.Developing,
|
||||
Links = request.Links,
|
||||
OauthConfig = request.OauthConfig,
|
||||
DeveloperId = pub.Id
|
||||
ProjectId = projectId
|
||||
};
|
||||
|
||||
if (request.PictureId is not null)
|
||||
@@ -74,17 +84,104 @@ public class CustomAppService(
|
||||
return app;
|
||||
}
|
||||
|
||||
public async Task<CustomApp?> GetAppAsync(Guid id, Guid? developerId = null)
|
||||
public async Task<CustomApp?> GetAppAsync(Guid id, Guid? projectId = null)
|
||||
{
|
||||
var query = db.CustomApps.Where(a => a.Id == id).AsQueryable();
|
||||
if (developerId.HasValue)
|
||||
query = query.Where(a => a.DeveloperId == developerId.Value);
|
||||
return await query.FirstOrDefaultAsync();
|
||||
var query = db.CustomApps.AsQueryable();
|
||||
|
||||
if (projectId.HasValue)
|
||||
{
|
||||
query = query.Where(a => a.ProjectId == projectId.Value);
|
||||
}
|
||||
|
||||
return await query.FirstOrDefaultAsync(a => a.Id == id);
|
||||
}
|
||||
|
||||
public async Task<List<CustomApp>> GetAppsByPublisherAsync(Guid publisherId)
|
||||
public async Task<List<CustomAppSecret>> GetAppSecretsAsync(Guid appId)
|
||||
{
|
||||
return await db.CustomApps.Where(a => a.DeveloperId == publisherId).ToListAsync();
|
||||
return await db.CustomAppSecrets
|
||||
.Where(s => s.AppId == appId)
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<CustomAppSecret?> GetAppSecretAsync(Guid secretId, Guid appId)
|
||||
{
|
||||
return await db.CustomAppSecrets
|
||||
.FirstOrDefaultAsync(s => s.Id == secretId && s.AppId == appId);
|
||||
}
|
||||
|
||||
public async Task<CustomAppSecret> CreateAppSecretAsync(CustomAppSecret secret)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(secret.Secret))
|
||||
{
|
||||
// Generate a new random secret if not provided
|
||||
secret.Secret = GenerateRandomSecret();
|
||||
}
|
||||
|
||||
secret.Id = Guid.NewGuid();
|
||||
secret.CreatedAt = NodaTime.SystemClock.Instance.GetCurrentInstant();
|
||||
secret.UpdatedAt = secret.CreatedAt;
|
||||
|
||||
db.CustomAppSecrets.Add(secret);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return secret;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAppSecretAsync(Guid secretId, Guid appId)
|
||||
{
|
||||
var secret = await db.CustomAppSecrets
|
||||
.FirstOrDefaultAsync(s => s.Id == secretId && s.AppId == appId);
|
||||
|
||||
if (secret == null)
|
||||
return false;
|
||||
|
||||
db.CustomAppSecrets.Remove(secret);
|
||||
await db.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<CustomAppSecret> RotateAppSecretAsync(CustomAppSecret secretUpdate)
|
||||
{
|
||||
var existingSecret = await db.CustomAppSecrets
|
||||
.FirstOrDefaultAsync(s => s.Id == secretUpdate.Id && s.AppId == secretUpdate.AppId);
|
||||
|
||||
if (existingSecret == null)
|
||||
throw new InvalidOperationException("Secret not found");
|
||||
|
||||
// Update the existing secret with new values
|
||||
existingSecret.Secret = GenerateRandomSecret();
|
||||
existingSecret.Description = secretUpdate.Description ?? existingSecret.Description;
|
||||
existingSecret.ExpiredAt = secretUpdate.ExpiredAt ?? existingSecret.ExpiredAt;
|
||||
existingSecret.IsOidc = secretUpdate.IsOidc;
|
||||
existingSecret.UpdatedAt = NodaTime.SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return existingSecret;
|
||||
}
|
||||
|
||||
private static string GenerateRandomSecret(int length = 64)
|
||||
{
|
||||
const string valid = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890-._~+";
|
||||
var res = new StringBuilder();
|
||||
using (var rng = RandomNumberGenerator.Create())
|
||||
{
|
||||
var uintBuffer = new byte[sizeof(uint)];
|
||||
while (length-- > 0)
|
||||
{
|
||||
rng.GetBytes(uintBuffer);
|
||||
var num = BitConverter.ToUInt32(uintBuffer, 0);
|
||||
res.Append(valid[(int)(num % (uint)valid.Length)]);
|
||||
}
|
||||
}
|
||||
return res.ToString();
|
||||
}
|
||||
|
||||
public async Task<List<CustomApp>> GetAppsByProjectAsync(Guid projectId)
|
||||
{
|
||||
return await db.CustomApps
|
||||
.Where(a => a.ProjectId == projectId)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<CustomApp?> UpdateAppAsync(CustomApp app, CustomAppController.CustomAppRequest request)
|
||||
|
@@ -1,4 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Develop.Project;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using VerificationMark = DysonNetwork.Shared.Data.VerificationMark;
|
||||
@@ -10,6 +12,8 @@ public class Developer
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid PublisherId { get; set; }
|
||||
|
||||
[JsonIgnore] public List<DevProject> Projects { get; set; } = [];
|
||||
|
||||
[NotMapped] public PublisherInfo? Publisher { get; set; }
|
||||
}
|
||||
|
||||
|
@@ -33,7 +33,8 @@ public class DeveloperController(
|
||||
|
||||
// Get custom apps count
|
||||
var customAppsCount = await db.CustomApps
|
||||
.Where(a => a.DeveloperId == developer.Id)
|
||||
.Include(a => a.Project)
|
||||
.Where(a => a.Project.DeveloperId == developer.Id)
|
||||
.CountAsync();
|
||||
|
||||
var stats = new DeveloperStats
|
||||
|
@@ -4,7 +4,10 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DysonNetwork.Develop.Identity;
|
||||
|
||||
public class DeveloperService(AppDatabase db, PublisherService.PublisherServiceClient ps, ILogger<DeveloperService> logger)
|
||||
public class DeveloperService(
|
||||
AppDatabase db,
|
||||
PublisherService.PublisherServiceClient ps,
|
||||
ILogger<DeveloperService> logger)
|
||||
{
|
||||
public async Task<Developer> LoadDeveloperPublisher(Developer developer)
|
||||
{
|
||||
@@ -47,6 +50,11 @@ public class DeveloperService(AppDatabase db, PublisherService.PublisherServiceC
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Developer?> GetDeveloperById(Guid id)
|
||||
{
|
||||
return await db.Developers.FirstOrDefaultAsync(d => d.Id == id);
|
||||
}
|
||||
|
||||
public async Task<bool> IsMemberWithRole(Guid pubId, Guid accountId, PublisherMemberRole role)
|
||||
{
|
||||
try
|
||||
|
270
DysonNetwork.Develop/Migrations/20250818124844_AddDevProject.Designer.cs
generated
Normal file
270
DysonNetwork.Develop/Migrations/20250818124844_AddDevProject.Designer.cs
generated
Normal file
@@ -0,0 +1,270 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DysonNetwork.Develop;
|
||||
using DysonNetwork.Develop.Identity;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NodaTime;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Develop.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDatabase))]
|
||||
[Migration("20250818124844_AddDevProject")]
|
||||
partial class AddDevProject
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<CloudFileReferenceObject>("Background")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("background");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<CustomAppLinks>("Links")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("links");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<CustomAppOauthConfig>("OauthConfig")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("oauth_config");
|
||||
|
||||
b.Property<CloudFileReferenceObject>("Picture")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("picture");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("project_id");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("slug");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<VerificationMark>("Verification")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("verification");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_custom_apps");
|
||||
|
||||
b.HasIndex("ProjectId")
|
||||
.HasDatabaseName("ix_custom_apps_project_id");
|
||||
|
||||
b.ToTable("custom_apps", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("AppId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("app_id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Instant?>("ExpiredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<bool>("IsOidc")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_oidc");
|
||||
|
||||
b.Property<string>("Secret")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("secret");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_custom_app_secrets");
|
||||
|
||||
b.HasIndex("AppId")
|
||||
.HasDatabaseName("ix_custom_app_secrets_app_id");
|
||||
|
||||
b.ToTable("custom_app_secrets", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("PublisherId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("publisher_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_developers");
|
||||
|
||||
b.ToTable("developers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Guid>("DeveloperId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("developer_id");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("slug");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_dev_projects");
|
||||
|
||||
b.HasIndex("DeveloperId")
|
||||
.HasDatabaseName("ix_dev_projects_developer_id");
|
||||
|
||||
b.ToTable("dev_projects", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Develop.Project.DevProject", "Project")
|
||||
.WithMany()
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_custom_apps_dev_projects_project_id");
|
||||
|
||||
b.Navigation("Project");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Develop.Identity.CustomApp", "App")
|
||||
.WithMany("Secrets")
|
||||
.HasForeignKey("AppId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_custom_app_secrets_custom_apps_app_id");
|
||||
|
||||
b.Navigation("App");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Develop.Identity.Developer", "Developer")
|
||||
.WithMany("Projects")
|
||||
.HasForeignKey("DeveloperId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_dev_projects_developers_developer_id");
|
||||
|
||||
b.Navigation("Developer");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
|
||||
{
|
||||
b.Navigation("Secrets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b =>
|
||||
{
|
||||
b.Navigation("Projects");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,96 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using NodaTime;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Develop.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddDevProject : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "fk_custom_apps_developers_developer_id",
|
||||
table: "custom_apps");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "developer_id",
|
||||
table: "custom_apps",
|
||||
newName: "project_id");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "ix_custom_apps_developer_id",
|
||||
table: "custom_apps",
|
||||
newName: "ix_custom_apps_project_id");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "dev_projects",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
slug = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||
name = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||
description = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false),
|
||||
developer_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_dev_projects", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_dev_projects_developers_developer_id",
|
||||
column: x => x.developer_id,
|
||||
principalTable: "developers",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_dev_projects_developer_id",
|
||||
table: "dev_projects",
|
||||
column: "developer_id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "fk_custom_apps_dev_projects_project_id",
|
||||
table: "custom_apps",
|
||||
column: "project_id",
|
||||
principalTable: "dev_projects",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "fk_custom_apps_dev_projects_project_id",
|
||||
table: "custom_apps");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "dev_projects");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "project_id",
|
||||
table: "custom_apps",
|
||||
newName: "developer_id");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "ix_custom_apps_project_id",
|
||||
table: "custom_apps",
|
||||
newName: "ix_custom_apps_developer_id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "fk_custom_apps_developers_developer_id",
|
||||
table: "custom_apps",
|
||||
column: "developer_id",
|
||||
principalTable: "developers",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
}
|
||||
}
|
324
DysonNetwork.Develop/Migrations/20250819163227_AddBotAccount.Designer.cs
generated
Normal file
324
DysonNetwork.Develop/Migrations/20250819163227_AddBotAccount.Designer.cs
generated
Normal file
@@ -0,0 +1,324 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DysonNetwork.Develop;
|
||||
using DysonNetwork.Develop.Identity;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NodaTime;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Develop.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDatabase))]
|
||||
[Migration("20250819163227_AddBotAccount")]
|
||||
partial class AddBotAccount
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.BotAccount", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_active");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("project_id");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("slug");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_bot_accounts");
|
||||
|
||||
b.HasIndex("ProjectId")
|
||||
.HasDatabaseName("ix_bot_accounts_project_id");
|
||||
|
||||
b.ToTable("bot_accounts", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<CloudFileReferenceObject>("Background")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("background");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<CustomAppLinks>("Links")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("links");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<CustomAppOauthConfig>("OauthConfig")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("oauth_config");
|
||||
|
||||
b.Property<CloudFileReferenceObject>("Picture")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("picture");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("project_id");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("slug");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<VerificationMark>("Verification")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("verification");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_custom_apps");
|
||||
|
||||
b.HasIndex("ProjectId")
|
||||
.HasDatabaseName("ix_custom_apps_project_id");
|
||||
|
||||
b.ToTable("custom_apps", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("AppId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("app_id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Instant?>("ExpiredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<bool>("IsOidc")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_oidc");
|
||||
|
||||
b.Property<string>("Secret")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("secret");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_custom_app_secrets");
|
||||
|
||||
b.HasIndex("AppId")
|
||||
.HasDatabaseName("ix_custom_app_secrets_app_id");
|
||||
|
||||
b.ToTable("custom_app_secrets", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("PublisherId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("publisher_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_developers");
|
||||
|
||||
b.ToTable("developers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Guid>("DeveloperId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("developer_id");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("slug");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_dev_projects");
|
||||
|
||||
b.HasIndex("DeveloperId")
|
||||
.HasDatabaseName("ix_dev_projects_developer_id");
|
||||
|
||||
b.ToTable("dev_projects", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.BotAccount", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Develop.Project.DevProject", "Project")
|
||||
.WithMany()
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_bot_accounts_dev_projects_project_id");
|
||||
|
||||
b.Navigation("Project");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Develop.Project.DevProject", "Project")
|
||||
.WithMany()
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_custom_apps_dev_projects_project_id");
|
||||
|
||||
b.Navigation("Project");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Develop.Identity.CustomApp", "App")
|
||||
.WithMany("Secrets")
|
||||
.HasForeignKey("AppId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_custom_app_secrets_custom_apps_app_id");
|
||||
|
||||
b.Navigation("App");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Develop.Identity.Developer", "Developer")
|
||||
.WithMany("Projects")
|
||||
.HasForeignKey("DeveloperId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_dev_projects_developers_developer_id");
|
||||
|
||||
b.Navigation("Developer");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
|
||||
{
|
||||
b.Navigation("Secrets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b =>
|
||||
{
|
||||
b.Navigation("Projects");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using NodaTime;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Develop.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddBotAccount : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "bot_accounts",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
slug = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||
is_active = table.Column<bool>(type: "boolean", nullable: false),
|
||||
project_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_bot_accounts", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_bot_accounts_dev_projects_project_id",
|
||||
column: x => x.project_id,
|
||||
principalTable: "dev_projects",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_bot_accounts_project_id",
|
||||
table: "bot_accounts",
|
||||
column: "project_id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "bot_accounts");
|
||||
}
|
||||
}
|
||||
}
|
@@ -25,6 +25,48 @@ namespace DysonNetwork.Develop.Migrations
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.BotAccount", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_active");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("project_id");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("slug");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_bot_accounts");
|
||||
|
||||
b.HasIndex("ProjectId")
|
||||
.HasDatabaseName("ix_bot_accounts_project_id");
|
||||
|
||||
b.ToTable("bot_accounts", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -49,10 +91,6 @@ namespace DysonNetwork.Develop.Migrations
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Guid>("DeveloperId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("developer_id");
|
||||
|
||||
b.Property<CustomAppLinks>("Links")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("links");
|
||||
@@ -71,6 +109,10 @@ namespace DysonNetwork.Develop.Migrations
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("picture");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("project_id");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
@@ -92,8 +134,8 @@ namespace DysonNetwork.Develop.Migrations
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_custom_apps");
|
||||
|
||||
b.HasIndex("DeveloperId")
|
||||
.HasDatabaseName("ix_custom_apps_developer_id");
|
||||
b.HasIndex("ProjectId")
|
||||
.HasDatabaseName("ix_custom_apps_project_id");
|
||||
|
||||
b.ToTable("custom_apps", (string)null);
|
||||
});
|
||||
@@ -166,16 +208,78 @@ namespace DysonNetwork.Develop.Migrations
|
||||
b.ToTable("developers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Develop.Identity.Developer", "Developer")
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Guid>("DeveloperId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("developer_id");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("slug");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_dev_projects");
|
||||
|
||||
b.HasIndex("DeveloperId")
|
||||
.HasDatabaseName("ix_dev_projects_developer_id");
|
||||
|
||||
b.ToTable("dev_projects", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.BotAccount", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Develop.Project.DevProject", "Project")
|
||||
.WithMany()
|
||||
.HasForeignKey("DeveloperId")
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_custom_apps_developers_developer_id");
|
||||
.HasConstraintName("fk_bot_accounts_dev_projects_project_id");
|
||||
|
||||
b.Navigation("Developer");
|
||||
b.Navigation("Project");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Develop.Project.DevProject", "Project")
|
||||
.WithMany()
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_custom_apps_dev_projects_project_id");
|
||||
|
||||
b.Navigation("Project");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b =>
|
||||
@@ -190,10 +294,27 @@ namespace DysonNetwork.Develop.Migrations
|
||||
b.Navigation("App");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Project.DevProject", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Develop.Identity.Developer", "Developer")
|
||||
.WithMany("Projects")
|
||||
.HasForeignKey("DeveloperId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_dev_projects_developers_developer_id");
|
||||
|
||||
b.Navigation("Developer");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
|
||||
{
|
||||
b.Navigation("Secrets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b =>
|
||||
{
|
||||
b.Navigation("Projects");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
|
@@ -3,6 +3,7 @@ using DysonNetwork.Shared.Auth;
|
||||
using DysonNetwork.Shared.Http;
|
||||
using DysonNetwork.Shared.Registry;
|
||||
using DysonNetwork.Develop.Startup;
|
||||
using DysonNetwork.Shared.Stream;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
@@ -10,6 +11,7 @@ var builder = WebApplication.CreateBuilder(args);
|
||||
builder.ConfigureAppKestrel(builder.Configuration);
|
||||
|
||||
builder.Services.AddRegistryService(builder.Configuration);
|
||||
builder.Services.AddStreamConnection(builder.Configuration);
|
||||
builder.Services.AddAppServices(builder.Configuration);
|
||||
builder.Services.AddAppAuthentication();
|
||||
builder.Services.AddAppSwagger();
|
||||
|
16
DysonNetwork.Develop/Project/DevProject.cs
Normal file
16
DysonNetwork.Develop/Project/DevProject.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Develop.Identity;
|
||||
using DysonNetwork.Shared.Data;
|
||||
|
||||
namespace DysonNetwork.Develop.Project;
|
||||
|
||||
public class DevProject : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
[MaxLength(1024)] public string Slug { get; set; } = string.Empty;
|
||||
[MaxLength(1024)] public string Name { get; set; } = string.Empty;
|
||||
[MaxLength(4096)] public string Description { get; set; } = string.Empty;
|
||||
|
||||
public Developer Developer { get; set; } = null!;
|
||||
public Guid DeveloperId { get; set; }
|
||||
}
|
107
DysonNetwork.Develop/Project/DevProjectController.cs
Normal file
107
DysonNetwork.Develop/Project/DevProjectController.cs
Normal file
@@ -0,0 +1,107 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Develop.Identity;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
|
||||
namespace DysonNetwork.Develop.Project;
|
||||
|
||||
[ApiController]
|
||||
[Route("/api/developers/{pubName}/projects")]
|
||||
public class DevProjectController(DevProjectService projectService, DeveloperService developerService) : ControllerBase
|
||||
{
|
||||
public record DevProjectRequest(
|
||||
[MaxLength(1024)] string? Slug,
|
||||
[MaxLength(1024)] string? Name,
|
||||
[MaxLength(4096)] string? Description
|
||||
);
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> ListProjects([FromRoute] string pubName)
|
||||
{
|
||||
var developer = await developerService.GetDeveloperByName(pubName);
|
||||
if (developer is null) return NotFound();
|
||||
|
||||
var projects = await projectService.GetProjectsByDeveloperAsync(developer.Id);
|
||||
return Ok(projects);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<IActionResult> GetProject([FromRoute] string pubName, Guid id)
|
||||
{
|
||||
var developer = await developerService.GetDeveloperByName(pubName);
|
||||
if (developer is null) return NotFound();
|
||||
|
||||
var project = await projectService.GetProjectAsync(id, developer.Id);
|
||||
if (project is null) return NotFound();
|
||||
|
||||
return Ok(project);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> CreateProject([FromRoute] string pubName, [FromBody] DevProjectRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await developerService.GetDeveloperByName(pubName);
|
||||
if (developer is null)
|
||||
return NotFound("Developer not found");
|
||||
|
||||
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
|
||||
return StatusCode(403, "You must be an editor of the developer to create a project");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Slug) || string.IsNullOrWhiteSpace(request.Name))
|
||||
return BadRequest("Slug and Name are required");
|
||||
|
||||
var project = await projectService.CreateProjectAsync(developer, request);
|
||||
return CreatedAtAction(
|
||||
nameof(GetProject),
|
||||
new { pubName, id = project.Id },
|
||||
project
|
||||
);
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> UpdateProject(
|
||||
[FromRoute] string pubName,
|
||||
[FromRoute] Guid id,
|
||||
[FromBody] DevProjectRequest request
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await developerService.GetDeveloperByName(pubName);
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
if (developer is null || developer.Id != accountId)
|
||||
return Forbid();
|
||||
|
||||
var project = await projectService.UpdateProjectAsync(id, developer.Id, request);
|
||||
if (project is null)
|
||||
return NotFound();
|
||||
|
||||
return Ok(project);
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> DeleteProject([FromRoute] string pubName, [FromRoute] Guid id)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await developerService.GetDeveloperByName(pubName);
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
if (developer is null || developer.Id != accountId)
|
||||
return Forbid();
|
||||
|
||||
var success = await projectService.DeleteProjectAsync(id, developer.Id);
|
||||
if (!success)
|
||||
return NotFound();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
77
DysonNetwork.Develop/Project/DevProjectService.cs
Normal file
77
DysonNetwork.Develop/Project/DevProjectService.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using DysonNetwork.Develop.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
|
||||
namespace DysonNetwork.Develop.Project;
|
||||
|
||||
public class DevProjectService(
|
||||
AppDatabase db,
|
||||
FileReferenceService.FileReferenceServiceClient fileRefs,
|
||||
FileService.FileServiceClient files
|
||||
)
|
||||
{
|
||||
public async Task<DevProject> CreateProjectAsync(
|
||||
Developer developer,
|
||||
DevProjectController.DevProjectRequest request
|
||||
)
|
||||
{
|
||||
var project = new DevProject
|
||||
{
|
||||
Slug = request.Slug!,
|
||||
Name = request.Name!,
|
||||
Description = request.Description ?? string.Empty,
|
||||
DeveloperId = developer.Id
|
||||
};
|
||||
|
||||
db.DevProjects.Add(project);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return project;
|
||||
}
|
||||
|
||||
public async Task<DevProject?> GetProjectAsync(Guid id, Guid? developerId = null)
|
||||
{
|
||||
var query = db.DevProjects.AsQueryable();
|
||||
|
||||
if (developerId.HasValue)
|
||||
{
|
||||
query = query.Where(p => p.DeveloperId == developerId.Value);
|
||||
}
|
||||
|
||||
return await query.FirstOrDefaultAsync(p => p.Id == id);
|
||||
}
|
||||
|
||||
public async Task<List<DevProject>> GetProjectsByDeveloperAsync(Guid developerId)
|
||||
{
|
||||
return await db.DevProjects
|
||||
.Where(p => p.DeveloperId == developerId)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<DevProject?> UpdateProjectAsync(
|
||||
Guid id,
|
||||
Guid developerId,
|
||||
DevProjectController.DevProjectRequest request
|
||||
)
|
||||
{
|
||||
var project = await GetProjectAsync(id, developerId);
|
||||
if (project == null) return null;
|
||||
|
||||
if (request.Slug != null) project.Slug = request.Slug;
|
||||
if (request.Name != null) project.Name = request.Name;
|
||||
if (request.Description != null) project.Description = request.Description;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return project;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteProjectAsync(Guid id, Guid developerId)
|
||||
{
|
||||
var project = await GetProjectAsync(id, developerId);
|
||||
if (project == null) return false;
|
||||
|
||||
db.DevProjects.Remove(project);
|
||||
await db.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
}
|
@@ -4,6 +4,7 @@ using NodaTime;
|
||||
using NodaTime.Serialization.SystemTextJson;
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Develop.Identity;
|
||||
using DysonNetwork.Develop.Project;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using StackExchange.Redis;
|
||||
|
||||
@@ -50,6 +51,8 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
services.AddScoped<DeveloperService>();
|
||||
services.AddScoped<CustomAppService>();
|
||||
services.AddScoped<DevProjectService>();
|
||||
services.AddScoped<BotAccountService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
@@ -12,7 +12,8 @@
|
||||
"ConnectionStrings": {
|
||||
"App": "Host=localhost;Port=5432;Database=dyson_network_dev;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60",
|
||||
"FastRetrieve": "localhost:6379",
|
||||
"Etcd": "etcd.orb.local:2379"
|
||||
"Etcd": "etcd.orb.local:2379",
|
||||
"Stream": "nats.orb.local:4222"
|
||||
},
|
||||
"KnownProxies": [
|
||||
"127.0.0.1",
|
||||
@@ -23,7 +24,7 @@
|
||||
},
|
||||
"Service": {
|
||||
"Name": "DysonNetwork.Develop",
|
||||
"Url": "https://localhost:7099",
|
||||
"Url": "https://localhost:7192",
|
||||
"ClientCert": "../Certificates/client.crt",
|
||||
"ClientKey": "../Certificates/client.key"
|
||||
}
|
||||
|
404
DysonNetwork.Drive/Migrations/20250819164302_RemoveUploadedTo.Designer.cs
generated
Normal file
404
DysonNetwork.Drive/Migrations/20250819164302_RemoveUploadedTo.Designer.cs
generated
Normal file
@@ -0,0 +1,404 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using DysonNetwork.Drive;
|
||||
using DysonNetwork.Drive.Storage;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NodaTime;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Drive.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDatabase))]
|
||||
[Migration("20250819164302_RemoveUploadedTo")]
|
||||
partial class RemoveUploadedTo
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Billing.QuotaRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("account_id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Instant?>("ExpiredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<long>("Quota")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("quota");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_quota_records");
|
||||
|
||||
b.ToTable("quota_records", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("account_id");
|
||||
|
||||
b.Property<Guid?>("BundleId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("bundle_id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Instant?>("ExpiredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<Dictionary<string, object>>("FileMeta")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("file_meta");
|
||||
|
||||
b.Property<bool>("HasCompression")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("has_compression");
|
||||
|
||||
b.Property<bool>("HasThumbnail")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("has_thumbnail");
|
||||
|
||||
b.Property<string>("Hash")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("hash");
|
||||
|
||||
b.Property<bool>("IsEncrypted")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_encrypted");
|
||||
|
||||
b.Property<bool>("IsMarkedRecycle")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_marked_recycle");
|
||||
|
||||
b.Property<string>("MimeType")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("mime_type");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<Guid?>("PoolId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("pool_id");
|
||||
|
||||
b.Property<List<ContentSensitiveMark>>("SensitiveMarks")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("sensitive_marks");
|
||||
|
||||
b.Property<long>("Size")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("size");
|
||||
|
||||
b.Property<string>("StorageId")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("storage_id");
|
||||
|
||||
b.Property<string>("StorageUrl")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("storage_url");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<Instant?>("UploadedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("uploaded_at");
|
||||
|
||||
b.Property<Dictionary<string, object>>("UserMeta")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("user_meta");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_files");
|
||||
|
||||
b.HasIndex("BundleId")
|
||||
.HasDatabaseName("ix_files_bundle_id");
|
||||
|
||||
b.HasIndex("PoolId")
|
||||
.HasDatabaseName("ix_files_pool_id");
|
||||
|
||||
b.ToTable("files", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<Instant?>("ExpiredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<string>("FileId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("file_id");
|
||||
|
||||
b.Property<string>("ResourceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("resource_id");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<string>("Usage")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("usage");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_file_references");
|
||||
|
||||
b.HasIndex("FileId")
|
||||
.HasDatabaseName("ix_file_references_file_id");
|
||||
|
||||
b.ToTable("file_references", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("account_id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(8192)
|
||||
.HasColumnType("character varying(8192)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<Instant?>("ExpiredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("Passcode")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("passcode");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("slug");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_bundles");
|
||||
|
||||
b.HasIndex("Slug")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_bundles_slug");
|
||||
|
||||
b.ToTable("bundles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.FilePool", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid?>("AccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("account_id");
|
||||
|
||||
b.Property<BillingConfig>("BillingConfig")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("billing_config");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8192)
|
||||
.HasColumnType("character varying(8192)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<bool>("IsHidden")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_hidden");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<PolicyConfig>("PolicyConfig")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("policy_config");
|
||||
|
||||
b.Property<RemoteStorageConfig>("StorageConfig")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("storage_config");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_pools");
|
||||
|
||||
b.ToTable("pools", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Drive.Storage.FileBundle", "Bundle")
|
||||
.WithMany("Files")
|
||||
.HasForeignKey("BundleId")
|
||||
.HasConstraintName("fk_files_bundles_bundle_id");
|
||||
|
||||
b.HasOne("DysonNetwork.Drive.Storage.FilePool", "Pool")
|
||||
.WithMany()
|
||||
.HasForeignKey("PoolId")
|
||||
.HasConstraintName("fk_files_pools_pool_id");
|
||||
|
||||
b.Navigation("Bundle");
|
||||
|
||||
b.Navigation("Pool");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File")
|
||||
.WithMany("References")
|
||||
.HasForeignKey("FileId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_file_references_files_file_id");
|
||||
|
||||
b.Navigation("File");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
|
||||
{
|
||||
b.Navigation("References");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b =>
|
||||
{
|
||||
b.Navigation("Files");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Drive.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RemoveUploadedTo : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "uploaded_to",
|
||||
table: "files");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "uploaded_to",
|
||||
table: "files",
|
||||
type: "character varying(128)",
|
||||
maxLength: 128,
|
||||
nullable: true);
|
||||
}
|
||||
}
|
||||
}
|
@@ -172,11 +172,6 @@ namespace DysonNetwork.Drive.Migrations
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("uploaded_at");
|
||||
|
||||
b.Property<string>("UploadedTo")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("uploaded_to");
|
||||
|
||||
b.Property<Dictionary<string, object>>("UserMeta")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("user_meta");
|
||||
@@ -382,7 +377,7 @@ namespace DysonNetwork.Drive.Migrations
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File")
|
||||
.WithMany()
|
||||
.WithMany("References")
|
||||
.HasForeignKey("FileId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
@@ -391,6 +386,11 @@ namespace DysonNetwork.Drive.Migrations
|
||||
b.Navigation("File");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
|
||||
{
|
||||
b.Navigation("References");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b =>
|
||||
{
|
||||
b.Navigation("Files");
|
||||
|
@@ -5,6 +5,7 @@ using DysonNetwork.Shared.Auth;
|
||||
using DysonNetwork.Shared.Http;
|
||||
using DysonNetwork.Shared.PageData;
|
||||
using DysonNetwork.Shared.Registry;
|
||||
using DysonNetwork.Shared.Stream;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using tusdotnet.Stores;
|
||||
|
||||
@@ -15,6 +16,7 @@ builder.ConfigureAppKestrel(builder.Configuration, maxRequestBodySize: long.MaxV
|
||||
|
||||
// Add application services
|
||||
builder.Services.AddRegistryService(builder.Configuration);
|
||||
builder.Services.AddStreamConnection(builder.Configuration);
|
||||
builder.Services.AddAppServices(builder.Configuration);
|
||||
builder.Services.AddAppRateLimiting();
|
||||
builder.Services.AddAppAuthentication();
|
||||
|
56
DysonNetwork.Drive/Startup/BroadcastEventHandler.cs
Normal file
56
DysonNetwork.Drive/Startup/BroadcastEventHandler.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Drive.Storage;
|
||||
using DysonNetwork.Shared.Stream;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NATS.Client.Core;
|
||||
|
||||
namespace DysonNetwork.Drive.Startup;
|
||||
|
||||
public class BroadcastEventHandler(
|
||||
INatsConnection nats,
|
||||
ILogger<BroadcastEventHandler> logger,
|
||||
IServiceProvider serviceProvider
|
||||
) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await foreach (var msg in nats.SubscribeAsync<byte[]>("accounts.deleted", cancellationToken: stoppingToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
var evt = JsonSerializer.Deserialize<AccountDeletedEvent>(msg.Data);
|
||||
if (evt == null) continue;
|
||||
|
||||
logger.LogInformation("Account deleted: {AccountId}", evt.AccountId);
|
||||
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var fs = scope.ServiceProvider.GetRequiredService<FileService>();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
|
||||
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken: stoppingToken);
|
||||
try
|
||||
{
|
||||
var files = await db.Files
|
||||
.Where(p => p.AccountId == evt.AccountId)
|
||||
.ToListAsync(cancellationToken: stoppingToken);
|
||||
|
||||
await fs.DeleteFileDataBatchAsync(files);
|
||||
await db.Files
|
||||
.Where(p => p.AccountId == evt.AccountId)
|
||||
.ExecuteDeleteAsync(cancellationToken: stoppingToken);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken: stoppingToken);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken: stoppingToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error processing AccountDeleted");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -141,6 +141,8 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<Billing.UsageService>();
|
||||
services.AddScoped<Billing.QuotaService>();
|
||||
|
||||
services.AddHostedService<BroadcastEventHandler>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
@@ -33,10 +33,6 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
|
||||
[JsonIgnore] public FileBundle? Bundle { get; set; }
|
||||
public Guid? BundleId { get; set; }
|
||||
|
||||
[Obsolete("Deprecated, use PoolId instead. For database migration only.")]
|
||||
[MaxLength(128)]
|
||||
public string? UploadedTo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The field is set to true if the recycling job plans to delete the file.
|
||||
/// Due to the unstable of the recycling job, this doesn't really delete the file until a human verifies it.
|
||||
@@ -61,6 +57,8 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? FastUploadLink { get; set; }
|
||||
|
||||
public ICollection<CloudFileReference> References { get; set; } = new List<CloudFileReference>();
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
|
||||
public CloudFileReferenceObject ToReferenceObject()
|
||||
|
@@ -190,10 +190,8 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
|
||||
.Where(r => r.ResourceId == resourceId && r.Usage == usage)
|
||||
.ToListAsync();
|
||||
|
||||
if (!references.Any())
|
||||
{
|
||||
if (references.Count == 0)
|
||||
return 0;
|
||||
}
|
||||
|
||||
var fileIds = references.Select(r => r.FileId).Distinct().ToList();
|
||||
|
||||
@@ -208,6 +206,28 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
public async Task<int> DeleteResourceReferencesBatchAsync(IEnumerable<string> resourceIds, string? usage = null)
|
||||
{
|
||||
var references = await db.FileReferences
|
||||
.Where(r => resourceIds.Contains(r.ResourceId))
|
||||
.If(usage != null, q => q.Where(q => q.Usage == usage))
|
||||
.ToListAsync();
|
||||
|
||||
if (references.Count == 0)
|
||||
return 0;
|
||||
|
||||
var fileIds = references.Select(r => r.FileId).Distinct().ToList();
|
||||
|
||||
db.FileReferences.RemoveRange(references);
|
||||
var deletedCount = await db.SaveChangesAsync();
|
||||
|
||||
// Purge caches
|
||||
var tasks = fileIds.Select(fileService._PurgeCacheAsync).ToList();
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a specific file reference
|
||||
/// </summary>
|
||||
|
@@ -85,7 +85,7 @@ namespace DysonNetwork.Drive.Storage
|
||||
public override async Task<DeleteResourceReferencesResponse> DeleteResourceReferences(
|
||||
DeleteResourceReferencesRequest request, ServerCallContext context)
|
||||
{
|
||||
var deletedCount = 0;
|
||||
int deletedCount;
|
||||
if (request.Usage is null)
|
||||
deletedCount = await fileReferenceService.DeleteResourceReferencesAsync(request.ResourceId);
|
||||
else
|
||||
@@ -94,6 +94,18 @@ namespace DysonNetwork.Drive.Storage
|
||||
return new DeleteResourceReferencesResponse { DeletedCount = deletedCount };
|
||||
}
|
||||
|
||||
public override async Task<DeleteResourceReferencesResponse> DeleteResourceReferencesBatch(DeleteResourceReferencesBatchRequest request, ServerCallContext context)
|
||||
{
|
||||
var resourceIds = request.ResourceIds.ToList();
|
||||
int deletedCount;
|
||||
if (request.Usage is null)
|
||||
deletedCount = await fileReferenceService.DeleteResourceReferencesBatchAsync(resourceIds);
|
||||
else
|
||||
deletedCount =
|
||||
await fileReferenceService.DeleteResourceReferencesBatchAsync(resourceIds, request.Usage!);
|
||||
return new DeleteResourceReferencesResponse { DeletedCount = deletedCount };
|
||||
}
|
||||
|
||||
public override async Task<DeleteReferenceResponse> DeleteReference(DeleteReferenceRequest request,
|
||||
ServerCallContext context)
|
||||
{
|
||||
|
@@ -102,6 +102,7 @@ public class FileService(
|
||||
|
||||
private static readonly string[] AnimatedImageTypes =
|
||||
["image/gif", "image/apng", "image/avif"];
|
||||
|
||||
private static readonly string[] AnimatedImageExtensions =
|
||||
[".gif", ".apng", ".avif"];
|
||||
|
||||
@@ -278,15 +279,15 @@ public class FileService(
|
||||
s.Rotation
|
||||
}).Where(s => double.IsNormal(s.AvgFrameRate)).ToList(),
|
||||
["audio_streams"] = mediaInfo.AudioStreams.Select(s => new
|
||||
{
|
||||
s.BitRate,
|
||||
s.Channels,
|
||||
s.ChannelLayout,
|
||||
s.CodecName,
|
||||
s.Duration,
|
||||
s.Language,
|
||||
s.SampleRateHz
|
||||
})
|
||||
{
|
||||
s.BitRate,
|
||||
s.Channels,
|
||||
s.ChannelLayout,
|
||||
s.CodecName,
|
||||
s.Duration,
|
||||
s.Language,
|
||||
s.SampleRateHz
|
||||
})
|
||||
.ToList(),
|
||||
};
|
||||
if (mediaInfo.PrimaryVideoStream is not null)
|
||||
@@ -336,7 +337,14 @@ public class FileService(
|
||||
if (!pool.PolicyConfig.NoOptimization)
|
||||
switch (contentType.Split('/')[0])
|
||||
{
|
||||
case "image" when !AnimatedImageTypes.Contains(contentType) && !AnimatedImageExtensions.Contains(fileExtension):
|
||||
case "image":
|
||||
if (AnimatedImageTypes.Contains(contentType) || AnimatedImageExtensions.Contains(fileExtension))
|
||||
{
|
||||
logger.LogInformation("Skip optimize file {FileId} due to it is animated...", fileId);
|
||||
uploads.Add((originalFilePath, string.Empty, contentType, false));
|
||||
break;
|
||||
}
|
||||
|
||||
newMimeType = "image/webp";
|
||||
using (var vipsImage = Image.NewFromFile(originalFilePath))
|
||||
{
|
||||
@@ -643,7 +651,44 @@ public class FileService(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<FileBundle?> GetBundleAsync(Guid id, Guid accountId)
|
||||
/// <summary>
|
||||
/// The most efficent way to delete file data (stored files) in batch.
|
||||
/// But this DO NOT check the storage id, so use with caution!
|
||||
/// </summary>
|
||||
/// <param name="files">Files to delete</param>
|
||||
/// <exception cref="InvalidOperationException">Something went wrong</exception>
|
||||
public async Task DeleteFileDataBatchAsync(List<CloudFile> files)
|
||||
{
|
||||
files = files.Where(f => f.PoolId.HasValue).ToList();
|
||||
|
||||
foreach (var fileGroup in files.GroupBy(f => f.PoolId!.Value))
|
||||
{
|
||||
// If any other file with the same storage ID is referenced, don't delete the actual file data
|
||||
var dest = await GetRemoteStorageConfig(fileGroup.Key);
|
||||
if (dest is null)
|
||||
throw new InvalidOperationException($"No remote storage configured for pool {fileGroup.Key}");
|
||||
var client = CreateMinioClient(dest);
|
||||
if (client is null)
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to configure client for remote destination '{fileGroup.Key}'"
|
||||
);
|
||||
|
||||
List<string> objectsToDelete = [];
|
||||
|
||||
foreach (var file in fileGroup)
|
||||
{
|
||||
objectsToDelete.Add(file.StorageId ?? file.Id);
|
||||
if (file.HasCompression) objectsToDelete.Add(file.StorageId ?? file.Id + ".compressed");
|
||||
if (file.HasThumbnail) objectsToDelete.Add(file.StorageId ?? file.Id + ".thumbnail");
|
||||
}
|
||||
|
||||
await client.RemoveObjectsAsync(
|
||||
new RemoveObjectsArgs().WithBucket(dest.Bucket).WithObjects(objectsToDelete)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<FileBundle?> GetBundleAsync(Guid id, Guid accountId)
|
||||
{
|
||||
var bundle = await db.Bundles
|
||||
.Where(e => e.Id == id)
|
||||
|
@@ -12,7 +12,8 @@
|
||||
"ConnectionStrings": {
|
||||
"App": "Host=localhost;Port=5432;Database=dyson_drive;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60",
|
||||
"FastRetrieve": "localhost:6379",
|
||||
"Etcd": "etcd.orb.local:2379"
|
||||
"Etcd": "etcd.orb.local:2379",
|
||||
"Stream": "nats.orb.local:4222"
|
||||
},
|
||||
"Authentication": {
|
||||
"Schemes": {
|
||||
|
@@ -2,6 +2,7 @@ using System.Net.Security;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using DysonNetwork.Shared.Registry;
|
||||
using Yarp.ReverseProxy.Configuration;
|
||||
using Yarp.ReverseProxy.Transforms;
|
||||
|
||||
namespace DysonNetwork.Gateway.Startup;
|
||||
|
||||
@@ -15,11 +16,15 @@ public static class ServiceCollectionExtensions
|
||||
.AddReverseProxy()
|
||||
.ConfigureHttpClient((context, handler) =>
|
||||
{
|
||||
var caCert = X509CertificateLoader.LoadCertificateFromFile(configuration["CaCert"]!);
|
||||
// var caCert = X509CertificateLoader.LoadCertificateFromFile(configuration["CaCert"]!);
|
||||
handler.SslOptions = new SslClientAuthenticationOptions
|
||||
{
|
||||
RemoteCertificateValidationCallback = (sender, cert, chain, errors) => true
|
||||
};
|
||||
})
|
||||
.AddTransforms(context =>
|
||||
{
|
||||
context.AddForwarded();
|
||||
});
|
||||
|
||||
services.AddRegistryService(configuration, addForwarder: false);
|
||||
|
@@ -21,6 +21,9 @@ public class Account : ModelBase
|
||||
public Instant? ActivatedAt { get; set; }
|
||||
public bool IsSuperuser { get; set; } = false;
|
||||
|
||||
// The ID is the BotAccount ID in the DysonNetwork.Develop
|
||||
public Guid? AutomatedId { get; set; }
|
||||
|
||||
public AccountProfile Profile { get; set; } = null!;
|
||||
public ICollection<AccountContact> Contacts { get; set; } = new List<AccountContact>();
|
||||
public ICollection<AccountBadge> Badges { get; set; } = new List<AccountBadge>();
|
||||
@@ -48,7 +51,8 @@ public class Account : ModelBase
|
||||
Profile = Profile.ToProtoValue(),
|
||||
PerkSubscription = PerkSubscription?.ToProtoValue(),
|
||||
CreatedAt = CreatedAt.ToTimestamp(),
|
||||
UpdatedAt = UpdatedAt.ToTimestamp()
|
||||
UpdatedAt = UpdatedAt.ToTimestamp(),
|
||||
AutomatedId = AutomatedId?.ToString()
|
||||
};
|
||||
|
||||
// Add contacts
|
||||
@@ -78,10 +82,10 @@ public class Account : ModelBase
|
||||
: null,
|
||||
CreatedAt = proto.CreatedAt.ToInstant(),
|
||||
UpdatedAt = proto.UpdatedAt.ToInstant(),
|
||||
AutomatedId = proto.AutomatedId is not null ? Guid.Parse(proto.AutomatedId) : null,
|
||||
Profile = AccountProfile.FromProtoValue(proto.Profile)
|
||||
};
|
||||
|
||||
account.Profile = AccountProfile.FromProtoValue(proto.Profile);
|
||||
|
||||
foreach (var contactProto in proto.Contacts)
|
||||
account.Contacts.Add(AccountContact.FromProtoValue(contactProto));
|
||||
|
||||
@@ -116,7 +120,7 @@ public abstract class Leveling
|
||||
|
||||
public class AccountProfile : ModelBase, IIdentifiedResource
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
[MaxLength(256)] public string? FirstName { get; set; }
|
||||
[MaxLength(256)] public string? MiddleName { get; set; }
|
||||
[MaxLength(256)] public string? LastName { get; set; }
|
||||
@@ -132,9 +136,20 @@ public class AccountProfile : ModelBase, IIdentifiedResource
|
||||
[Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; }
|
||||
[Column(TypeName = "jsonb")] public BadgeReferenceObject? ActiveBadge { get; set; }
|
||||
|
||||
public int Experience { get; set; } = 0;
|
||||
public int Experience { get; set; }
|
||||
[NotMapped] public int Level => Leveling.ExperiencePerLevel.Count(xp => Experience >= xp) - 1;
|
||||
|
||||
public double SocialCredits { get; set; } = 100;
|
||||
|
||||
[NotMapped]
|
||||
public int SocialCreditsLevel => SocialCredits switch
|
||||
{
|
||||
< 100 => -1,
|
||||
> 100 and < 200 => 0,
|
||||
< 200 => 1,
|
||||
_ => 2
|
||||
};
|
||||
|
||||
[NotMapped]
|
||||
public double LevelingProgress => Level >= Leveling.ExperiencePerLevel.Count - 1
|
||||
? 100
|
||||
@@ -165,6 +180,8 @@ public class AccountProfile : ModelBase, IIdentifiedResource
|
||||
Experience = Experience,
|
||||
Level = Level,
|
||||
LevelingProgress = LevelingProgress,
|
||||
SocialCredits = SocialCredits,
|
||||
SocialCreditsLevel = SocialCreditsLevel,
|
||||
Picture = Picture?.ToProtoValue(),
|
||||
Background = Background?.ToProtoValue(),
|
||||
AccountId = AccountId.ToString(),
|
||||
@@ -195,6 +212,7 @@ public class AccountProfile : ModelBase, IIdentifiedResource
|
||||
Verification = proto.Verification is null ? null : VerificationMark.FromProtoValue(proto.Verification),
|
||||
ActiveBadge = proto.ActiveBadge is null ? null : BadgeReferenceObject.FromProtoValue(proto.ActiveBadge),
|
||||
Experience = proto.Experience,
|
||||
SocialCredits = proto.SocialCredits,
|
||||
Picture = proto.Picture is null ? null : CloudFileReferenceObject.FromProtoValue(proto.Picture),
|
||||
Background = proto.Background is null ? null : CloudFileReferenceObject.FromProtoValue(proto.Background),
|
||||
AccountId = Guid.Parse(proto.AccountId),
|
||||
|
@@ -1,5 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Pass.Auth;
|
||||
using DysonNetwork.Pass.Credit;
|
||||
using DysonNetwork.Pass.Wallet;
|
||||
using DysonNetwork.Shared.Error;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -15,7 +16,8 @@ public class AccountController(
|
||||
AuthService auth,
|
||||
AccountService accounts,
|
||||
SubscriptionService subscriptions,
|
||||
AccountEventService events
|
||||
AccountEventService events,
|
||||
SocialCreditService socialCreditService
|
||||
) : ControllerBase
|
||||
{
|
||||
[HttpGet("{name}")]
|
||||
@@ -49,6 +51,25 @@ public class AccountController(
|
||||
return account is null ? NotFound(ApiError.NotFound(name, traceId: HttpContext.TraceIdentifier)) : account.Badges.ToList();
|
||||
}
|
||||
|
||||
[HttpGet("{name}/credits")]
|
||||
[ProducesResponseType<double>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<double>> GetSocialCredits(string name)
|
||||
{
|
||||
var account = await db.Accounts
|
||||
.Where(a => a.Name == name)
|
||||
.Select(a => new { a.Id })
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (account is null)
|
||||
{
|
||||
return NotFound(ApiError.NotFound(name, traceId: HttpContext.TraceIdentifier));
|
||||
}
|
||||
|
||||
var credits = await socialCreditService.GetSocialCredit(account.Id);
|
||||
return credits;
|
||||
}
|
||||
|
||||
public class AccountCreateRequest
|
||||
{
|
||||
[Required]
|
||||
|
@@ -24,11 +24,13 @@ public class AccountCurrentController(
|
||||
AccountEventService events,
|
||||
AuthService auth,
|
||||
FileService.FileServiceClient files,
|
||||
FileReferenceService.FileReferenceServiceClient fileRefs
|
||||
FileReferenceService.FileReferenceServiceClient fileRefs,
|
||||
Credit.SocialCreditService creditService
|
||||
) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[ProducesResponseType<Account>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ApiError>(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ActionResult<Account>> GetCurrentIdentity()
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
@@ -268,7 +270,9 @@ public class AccountCurrentController(
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
return result is null ? NotFound(ApiError.NotFound("check-in", traceId: HttpContext.TraceIdentifier)) : Ok(result);
|
||||
return result is null
|
||||
? NotFound(ApiError.NotFound("check-in", traceId: HttpContext.TraceIdentifier))
|
||||
: Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("check-in")]
|
||||
@@ -323,10 +327,11 @@ public class AccountCurrentController(
|
||||
TraceId = HttpContext.TraceIdentifier
|
||||
}
|
||||
),
|
||||
true when !await auth.ValidateCaptcha(captchaToken!) => BadRequest(ApiError.Validation(new Dictionary<string, string[]>
|
||||
{
|
||||
["captchaToken"] = new[] { "Invalid captcha token." }
|
||||
}, traceId: HttpContext.TraceIdentifier)),
|
||||
true when !await auth.ValidateCaptcha(captchaToken!) => BadRequest(ApiError.Validation(
|
||||
new Dictionary<string, string[]>
|
||||
{
|
||||
["captchaToken"] = new[] { "Invalid captcha token." }
|
||||
}, traceId: HttpContext.TraceIdentifier)),
|
||||
_ => await events.CheckInDaily(currentUser, backdated)
|
||||
};
|
||||
}
|
||||
@@ -823,4 +828,60 @@ public class AccountCurrentController(
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("leveling")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<ExperienceRecord>> GetLevelingHistory(
|
||||
[FromQuery] int take = 20,
|
||||
[FromQuery] int offset = 0
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
|
||||
var queryable = db.ExperienceRecords
|
||||
.Where(r => r.AccountId == currentUser.Id)
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.AsQueryable();
|
||||
|
||||
var totalCount = await queryable.CountAsync();
|
||||
Response.Headers["X-Total"] = totalCount.ToString();
|
||||
|
||||
var records = await queryable
|
||||
.Skip(offset)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(records);
|
||||
}
|
||||
|
||||
[HttpGet("credits")]
|
||||
public async Task<ActionResult<bool>> GetSocialCredit()
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
|
||||
var credit = await creditService.GetSocialCredit(currentUser.Id);
|
||||
return Ok(credit);
|
||||
}
|
||||
|
||||
[HttpGet("credits/history")]
|
||||
public async Task<ActionResult<SocialCreditRecord>> GetCreditHistory(
|
||||
[FromQuery] int take = 20,
|
||||
[FromQuery] int offset = 0
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
|
||||
var queryable = db.SocialCreditRecords
|
||||
.Where(r => r.AccountId == currentUser.Id)
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.AsQueryable();
|
||||
|
||||
var totalCount = await queryable.CountAsync();
|
||||
Response.Headers["X-Total"] = totalCount.ToString();
|
||||
|
||||
var records = await queryable
|
||||
.Skip(offset)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(records);
|
||||
}
|
||||
}
|
@@ -15,7 +15,8 @@ public class AccountEventService(
|
||||
ICacheService cache,
|
||||
IStringLocalizer<Localization.AccountEventResource> localizer,
|
||||
PusherService.PusherServiceClient pusher,
|
||||
SubscriptionService subscriptions
|
||||
SubscriptionService subscriptions,
|
||||
Pass.Leveling.ExperienceService experienceService
|
||||
)
|
||||
{
|
||||
private static readonly Random Random = new();
|
||||
@@ -327,13 +328,15 @@ public class AccountEventService(
|
||||
result.RewardPoints = null;
|
||||
}
|
||||
|
||||
await db.AccountProfiles
|
||||
.Where(p => p.AccountId == user.Id)
|
||||
.ExecuteUpdateAsync(s =>
|
||||
s.SetProperty(b => b.Experience, b => b.Experience + result.RewardExperience)
|
||||
);
|
||||
db.AccountCheckInResults.Add(result);
|
||||
await db.SaveChangesAsync(); // Don't forget to save changes to the database
|
||||
await db.SaveChangesAsync(); // Remember to save changes to the database
|
||||
if (result.RewardExperience is not null)
|
||||
await experienceService.AddRecord(
|
||||
"check-in",
|
||||
$"Check-in reward on {now:yyyy/MM/dd}",
|
||||
result.RewardExperience.Value,
|
||||
user.Id
|
||||
);
|
||||
|
||||
// The lock will be automatically released by the await using statement
|
||||
return result;
|
||||
|
@@ -1,14 +1,18 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Pass.Auth;
|
||||
using DysonNetwork.Pass.Auth.OpenId;
|
||||
using DysonNetwork.Pass.Email;
|
||||
using DysonNetwork.Pass.Localization;
|
||||
using DysonNetwork.Pass.Permission;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using DysonNetwork.Shared.Stream;
|
||||
using EFCore.BulkExtensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using NATS.Client.Core;
|
||||
using NodaTime;
|
||||
using OtpNet;
|
||||
using AuthService = DysonNetwork.Pass.Auth.AuthService;
|
||||
@@ -18,12 +22,15 @@ namespace DysonNetwork.Pass.Account;
|
||||
public class AccountService(
|
||||
AppDatabase db,
|
||||
MagicSpellService spells,
|
||||
FileService.FileServiceClient files,
|
||||
FileReferenceService.FileReferenceServiceClient fileRefs,
|
||||
AccountUsernameService uname,
|
||||
EmailService mailer,
|
||||
PusherService.PusherServiceClient pusher,
|
||||
IStringLocalizer<NotificationResource> localizer,
|
||||
ICacheService cache,
|
||||
ILogger<AccountService> logger
|
||||
ILogger<AccountService> logger,
|
||||
INatsConnection nats
|
||||
)
|
||||
{
|
||||
public static void SetCultureInfo(Account account)
|
||||
@@ -178,6 +185,59 @@ public class AccountService(
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<Account> CreateBotAccount(Account account, Guid automatedId, string? pictureId, string? backgroundId)
|
||||
{
|
||||
var dupeAutomateCount = await db.Accounts.Where(a => a.AutomatedId == automatedId).CountAsync();
|
||||
if (dupeAutomateCount > 0)
|
||||
throw new InvalidOperationException("Automated ID has already been used.");
|
||||
|
||||
var dupeNameCount = await db.Accounts.Where(a => a.Name == account.Name).CountAsync();
|
||||
if (dupeNameCount > 0)
|
||||
throw new InvalidOperationException("Account name has already been taken.");
|
||||
|
||||
account.AutomatedId = automatedId;
|
||||
account.ActivatedAt = SystemClock.Instance.GetCurrentInstant();
|
||||
account.IsSuperuser = false;
|
||||
|
||||
if (!string.IsNullOrEmpty(pictureId))
|
||||
{
|
||||
var file = await files.GetFileAsync(new GetFileRequest { Id = pictureId });
|
||||
await fileRefs.CreateReferenceAsync(
|
||||
new CreateReferenceRequest
|
||||
{
|
||||
ResourceId = account.Profile.ResourceIdentifier,
|
||||
FileId = pictureId,
|
||||
Usage = "profile.picture"
|
||||
}
|
||||
);
|
||||
account.Profile.Picture = CloudFileReferenceObject.FromProtoValue(file);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(backgroundId))
|
||||
{
|
||||
var file = await files.GetFileAsync(new GetFileRequest { Id = backgroundId });
|
||||
await fileRefs.CreateReferenceAsync(
|
||||
new CreateReferenceRequest
|
||||
{
|
||||
ResourceId = account.Profile.ResourceIdentifier,
|
||||
FileId = backgroundId,
|
||||
Usage = "profile.background"
|
||||
}
|
||||
);
|
||||
account.Profile.Background = CloudFileReferenceObject.FromProtoValue(file);
|
||||
}
|
||||
|
||||
db.Accounts.Add(account);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
public async Task<Account?> GetBotAccount(Guid automatedId)
|
||||
{
|
||||
return await db.Accounts.FirstOrDefaultAsync(a => a.AutomatedId == automatedId);
|
||||
}
|
||||
|
||||
public async Task RequestAccountDeletion(Account account)
|
||||
{
|
||||
var spell = await spells.CreateMagicSpell(
|
||||
@@ -440,7 +500,7 @@ public class AccountService(
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<bool> IsDeviceActive(Guid id)
|
||||
private async Task<bool> IsDeviceActive(Guid id)
|
||||
{
|
||||
return await db.AuthSessions
|
||||
.Include(s => s.Challenge)
|
||||
@@ -449,8 +509,7 @@ public class AccountService(
|
||||
|
||||
public async Task<AuthClient> UpdateDeviceName(Account account, string deviceId, string label)
|
||||
{
|
||||
var device = await db.AuthClients.FirstOrDefaultAsync(
|
||||
c => c.DeviceId == deviceId && c.AccountId == account.Id
|
||||
var device = await db.AuthClients.FirstOrDefaultAsync(c => c.DeviceId == deviceId && c.AccountId == account.Id
|
||||
);
|
||||
if (device is null) throw new InvalidOperationException("Device was not found.");
|
||||
|
||||
@@ -470,54 +529,48 @@ public class AccountService(
|
||||
.FirstOrDefaultAsync();
|
||||
if (session is null) throw new InvalidOperationException("Session was not found.");
|
||||
|
||||
var sessions = await db.AuthSessions
|
||||
.Include(s => s.Challenge)
|
||||
.Where(s => s.AccountId == session.Id && s.Challenge.DeviceId == session.Challenge.DeviceId)
|
||||
.ToListAsync();
|
||||
// The current session should be included in the sessions' list
|
||||
db.AuthSessions.Remove(session);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
if (session.Challenge.ClientId.HasValue)
|
||||
{
|
||||
if (!await IsDeviceActive(session.Challenge.ClientId.Value))
|
||||
await pusher.UnsubscribePushNotificationsAsync(new UnsubscribePushNotificationsRequest()
|
||||
{ DeviceId = session.Challenge.Client!.DeviceId }
|
||||
{ DeviceId = session.Challenge.Client!.DeviceId }
|
||||
);
|
||||
}
|
||||
|
||||
// The current session should be included in the sessions' list
|
||||
await db.AuthSessions
|
||||
.Include(s => s.Challenge)
|
||||
.Where(s => s.Challenge.DeviceId == session.Challenge.DeviceId)
|
||||
.ExecuteDeleteAsync();
|
||||
logger.LogInformation("Deleted session #{SessionId}", session.Id);
|
||||
|
||||
foreach (var item in sessions)
|
||||
await cache.RemoveAsync($"{AuthService.AuthCachePrefix}{item.Id}");
|
||||
await cache.RemoveAsync($"{AuthService.AuthCachePrefix}{session.Id}");
|
||||
}
|
||||
|
||||
public async Task DeleteDevice(Account account, string deviceId)
|
||||
{
|
||||
var device = await db.AuthClients.FirstOrDefaultAsync(
|
||||
c => c.DeviceId == deviceId && c.AccountId == account.Id
|
||||
var device = await db.AuthClients.FirstOrDefaultAsync(c => c.DeviceId == deviceId && c.AccountId == account.Id
|
||||
);
|
||||
if (device is null)
|
||||
throw new InvalidOperationException("Device not found.");
|
||||
|
||||
await pusher.UnsubscribePushNotificationsAsync(
|
||||
new UnsubscribePushNotificationsRequest() { DeviceId = device.DeviceId }
|
||||
new UnsubscribePushNotificationsRequest { DeviceId = device.DeviceId }
|
||||
);
|
||||
|
||||
db.AuthClients.Remove(device);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var sessions = await db.AuthSessions
|
||||
.Include(s => s.Challenge)
|
||||
.Where(s => s.Challenge.ClientId == device.Id)
|
||||
.Where(s => s.Challenge.ClientId == device.Id && s.AccountId == account.Id)
|
||||
.ToListAsync();
|
||||
|
||||
// The current session should be included in the sessions' list
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
await db.AuthSessions
|
||||
.Include(s => s.Challenge)
|
||||
.Where(s => s.Challenge.DeviceId == device.DeviceId)
|
||||
.ExecuteDeleteAsync();
|
||||
.Where(s => s.Challenge.ClientId == device.Id)
|
||||
.ExecuteUpdateAsync(p => p.SetProperty(s => s.DeletedAt, s => now));
|
||||
|
||||
db.AuthClients.Remove(device);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
foreach (var item in sessions)
|
||||
await cache.RemoveAsync($"{AuthService.AuthCachePrefix}{item.Id}");
|
||||
@@ -672,21 +725,19 @@ public class AccountService(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The maintenance method for server administrator.
|
||||
/// To check every user has an account profile and to create them if it isn't having one.
|
||||
/// </summary>
|
||||
public async Task EnsureAccountProfileCreated()
|
||||
public async Task DeleteAccount(Account account)
|
||||
{
|
||||
var accountsId = await db.Accounts.Select(a => a.Id).ToListAsync();
|
||||
var existingId = await db.AccountProfiles.Select(p => p.AccountId).ToListAsync();
|
||||
var missingId = accountsId.Except(existingId).ToList();
|
||||
await db.AuthSessions
|
||||
.Where(s => s.AccountId == account.Id)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
if (missingId.Count != 0)
|
||||
db.Accounts.Remove(account);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await nats.PublishAsync(AccountDeletedEvent.Type, JsonSerializer.SerializeToUtf8Bytes(new AccountDeletedEvent
|
||||
{
|
||||
var newProfiles = missingId.Select(id => new AccountProfile { Id = Guid.NewGuid(), AccountId = id })
|
||||
.ToList();
|
||||
await db.BulkInsertAsync(newProfiles);
|
||||
}
|
||||
AccountId = account.Id,
|
||||
DeletedAt = SystemClock.Instance.GetCurrentInstant()
|
||||
}));
|
||||
}
|
||||
}
|
@@ -42,6 +42,26 @@ public class AccountServiceGrpc(
|
||||
return account.ToProtoValue();
|
||||
}
|
||||
|
||||
public override async Task<Shared.Proto.Account> GetBotAccount(GetBotAccountRequest request,
|
||||
ServerCallContext context)
|
||||
{
|
||||
if (!Guid.TryParse(request.AutomatedId, out var automatedId))
|
||||
throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid automated ID format"));
|
||||
|
||||
var account = await _db.Accounts
|
||||
.AsNoTracking()
|
||||
.Include(a => a.Profile)
|
||||
.FirstOrDefaultAsync(a => a.AutomatedId == automatedId);
|
||||
|
||||
if (account == null)
|
||||
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, $"Account with automated ID {request.AutomatedId} not found"));
|
||||
|
||||
var perk = await subscriptions.GetPerkSubscriptionAsync(account.Id);
|
||||
account.PerkSubscription = perk?.ToReference();
|
||||
|
||||
return account.ToProtoValue();
|
||||
}
|
||||
|
||||
public override async Task<GetAccountBatchResponse> GetAccountBatch(GetAccountBatchRequest request,
|
||||
ServerCallContext context)
|
||||
{
|
||||
@@ -69,6 +89,34 @@ public class AccountServiceGrpc(
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
public override async Task<GetAccountBatchResponse> GetBotAccountBatch(GetBotAccountBatchRequest request,
|
||||
ServerCallContext context)
|
||||
{
|
||||
var automatedIds = request.AutomatedId
|
||||
.Select(id => Guid.TryParse(id, out var automatedId) ? automatedId : (Guid?)null)
|
||||
.Where(id => id.HasValue)
|
||||
.Select(id => id!.Value)
|
||||
.ToList();
|
||||
|
||||
var accounts = await _db.Accounts
|
||||
.AsNoTracking()
|
||||
.Where(a => a.AutomatedId != null && automatedIds.Contains(a.AutomatedId.Value))
|
||||
.Include(a => a.Profile)
|
||||
.ToListAsync();
|
||||
|
||||
var perks = await subscriptions.GetPerkSubscriptionsAsync(
|
||||
accounts.Select(x => x.Id).ToList()
|
||||
);
|
||||
foreach (var account in accounts)
|
||||
if (perks.TryGetValue(account.Id, out var perk))
|
||||
account.PerkSubscription = perk?.ToReference();
|
||||
|
||||
var response = new GetAccountBatchResponse();
|
||||
response.Accounts.AddRange(accounts.Select(a => a.ToProtoValue()));
|
||||
return response;
|
||||
}
|
||||
|
||||
public override async Task<AccountStatus> GetAccountStatus(GetAccountRequest request, ServerCallContext context)
|
||||
{
|
||||
var accountId = Guid.Parse(request.Id);
|
||||
@@ -76,7 +124,8 @@ public class AccountServiceGrpc(
|
||||
return status.ToProtoValue();
|
||||
}
|
||||
|
||||
public override async Task<GetAccountStatusBatchResponse> GetAccountStatusBatch(GetAccountBatchRequest request, ServerCallContext context)
|
||||
public override async Task<GetAccountStatusBatchResponse> GetAccountStatusBatch(GetAccountBatchRequest request,
|
||||
ServerCallContext context)
|
||||
{
|
||||
var accountIds = request.Id
|
||||
.Select(id => Guid.TryParse(id, out var accountId) ? accountId : (Guid?)null)
|
||||
|
218
DysonNetwork.Pass/Account/BotAccountReceiverGrpc.cs
Normal file
218
DysonNetwork.Pass/Account/BotAccountReceiverGrpc.cs
Normal file
@@ -0,0 +1,218 @@
|
||||
using DysonNetwork.Shared.Data;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Grpc.Core;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime.Serialization.Protobuf;
|
||||
using ApiKey = DysonNetwork.Shared.Proto.ApiKey;
|
||||
using AuthService = DysonNetwork.Pass.Auth.AuthService;
|
||||
|
||||
namespace DysonNetwork.Pass.Account;
|
||||
|
||||
public class BotAccountReceiverGrpc(
|
||||
AppDatabase db,
|
||||
AccountService accounts,
|
||||
FileService.FileServiceClient files,
|
||||
FileReferenceService.FileReferenceServiceClient fileRefs,
|
||||
AuthService authService
|
||||
)
|
||||
: BotAccountReceiverService.BotAccountReceiverServiceBase
|
||||
{
|
||||
public override async Task<CreateBotAccountResponse> CreateBotAccount(
|
||||
CreateBotAccountRequest request,
|
||||
ServerCallContext context
|
||||
)
|
||||
{
|
||||
var account = Account.FromProtoValue(request.Account);
|
||||
account = await accounts.CreateBotAccount(
|
||||
account,
|
||||
Guid.Parse(request.AutomatedId),
|
||||
request.PictureId,
|
||||
request.BackgroundId
|
||||
);
|
||||
|
||||
return new CreateBotAccountResponse
|
||||
{
|
||||
Bot = new BotAccount
|
||||
{
|
||||
Account = account.ToProtoValue(),
|
||||
AutomatedId = account.Id.ToString(),
|
||||
CreatedAt = account.CreatedAt.ToTimestamp(),
|
||||
UpdatedAt = account.UpdatedAt.ToTimestamp(),
|
||||
IsActive = true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public override async Task<UpdateBotAccountResponse> UpdateBotAccount(
|
||||
UpdateBotAccountRequest request,
|
||||
ServerCallContext context
|
||||
)
|
||||
{
|
||||
var account = Account.FromProtoValue(request.Account);
|
||||
|
||||
if (request.PictureId is not null)
|
||||
{
|
||||
var file = await files.GetFileAsync(new GetFileRequest { Id = request.PictureId });
|
||||
if (account.Profile.Picture is not null)
|
||||
await fileRefs.DeleteResourceReferencesAsync(
|
||||
new DeleteResourceReferencesRequest { ResourceId = account.Profile.ResourceIdentifier }
|
||||
);
|
||||
await fileRefs.CreateReferenceAsync(
|
||||
new CreateReferenceRequest
|
||||
{
|
||||
ResourceId = account.Profile.ResourceIdentifier,
|
||||
FileId = request.PictureId,
|
||||
Usage = "profile.picture"
|
||||
}
|
||||
);
|
||||
account.Profile.Picture = CloudFileReferenceObject.FromProtoValue(file);
|
||||
}
|
||||
|
||||
if (request.BackgroundId is not null)
|
||||
{
|
||||
var file = await files.GetFileAsync(new GetFileRequest { Id = request.BackgroundId });
|
||||
if (account.Profile.Background is not null)
|
||||
await fileRefs.DeleteResourceReferencesAsync(
|
||||
new DeleteResourceReferencesRequest { ResourceId = account.Profile.ResourceIdentifier }
|
||||
);
|
||||
await fileRefs.CreateReferenceAsync(
|
||||
new CreateReferenceRequest
|
||||
{
|
||||
ResourceId = account.Profile.ResourceIdentifier,
|
||||
FileId = request.BackgroundId,
|
||||
Usage = "profile.background"
|
||||
}
|
||||
);
|
||||
account.Profile.Background = CloudFileReferenceObject.FromProtoValue(file);
|
||||
}
|
||||
|
||||
db.Accounts.Update(account);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return new UpdateBotAccountResponse
|
||||
{
|
||||
Bot = new BotAccount
|
||||
{
|
||||
Account = account.ToProtoValue(),
|
||||
AutomatedId = account.Id.ToString(),
|
||||
CreatedAt = account.CreatedAt.ToTimestamp(),
|
||||
UpdatedAt = account.UpdatedAt.ToTimestamp(),
|
||||
IsActive = true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public override async Task<DeleteBotAccountResponse> DeleteBotAccount(
|
||||
DeleteBotAccountRequest request,
|
||||
ServerCallContext context
|
||||
)
|
||||
{
|
||||
var automatedId = Guid.Parse(request.AutomatedId);
|
||||
var account = await accounts.GetBotAccount(automatedId);
|
||||
if (account is null)
|
||||
throw new RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.NotFound, "Account not found"));
|
||||
|
||||
await accounts.DeleteAccount(account);
|
||||
|
||||
return new DeleteBotAccountResponse();
|
||||
}
|
||||
|
||||
public override async Task<ApiKey> GetApiKey(GetApiKeyRequest request, ServerCallContext context)
|
||||
{
|
||||
var keyId = Guid.Parse(request.Id);
|
||||
var key = await db.ApiKeys
|
||||
.Include(k => k.Account)
|
||||
.FirstOrDefaultAsync(k => k.Id == keyId);
|
||||
|
||||
if (key == null)
|
||||
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "API key not found"));
|
||||
|
||||
return key.ToProtoValue();
|
||||
}
|
||||
|
||||
public override async Task<GetApiKeyBatchResponse> ListApiKey(ListApiKeyRequest request, ServerCallContext context)
|
||||
{
|
||||
var automatedId = Guid.Parse(request.AutomatedId);
|
||||
var account = await accounts.GetBotAccount(automatedId);
|
||||
if (account == null)
|
||||
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "Account not found"));
|
||||
|
||||
var keys = await db.ApiKeys
|
||||
.Where(k => k.AccountId == account.Id)
|
||||
.Select(k => k.ToProtoValue())
|
||||
.ToListAsync();
|
||||
|
||||
var response = new GetApiKeyBatchResponse();
|
||||
response.Data.AddRange(keys);
|
||||
return response;
|
||||
}
|
||||
|
||||
public override async Task<ApiKey> CreateApiKey(ApiKey request, ServerCallContext context)
|
||||
{
|
||||
var accountId = Guid.Parse(request.AccountId);
|
||||
var account = await accounts.GetBotAccount(accountId);
|
||||
if (account == null)
|
||||
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "Account not found"));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Label))
|
||||
throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Label is required"));
|
||||
|
||||
var key = await authService.CreateApiKey(account.Id, request.Label, null);
|
||||
key.Key = await authService.IssueApiKeyToken(key);
|
||||
|
||||
return key.ToProtoValue();
|
||||
}
|
||||
|
||||
public override async Task<ApiKey> UpdateApiKey(ApiKey request, ServerCallContext context)
|
||||
{
|
||||
var keyId = Guid.Parse(request.Id);
|
||||
var accountId = Guid.Parse(request.AccountId);
|
||||
|
||||
var key = await db.ApiKeys
|
||||
.Include(k => k.Session)
|
||||
.Where(k => k.Id == keyId && k.AccountId == accountId)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (key == null)
|
||||
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "API key not found"));
|
||||
|
||||
// Only update the label if provided
|
||||
if (string.IsNullOrWhiteSpace(request.Label)) return key.ToProtoValue();
|
||||
key.Label = request.Label;
|
||||
db.ApiKeys.Update(key);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return key.ToProtoValue();
|
||||
}
|
||||
|
||||
public override async Task<ApiKey> RotateApiKey(GetApiKeyRequest request, ServerCallContext context)
|
||||
{
|
||||
var keyId = Guid.Parse(request.Id);
|
||||
var key = await db.ApiKeys
|
||||
.Include(k => k.Session)
|
||||
.FirstOrDefaultAsync(k => k.Id == keyId);
|
||||
|
||||
if (key == null)
|
||||
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "API key not found"));
|
||||
|
||||
key = await authService.RotateApiKeyToken(key);
|
||||
key.Key = await authService.IssueApiKeyToken(key);
|
||||
|
||||
return key.ToProtoValue();
|
||||
}
|
||||
|
||||
public override async Task<DeleteApiKeyResponse> DeleteApiKey(GetApiKeyRequest request, ServerCallContext context)
|
||||
{
|
||||
var keyId = Guid.Parse(request.Id);
|
||||
var key = await db.ApiKeys
|
||||
.Include(k => k.Session)
|
||||
.FirstOrDefaultAsync(k => k.Id == keyId);
|
||||
|
||||
if (key == null)
|
||||
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "API key not found"));
|
||||
|
||||
await authService.RevokeApiKeyToken(key);
|
||||
|
||||
return new DeleteApiKeyResponse { Success = true };
|
||||
}
|
||||
}
|
@@ -1,10 +1,18 @@
|
||||
using DysonNetwork.Pass.Localization;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Pass.Account;
|
||||
|
||||
public class RelationshipService(AppDatabase db, ICacheService cache)
|
||||
public class RelationshipService(
|
||||
AppDatabase db,
|
||||
ICacheService cache,
|
||||
PusherService.PusherServiceClient pusher,
|
||||
IStringLocalizer<NotificationResource> localizer
|
||||
)
|
||||
{
|
||||
private const string UserFriendsCacheKeyPrefix = "accounts:friends:";
|
||||
private const string UserBlockedCacheKeyPrefix = "accounts:blocked:";
|
||||
@@ -92,6 +100,19 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
|
||||
db.AccountRelationships.Add(relationship);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await pusher.SendPushNotificationToUserAsync(new SendPushNotificationToUserRequest
|
||||
{
|
||||
UserId = target.Id.ToString(),
|
||||
Notification = new PushNotification
|
||||
{
|
||||
Topic = "relationships.friends.request",
|
||||
Title = localizer["FriendRequestTitle", sender.Nick],
|
||||
Body = localizer["FriendRequestBody"],
|
||||
ActionUri = "/account/relationships",
|
||||
IsSavable = true
|
||||
}
|
||||
});
|
||||
|
||||
return relationship;
|
||||
}
|
||||
|
||||
|
@@ -2,6 +2,8 @@ using System.Linq.Expressions;
|
||||
using System.Reflection;
|
||||
using DysonNetwork.Pass.Account;
|
||||
using DysonNetwork.Pass.Auth;
|
||||
using DysonNetwork.Pass.Credit;
|
||||
using DysonNetwork.Pass.Leveling;
|
||||
using DysonNetwork.Pass.Permission;
|
||||
using DysonNetwork.Pass.Wallet;
|
||||
using DysonNetwork.Shared.Data;
|
||||
@@ -38,6 +40,7 @@ public class AppDatabase(
|
||||
public DbSet<AuthSession> AuthSessions { get; set; } = null!;
|
||||
public DbSet<AuthChallenge> AuthChallenges { get; set; } = null!;
|
||||
public DbSet<AuthClient> AuthClients { get; set; } = null!;
|
||||
public DbSet<ApiKey> ApiKeys { get; set; } = null!;
|
||||
|
||||
public DbSet<Wallet.Wallet> Wallets { get; set; } = null!;
|
||||
public DbSet<WalletPocket> WalletPockets { get; set; } = null!;
|
||||
@@ -48,6 +51,9 @@ public class AppDatabase(
|
||||
|
||||
public DbSet<Punishment> Punishments { get; set; } = null!;
|
||||
|
||||
public DbSet<SocialCreditRecord> SocialCreditRecords { get; set; } = null!;
|
||||
public DbSet<ExperienceRecord> ExperienceRecords { get; set; } = null!;
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
optionsBuilder.UseNpgsql(
|
||||
|
50
DysonNetwork.Pass/Auth/ApiKey.cs
Normal file
50
DysonNetwork.Pass/Auth/ApiKey.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using NodaTime.Serialization.Protobuf;
|
||||
|
||||
namespace DysonNetwork.Pass.Auth;
|
||||
|
||||
public class ApiKey : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
[MaxLength(1024)] public string Label { get; set; } = null!;
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
public Account.Account Account { get; set; } = null!;
|
||||
public Guid SessionId { get; set; }
|
||||
public AuthSession Session { get; set; } = null!;
|
||||
|
||||
[NotMapped]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Key { get; set; }
|
||||
|
||||
public DysonNetwork.Shared.Proto.ApiKey ToProtoValue()
|
||||
{
|
||||
return new DysonNetwork.Shared.Proto.ApiKey
|
||||
{
|
||||
Id = Id.ToString(),
|
||||
Label = Label,
|
||||
AccountId = AccountId.ToString(),
|
||||
SessionId = SessionId.ToString(),
|
||||
Key = Key,
|
||||
CreatedAt = CreatedAt.ToTimestamp(),
|
||||
UpdatedAt = UpdatedAt.ToTimestamp()
|
||||
};
|
||||
}
|
||||
|
||||
public static ApiKey FromProtoValue(DysonNetwork.Shared.Proto.ApiKey proto)
|
||||
{
|
||||
return new ApiKey
|
||||
{
|
||||
Id = Guid.Parse(proto.Id),
|
||||
AccountId = Guid.Parse(proto.AccountId),
|
||||
SessionId = Guid.Parse(proto.SessionId),
|
||||
Label = proto.Label,
|
||||
Key = proto.Key,
|
||||
CreatedAt = proto.CreatedAt.ToInstant(),
|
||||
UpdatedAt = proto.UpdatedAt.ToInstant()
|
||||
};
|
||||
}
|
||||
}
|
90
DysonNetwork.Pass/Auth/ApiKeyController.cs
Normal file
90
DysonNetwork.Pass/Auth/ApiKeyController.cs
Normal file
@@ -0,0 +1,90 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Pass.Auth;
|
||||
|
||||
[ApiController]
|
||||
[Route("/api/auth/keys")]
|
||||
public class ApiKeyController(AppDatabase db, AuthService auth) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> GetKeys([FromQuery] int offset = 0, [FromQuery] int take = 20)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
var query = db.ApiKeys
|
||||
.Where(e => e.AccountId == currentUser.Id)
|
||||
.AsQueryable();
|
||||
|
||||
var totalCount = await query.CountAsync();
|
||||
Response.Headers["X-Total"] = totalCount.ToString();
|
||||
|
||||
var keys = await query
|
||||
.Skip(offset)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(keys);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> GetKey(Guid id)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
var key = await db.ApiKeys
|
||||
.Where(e => e.AccountId == currentUser.Id)
|
||||
.Where(e => e.Id == id)
|
||||
.FirstOrDefaultAsync();
|
||||
if (key == null) return NotFound();
|
||||
return Ok(key);
|
||||
}
|
||||
|
||||
public class ApiKeyRequest
|
||||
{
|
||||
[MaxLength(1024)] public string? Label { get; set; }
|
||||
public Instant? ExpiredAt { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> CreateKey([FromBody] ApiKeyRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Label))
|
||||
return BadRequest("Label is required");
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
var key = await auth.CreateApiKey(currentUser.Id, request.Label, request.ExpiredAt);
|
||||
key.Key = await auth.IssueApiKeyToken(key);
|
||||
return Ok(key);
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/rotate")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> RotateKey(Guid id)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
var key = await auth.GetApiKey(id, currentUser.Id);
|
||||
if(key is null) return NotFound();
|
||||
key = await auth.RotateApiKeyToken(key);
|
||||
key.Key = await auth.IssueApiKeyToken(key);
|
||||
return Ok(key);
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> DeleteKey(Guid id)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
var key = await auth.GetApiKey(id, currentUser.Id);
|
||||
if(key is null) return NotFound();
|
||||
await auth.RevokeApiKeyToken(key);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
@@ -49,7 +49,10 @@ public class DysonTokenAuthHandler(
|
||||
|
||||
try
|
||||
{
|
||||
var (valid, session, message) = await token.AuthenticateTokenAsync(tokenInfo.Token);
|
||||
// Get client IP address
|
||||
var ipAddress = Context.Connection.RemoteIpAddress?.ToString();
|
||||
|
||||
var (valid, session, message) = await token.AuthenticateTokenAsync(tokenInfo.Token, ipAddress);
|
||||
if (!valid || session is null)
|
||||
return AuthenticateResult.Fail(message ?? "Authentication failed.");
|
||||
|
||||
@@ -67,7 +70,7 @@ public class DysonTokenAuthHandler(
|
||||
};
|
||||
|
||||
// Add scopes as claims
|
||||
session.Challenge.Scopes.ForEach(scope => claims.Add(new Claim("scope", scope)));
|
||||
session.Challenge?.Scopes.ForEach(scope => claims.Add(new Claim("scope", scope)));
|
||||
|
||||
// Add superuser claim if applicable
|
||||
if (session.Account.IsSuperuser)
|
||||
|
@@ -51,7 +51,11 @@ public class AuthController(
|
||||
.Where(e => e.Type == PunishmentType.BlockLogin || e.Type == PunishmentType.DisableAccount)
|
||||
.Where(e => e.ExpiredAt == null || now < e.ExpiredAt)
|
||||
.FirstOrDefaultAsync();
|
||||
if (punishment is not null) return StatusCode(423, punishment);
|
||||
if (punishment is not null)
|
||||
return StatusCode(
|
||||
423,
|
||||
$"Your account has been suspended. Reason: {punishment.Reason}. Expired at: {punishment.ExpiredAt?.ToString() ?? "never"}"
|
||||
);
|
||||
|
||||
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
var userAgent = HttpContext.Request.Headers.UserAgent.ToString();
|
||||
|
@@ -52,7 +52,7 @@ public class AuthService(
|
||||
riskScore += 1;
|
||||
else
|
||||
{
|
||||
if (!string.IsNullOrEmpty(lastActiveInfo?.Challenge.IpAddress) &&
|
||||
if (!string.IsNullOrEmpty(lastActiveInfo?.Challenge?.IpAddress) &&
|
||||
!lastActiveInfo.Challenge.IpAddress.Equals(ipAddress, StringComparison.OrdinalIgnoreCase))
|
||||
riskScore += 1;
|
||||
}
|
||||
@@ -318,6 +318,87 @@ public class AuthService(
|
||||
return factor.VerifyPassword(pinCode);
|
||||
}
|
||||
|
||||
public async Task<ApiKey?> GetApiKey(Guid id, Guid? accountId = null)
|
||||
{
|
||||
var key = await db.ApiKeys
|
||||
.Include(e => e.Session)
|
||||
.Where(e => e.Id == id)
|
||||
.If(accountId.HasValue, q => q.Where(e => e.AccountId == accountId!.Value))
|
||||
.FirstOrDefaultAsync();
|
||||
return key;
|
||||
}
|
||||
|
||||
public async Task<ApiKey> CreateApiKey(Guid accountId, string label, Instant? expiredAt = null)
|
||||
{
|
||||
var key = new ApiKey
|
||||
{
|
||||
AccountId = accountId,
|
||||
Label = label,
|
||||
Session = new AuthSession
|
||||
{
|
||||
AccountId = accountId,
|
||||
ExpiredAt = expiredAt
|
||||
},
|
||||
};
|
||||
|
||||
db.ApiKeys.Add(key);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
public async Task<string> IssueApiKeyToken(ApiKey key)
|
||||
{
|
||||
key.Session.LastGrantedAt = SystemClock.Instance.GetCurrentInstant();
|
||||
db.Update(key.Session);
|
||||
await db.SaveChangesAsync();
|
||||
var tk = CreateToken(key.Session);
|
||||
return tk;
|
||||
}
|
||||
|
||||
public async Task RevokeApiKeyToken(ApiKey key)
|
||||
{
|
||||
db.Remove(key);
|
||||
db.Remove(key.Session);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<ApiKey> RotateApiKeyToken(ApiKey key)
|
||||
{
|
||||
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
var oldSessionId = key.SessionId;
|
||||
|
||||
// Create new session
|
||||
var newSession = new AuthSession
|
||||
{
|
||||
AccountId = key.AccountId,
|
||||
ExpiredAt = key.Session?.ExpiredAt
|
||||
};
|
||||
|
||||
db.AuthSessions.Add(newSession);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Update ApiKey to point to new session
|
||||
key.SessionId = newSession.Id;
|
||||
key.Session = newSession;
|
||||
db.ApiKeys.Update(key);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Delete old session
|
||||
await db.AuthSessions.Where(s => s.Id == oldSessionId).ExecuteDeleteAsync();
|
||||
|
||||
await transaction.CommitAsync();
|
||||
return key;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper methods for Base64Url encoding/decoding
|
||||
private static string Base64UrlEncode(byte[] data)
|
||||
{
|
||||
@@ -329,7 +410,7 @@ public class AuthService(
|
||||
|
||||
private static byte[] Base64UrlDecode(string base64Url)
|
||||
{
|
||||
string padded = base64Url
|
||||
var padded = base64Url
|
||||
.Replace('-', '+')
|
||||
.Replace('_', '/');
|
||||
|
||||
|
@@ -1,9 +1,5 @@
|
||||
using DysonNetwork.Pass.Wallet;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Grpc.Core;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Pass.Auth;
|
||||
|
||||
@@ -18,7 +14,7 @@ public class AuthServiceGrpc(
|
||||
ServerCallContext context
|
||||
)
|
||||
{
|
||||
var (valid, session, message) = await token.AuthenticateTokenAsync(request.Token);
|
||||
var (valid, session, message) = await token.AuthenticateTokenAsync(request.Token, request.IpAddress);
|
||||
if (!valid || session is null)
|
||||
return new AuthenticateResponse { Valid = false, Message = message ?? "Authentication failed." };
|
||||
|
||||
|
@@ -2,7 +2,6 @@ using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using NodaTime.Serialization.Protobuf;
|
||||
using Point = NetTopologySuite.Geometries.Point;
|
||||
@@ -12,26 +11,28 @@ namespace DysonNetwork.Pass.Auth;
|
||||
public class AuthSession : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
[MaxLength(1024)] public string? Label { get; set; }
|
||||
public Instant? LastGrantedAt { get; set; }
|
||||
public Instant? ExpiredAt { get; set; }
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
[JsonIgnore] public Account.Account Account { get; set; } = null!;
|
||||
public Guid ChallengeId { get; set; }
|
||||
public AuthChallenge Challenge { get; set; } = null!;
|
||||
|
||||
// When the challenge is null, indicates the session is for an API Key
|
||||
public Guid? ChallengeId { get; set; }
|
||||
public AuthChallenge? Challenge { get; set; } = null!;
|
||||
|
||||
// Indicates the session is for an OIDC connection
|
||||
public Guid? AppId { get; set; }
|
||||
|
||||
public Shared.Proto.AuthSession ToProtoValue() => new()
|
||||
{
|
||||
Id = Id.ToString(),
|
||||
Label = Label,
|
||||
LastGrantedAt = LastGrantedAt?.ToTimestamp(),
|
||||
ExpiredAt = ExpiredAt?.ToTimestamp(),
|
||||
AccountId = AccountId.ToString(),
|
||||
Account = Account.ToProtoValue(),
|
||||
ChallengeId = ChallengeId.ToString(),
|
||||
Challenge = Challenge.ToProtoValue(),
|
||||
Challenge = Challenge?.ToProtoValue(),
|
||||
AppId = AppId?.ToString()
|
||||
};
|
||||
}
|
||||
@@ -68,7 +69,6 @@ public class AuthChallenge : ModelBase
|
||||
[MaxLength(128)] public string? IpAddress { get; set; }
|
||||
[MaxLength(512)] public string? UserAgent { get; set; }
|
||||
[MaxLength(1024)] public string? Nonce { get; set; }
|
||||
[MaxLength(1024)] public string? DeviceId { get; set; } = string.Empty;
|
||||
public Point? Location { get; set; }
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
|
@@ -5,8 +5,10 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Web;
|
||||
using DysonNetwork.Pass.Account;
|
||||
using DysonNetwork.Pass.Auth.OidcProvider.Options;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using NodaTime;
|
||||
@@ -19,10 +21,199 @@ public class OidcProviderController(
|
||||
AppDatabase db,
|
||||
OidcProviderService oidcService,
|
||||
IConfiguration configuration,
|
||||
IOptions<OidcProviderOptions> options
|
||||
)
|
||||
: ControllerBase
|
||||
IOptions<OidcProviderOptions> options,
|
||||
ILogger<OidcProviderController> logger
|
||||
) : ControllerBase
|
||||
{
|
||||
[HttpGet("authorize")]
|
||||
[Produces("application/json")]
|
||||
public async Task<IActionResult> Authorize(
|
||||
[FromQuery(Name = "client_id")] string clientId,
|
||||
[FromQuery(Name = "response_type")] string responseType,
|
||||
[FromQuery(Name = "redirect_uri")] string? redirectUri = null,
|
||||
[FromQuery] string? scope = null,
|
||||
[FromQuery] string? state = null,
|
||||
[FromQuery(Name = "response_mode")] string? responseMode = null,
|
||||
[FromQuery] string? nonce = null,
|
||||
[FromQuery] string? display = null,
|
||||
[FromQuery] string? prompt = null,
|
||||
[FromQuery(Name = "code_challenge")] string? codeChallenge = null,
|
||||
[FromQuery(Name = "code_challenge_method")]
|
||||
string? codeChallengeMethod = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(clientId))
|
||||
{
|
||||
return BadRequest(new ErrorResponse
|
||||
{
|
||||
Error = "invalid_request",
|
||||
ErrorDescription = "client_id is required"
|
||||
});
|
||||
}
|
||||
|
||||
var client = await oidcService.FindClientBySlugAsync(clientId);
|
||||
if (client == null)
|
||||
{
|
||||
return BadRequest(new ErrorResponse
|
||||
{
|
||||
Error = "unauthorized_client",
|
||||
ErrorDescription = "Client not found"
|
||||
});
|
||||
}
|
||||
|
||||
// Validate response_type
|
||||
if (string.IsNullOrEmpty(responseType))
|
||||
{
|
||||
return BadRequest(new ErrorResponse
|
||||
{
|
||||
Error = "invalid_request",
|
||||
ErrorDescription = "response_type is required"
|
||||
});
|
||||
}
|
||||
|
||||
// Check if the client is allowed to use the requested response type
|
||||
var allowedResponseTypes = new[] { "code", "token", "id_token" };
|
||||
var requestedResponseTypes = responseType.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
if (requestedResponseTypes.Any(rt => !allowedResponseTypes.Contains(rt)))
|
||||
{
|
||||
return BadRequest(new ErrorResponse
|
||||
{
|
||||
Error = "unsupported_response_type",
|
||||
ErrorDescription = "The requested response type is not supported"
|
||||
});
|
||||
}
|
||||
|
||||
// Validate redirect_uri if provided
|
||||
if (!string.IsNullOrEmpty(redirectUri) &&
|
||||
!await oidcService.ValidateRedirectUriAsync(Guid.Parse(client.Id), redirectUri))
|
||||
{
|
||||
return BadRequest(new ErrorResponse
|
||||
{
|
||||
Error = "invalid_request",
|
||||
ErrorDescription = "Invalid redirect_uri"
|
||||
});
|
||||
}
|
||||
|
||||
// Return client information
|
||||
var clientInfo = new ClientInfoResponse
|
||||
{
|
||||
ClientId = Guid.Parse(client.Id),
|
||||
Picture = client.Picture is not null ? CloudFileReferenceObject.FromProtoValue(client.Picture) : null,
|
||||
Background = client.Background is not null
|
||||
? CloudFileReferenceObject.FromProtoValue(client.Background)
|
||||
: null,
|
||||
ClientName = client.Name,
|
||||
HomeUri = client.Links.HomePage,
|
||||
PolicyUri = client.Links.PrivacyPolicy,
|
||||
TermsOfServiceUri = client.Links.TermsOfService,
|
||||
ResponseTypes = responseType,
|
||||
Scopes = scope?.Split(' ', StringSplitOptions.RemoveEmptyEntries) ?? [],
|
||||
State = state,
|
||||
Nonce = nonce,
|
||||
CodeChallenge = codeChallenge,
|
||||
CodeChallengeMethod = codeChallengeMethod
|
||||
};
|
||||
|
||||
return Ok(clientInfo);
|
||||
}
|
||||
|
||||
[HttpPost("authorize")]
|
||||
[Consumes("application/x-www-form-urlencoded")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> HandleAuthorizationResponse(
|
||||
[FromForm(Name = "authorize")] string? authorize,
|
||||
[FromForm(Name = "client_id")] string clientId,
|
||||
[FromForm(Name = "redirect_uri")] string? redirectUri = null,
|
||||
[FromForm] string? scope = null,
|
||||
[FromForm] string? state = null,
|
||||
[FromForm] string? nonce = null,
|
||||
[FromForm(Name = "code_challenge")] string? codeChallenge = null,
|
||||
[FromForm(Name = "code_challenge_method")]
|
||||
string? codeChallengeMethod = null)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account account)
|
||||
return Unauthorized();
|
||||
|
||||
// Find the client
|
||||
var client = await oidcService.FindClientBySlugAsync(clientId);
|
||||
if (client == null)
|
||||
{
|
||||
return BadRequest(new ErrorResponse
|
||||
{
|
||||
Error = "unauthorized_client",
|
||||
ErrorDescription = "Client not found"
|
||||
});
|
||||
}
|
||||
|
||||
// If user denied the request
|
||||
if (string.IsNullOrEmpty(authorize) || !bool.TryParse(authorize, out var isAuthorized) || !isAuthorized)
|
||||
{
|
||||
var errorUri = new UriBuilder(redirectUri ?? client.Links?.HomePage ?? "https://example.com");
|
||||
var queryParams = HttpUtility.ParseQueryString(errorUri.Query);
|
||||
queryParams["error"] = "access_denied";
|
||||
queryParams["error_description"] = "The user denied the authorization request";
|
||||
if (!string.IsNullOrEmpty(state)) queryParams["state"] = state;
|
||||
|
||||
errorUri.Query = queryParams.ToString();
|
||||
return Ok(new { redirectUri = errorUri.Uri.ToString() });
|
||||
}
|
||||
|
||||
// Validate redirect_uri if provided
|
||||
if (!string.IsNullOrEmpty(redirectUri) &&
|
||||
!await oidcService.ValidateRedirectUriAsync(Guid.Parse(client!.Id), redirectUri))
|
||||
{
|
||||
return BadRequest(new ErrorResponse
|
||||
{
|
||||
Error = "invalid_request",
|
||||
ErrorDescription = "Invalid redirect_uri"
|
||||
});
|
||||
}
|
||||
|
||||
// Default to client's first redirect URI if not provided
|
||||
redirectUri ??= client.OauthConfig?.RedirectUris?.FirstOrDefault();
|
||||
if (string.IsNullOrEmpty(redirectUri))
|
||||
{
|
||||
return BadRequest(new ErrorResponse
|
||||
{
|
||||
Error = "invalid_request",
|
||||
ErrorDescription = "No valid redirect_uri available"
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Generate authorization code and create session
|
||||
var authorizationCode = await oidcService.GenerateAuthorizationCodeAsync(
|
||||
Guid.Parse(client.Id),
|
||||
account.Id,
|
||||
redirectUri,
|
||||
scope?.Split(' ') ?? [],
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
nonce
|
||||
);
|
||||
|
||||
// Build the redirect URI with the authorization code
|
||||
var redirectBuilder = new UriBuilder(redirectUri);
|
||||
var queryParams = HttpUtility.ParseQueryString(redirectBuilder.Query);
|
||||
queryParams["code"] = authorizationCode;
|
||||
if (!string.IsNullOrEmpty(state)) queryParams["state"] = state;
|
||||
|
||||
redirectBuilder.Query = queryParams.ToString();
|
||||
|
||||
return Ok(new { redirectUri = redirectBuilder.Uri.ToString() });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error processing authorization request");
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, new ErrorResponse
|
||||
{
|
||||
Error = "server_error",
|
||||
ErrorDescription = "An error occurred while processing your request"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("token")]
|
||||
[Consumes("application/x-www-form-urlencoded")]
|
||||
public async Task<IActionResult> Token([FromForm] TokenRequest request)
|
||||
@@ -35,74 +226,74 @@ public class OidcProviderController(
|
||||
case "authorization_code" when request.Code == null:
|
||||
return BadRequest("Authorization code is required");
|
||||
case "authorization_code":
|
||||
{
|
||||
var client = await oidcService.FindClientByIdAsync(request.ClientId.Value);
|
||||
if (client == null ||
|
||||
!await oidcService.ValidateClientCredentialsAsync(request.ClientId.Value, request.ClientSecret))
|
||||
return BadRequest(new ErrorResponse
|
||||
{
|
||||
var client = await oidcService.FindClientBySlugAsync(request.ClientId);
|
||||
if (client == null ||
|
||||
!await oidcService.ValidateClientCredentialsAsync(Guid.Parse(client.Id), request.ClientSecret))
|
||||
return BadRequest(new ErrorResponse
|
||||
{ Error = "invalid_client", ErrorDescription = "Invalid client credentials" });
|
||||
|
||||
// Generate tokens
|
||||
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
|
||||
clientId: request.ClientId.Value,
|
||||
authorizationCode: request.Code!,
|
||||
redirectUri: request.RedirectUri,
|
||||
codeVerifier: request.CodeVerifier
|
||||
);
|
||||
// Generate tokens
|
||||
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
|
||||
clientId: Guid.Parse(client.Id),
|
||||
authorizationCode: request.Code!,
|
||||
redirectUri: request.RedirectUri,
|
||||
codeVerifier: request.CodeVerifier
|
||||
);
|
||||
|
||||
return Ok(tokenResponse);
|
||||
}
|
||||
return Ok(tokenResponse);
|
||||
}
|
||||
case "refresh_token" when string.IsNullOrEmpty(request.RefreshToken):
|
||||
return BadRequest(new ErrorResponse
|
||||
{ Error = "invalid_request", ErrorDescription = "Refresh token is required" });
|
||||
{ Error = "invalid_request", ErrorDescription = "Refresh token is required" });
|
||||
case "refresh_token":
|
||||
{
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
// Decode the base64 refresh token to get the session ID
|
||||
var sessionIdBytes = Convert.FromBase64String(request.RefreshToken);
|
||||
var sessionId = new Guid(sessionIdBytes);
|
||||
// Decode the base64 refresh token to get the session ID
|
||||
var sessionIdBytes = Convert.FromBase64String(request.RefreshToken);
|
||||
var sessionId = new Guid(sessionIdBytes);
|
||||
|
||||
// Find the session and related data
|
||||
var session = await oidcService.FindSessionByIdAsync(sessionId);
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
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)
|
||||
// Find the session and related data
|
||||
var session = await oidcService.FindSessionByIdAsync(sessionId);
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
if (session?.AppId is null || session.ExpiredAt < now)
|
||||
{
|
||||
return BadRequest(new ErrorResponse
|
||||
{
|
||||
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:
|
||||
return BadRequest(new ErrorResponse { Error = "unsupported_grant_type" });
|
||||
}
|
||||
@@ -116,7 +307,7 @@ public class OidcProviderController(
|
||||
HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
|
||||
|
||||
// Get requested scopes from the token
|
||||
var scopes = currentSession.Challenge.Scopes;
|
||||
var scopes = currentSession.Challenge?.Scopes ?? [];
|
||||
|
||||
var userInfo = new Dictionary<string, object>
|
||||
{
|
||||
@@ -150,10 +341,10 @@ public class OidcProviderController(
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
issuer = issuer,
|
||||
issuer,
|
||||
authorization_endpoint = $"{baseUrl}/auth/authorize",
|
||||
token_endpoint = $"{baseUrl}/auth/open/token",
|
||||
userinfo_endpoint = $"{baseUrl}/auth/open/userinfo",
|
||||
token_endpoint = $"{baseUrl}/api/auth/open/token",
|
||||
userinfo_endpoint = $"{baseUrl}/api/auth/open/userinfo",
|
||||
jwks_uri = $"{baseUrl}/.well-known/jwks",
|
||||
scopes_supported = new[] { "openid", "profile", "email" },
|
||||
response_types_supported = new[]
|
||||
@@ -220,7 +411,7 @@ public class TokenRequest
|
||||
|
||||
[JsonPropertyName("client_id")]
|
||||
[FromForm(Name = "client_id")]
|
||||
public Guid? ClientId { get; set; }
|
||||
public string? ClientId { get; set; }
|
||||
|
||||
[JsonPropertyName("client_secret")]
|
||||
[FromForm(Name = "client_secret")]
|
||||
|
@@ -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")]
|
||||
public string? Scope { get; set; }
|
||||
|
||||
|
||||
[JsonPropertyName("id_token")]
|
||||
public string? IdToken { get; set; }
|
||||
}
|
||||
|
@@ -11,6 +11,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using NodaTime;
|
||||
using AccountContactType = DysonNetwork.Pass.Account.AccountContactType;
|
||||
|
||||
namespace DysonNetwork.Pass.Auth.OidcProvider.Services;
|
||||
|
||||
@@ -31,15 +32,31 @@ public class OidcProviderService(
|
||||
return resp.App ?? null;
|
||||
}
|
||||
|
||||
public async Task<AuthSession?> FindValidSessionAsync(Guid accountId, Guid clientId)
|
||||
public async Task<CustomApp?> FindClientBySlugAsync(string slug)
|
||||
{
|
||||
var resp = await customApps.GetCustomAppAsync(new GetCustomAppRequest { Slug = slug });
|
||||
return resp.App ?? null;
|
||||
}
|
||||
|
||||
public async Task<AuthSession?> FindValidSessionAsync(Guid accountId, Guid clientId, bool withAccount = false)
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
return await db.AuthSessions
|
||||
var queryable = db.AuthSessions
|
||||
.Include(s => s.Challenge)
|
||||
.AsQueryable();
|
||||
if (withAccount)
|
||||
queryable = queryable
|
||||
.Include(s => s.Account)
|
||||
.ThenInclude(a => a.Profile)
|
||||
.Include(a => a.Account.Contacts)
|
||||
.AsQueryable();
|
||||
|
||||
return await queryable
|
||||
.Where(s => s.AccountId == accountId &&
|
||||
s.AppId == clientId &&
|
||||
(s.ExpiredAt == null || s.ExpiredAt > now) &&
|
||||
s.Challenge != null &&
|
||||
s.Challenge.Type == ChallengeType.OAuth)
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.FirstOrDefaultAsync();
|
||||
@@ -56,6 +73,149 @@ public class OidcProviderService(
|
||||
return resp.Valid;
|
||||
}
|
||||
|
||||
public async Task<bool> ValidateRedirectUriAsync(Guid clientId, string redirectUri)
|
||||
{
|
||||
if (string.IsNullOrEmpty(redirectUri))
|
||||
return false;
|
||||
|
||||
|
||||
var client = await FindClientByIdAsync(clientId);
|
||||
if (client?.Status != CustomAppStatus.Production)
|
||||
return true;
|
||||
|
||||
if (client?.OauthConfig?.RedirectUris == null)
|
||||
return false;
|
||||
|
||||
// Check if the redirect URI matches any of the allowed URIs
|
||||
// For exact match
|
||||
if (client.OauthConfig.RedirectUris.Contains(redirectUri))
|
||||
return true;
|
||||
|
||||
// Check for wildcard matches (e.g., https://*.example.com/*)
|
||||
foreach (var allowedUri in client.OauthConfig.RedirectUris)
|
||||
{
|
||||
if (string.IsNullOrEmpty(allowedUri))
|
||||
continue;
|
||||
|
||||
// Handle wildcard in domain
|
||||
if (allowedUri.Contains("*.") && allowedUri.StartsWith("http"))
|
||||
{
|
||||
try
|
||||
{
|
||||
var allowedUriObj = new Uri(allowedUri);
|
||||
var redirectUriObj = new Uri(redirectUri);
|
||||
|
||||
if (allowedUriObj.Scheme != redirectUriObj.Scheme ||
|
||||
allowedUriObj.Port != redirectUriObj.Port)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if the domain matches the wildcard pattern
|
||||
var allowedDomain = allowedUriObj.Host;
|
||||
var redirectDomain = redirectUriObj.Host;
|
||||
|
||||
if (allowedDomain.StartsWith("*."))
|
||||
{
|
||||
var baseDomain = allowedDomain[2..]; // Remove the "*." prefix
|
||||
if (redirectDomain == baseDomain || redirectDomain.EndsWith($".{baseDomain}"))
|
||||
{
|
||||
// Check path
|
||||
var allowedPath = allowedUriObj.AbsolutePath.TrimEnd('/');
|
||||
var redirectPath = redirectUriObj.AbsolutePath.TrimEnd('/');
|
||||
|
||||
if (string.IsNullOrEmpty(allowedPath) ||
|
||||
redirectPath.StartsWith(allowedPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (UriFormatException)
|
||||
{
|
||||
// Invalid URI format in allowed URIs, skip
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private string GenerateIdToken(
|
||||
CustomApp client,
|
||||
AuthSession session,
|
||||
string? nonce = null,
|
||||
IEnumerable<string>? scopes = null
|
||||
)
|
||||
{
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
var clock = SystemClock.Instance;
|
||||
var now = clock.GetCurrentInstant();
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(JwtRegisteredClaimNames.Iss, _options.IssuerUri),
|
||||
new(JwtRegisteredClaimNames.Sub, session.AccountId.ToString()),
|
||||
new(JwtRegisteredClaimNames.Aud, client.Slug),
|
||||
new(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64),
|
||||
new(JwtRegisteredClaimNames.Exp,
|
||||
now.Plus(Duration.FromSeconds(_options.AccessTokenLifetime.TotalSeconds)).ToUnixTimeSeconds()
|
||||
.ToString(), ClaimValueTypes.Integer64),
|
||||
new(JwtRegisteredClaimNames.AuthTime, session.CreatedAt.ToUnixTimeSeconds().ToString(),
|
||||
ClaimValueTypes.Integer64),
|
||||
};
|
||||
|
||||
// Add nonce if provided (required for implicit and hybrid flows)
|
||||
if (!string.IsNullOrEmpty(nonce))
|
||||
{
|
||||
claims.Add(new Claim("nonce", nonce));
|
||||
}
|
||||
|
||||
// Add email claim if email scope is requested
|
||||
var scopesList = scopes?.ToList() ?? [];
|
||||
if (scopesList.Contains("email"))
|
||||
{
|
||||
var contact = session.Account.Contacts.FirstOrDefault(c => c.Type == AccountContactType.Email);
|
||||
if (contact is not null)
|
||||
{
|
||||
claims.Add(new Claim(JwtRegisteredClaimNames.Email, contact.Content));
|
||||
claims.Add(new Claim("email_verified", contact.VerifiedAt is not null ? "true" : "false",
|
||||
ClaimValueTypes.Boolean));
|
||||
}
|
||||
}
|
||||
|
||||
// Add profile claims if profile scope is requested
|
||||
if (scopes != null && scopesList.Contains("profile"))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(session.Account.Name))
|
||||
claims.Add(new Claim("preferred_username", session.Account.Name));
|
||||
if (!string.IsNullOrEmpty(session.Account.Nick))
|
||||
claims.Add(new Claim("name", session.Account.Nick));
|
||||
if (!string.IsNullOrEmpty(session.Account.Profile.FirstName))
|
||||
claims.Add(new Claim("given_name", session.Account.Profile.FirstName));
|
||||
if (!string.IsNullOrEmpty(session.Account.Profile.LastName))
|
||||
claims.Add(new Claim("family_name", session.Account.Profile.LastName));
|
||||
}
|
||||
|
||||
var tokenDescriptor = new SecurityTokenDescriptor
|
||||
{
|
||||
Subject = new ClaimsIdentity(claims),
|
||||
Issuer = _options.IssuerUri,
|
||||
Audience = client.Id.ToString(),
|
||||
Expires = now.Plus(Duration.FromSeconds(_options.AccessTokenLifetime.TotalSeconds)).ToDateTimeUtc(),
|
||||
NotBefore = now.ToDateTimeUtc(),
|
||||
SigningCredentials = new SigningCredentials(
|
||||
new RsaSecurityKey(_options.GetRsaPrivateKey()),
|
||||
SecurityAlgorithms.RsaSha256
|
||||
)
|
||||
};
|
||||
|
||||
var token = tokenHandler.CreateToken(tokenDescriptor);
|
||||
return tokenHandler.WriteToken(token);
|
||||
}
|
||||
|
||||
public async Task<TokenResponse> GenerateTokenResponseAsync(
|
||||
Guid clientId,
|
||||
string? authorizationCode = null,
|
||||
@@ -71,24 +231,43 @@ public class OidcProviderService(
|
||||
AuthSession session;
|
||||
var clock = SystemClock.Instance;
|
||||
var now = clock.GetCurrentInstant();
|
||||
|
||||
string? nonce = null;
|
||||
List<string>? scopes = null;
|
||||
|
||||
if (authorizationCode != null)
|
||||
{
|
||||
// Authorization code flow
|
||||
var authCode = await ValidateAuthorizationCodeAsync(authorizationCode, clientId, redirectUri, codeVerifier);
|
||||
if (authCode is null) throw new InvalidOperationException("Invalid authorization code");
|
||||
var account = await db.Accounts.Where(a => a.Id == authCode.AccountId).FirstOrDefaultAsync();
|
||||
if (account is null) throw new InvalidOperationException("Account was not found");
|
||||
if (authCode == null)
|
||||
throw new InvalidOperationException("Invalid authorization code");
|
||||
|
||||
// Load the session for the user
|
||||
var existingSession = await FindValidSessionAsync(authCode.AccountId, clientId, withAccount: true);
|
||||
|
||||
if (existingSession is null)
|
||||
{
|
||||
var account = await db.Accounts
|
||||
.Where(a => a.Id == authCode.AccountId)
|
||||
.Include(a => a.Profile)
|
||||
.Include(a => a.Contacts)
|
||||
.FirstOrDefaultAsync();
|
||||
if (account is null) throw new InvalidOperationException("Account not found");
|
||||
session = await auth.CreateSessionForOidcAsync(account, clock.GetCurrentInstant(), clientId);
|
||||
session.Account = account;
|
||||
}
|
||||
else
|
||||
{
|
||||
session = existingSession;
|
||||
}
|
||||
|
||||
session = await auth.CreateSessionForOidcAsync(account, now, clientId);
|
||||
scopes = authCode.Scopes;
|
||||
nonce = authCode.Nonce;
|
||||
}
|
||||
else if (sessionId.HasValue)
|
||||
{
|
||||
// Refresh token flow
|
||||
session = await FindSessionByIdAsync(sessionId.Value) ??
|
||||
throw new InvalidOperationException("Invalid session");
|
||||
throw new InvalidOperationException("Session not found");
|
||||
|
||||
// Verify the session is still valid
|
||||
if (session.ExpiredAt < now)
|
||||
@@ -102,13 +281,15 @@ public class OidcProviderService(
|
||||
var expiresIn = (int)_options.AccessTokenLifetime.TotalSeconds;
|
||||
var expiresAt = now.Plus(Duration.FromSeconds(expiresIn));
|
||||
|
||||
// Generate an access token
|
||||
// Generate tokens
|
||||
var accessToken = GenerateJwtToken(client, session, expiresAt, scopes);
|
||||
var idToken = GenerateIdToken(client, session, nonce, scopes);
|
||||
var refreshToken = GenerateRefreshToken(session);
|
||||
|
||||
return new TokenResponse
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
IdToken = idToken,
|
||||
ExpiresIn = expiresIn,
|
||||
TokenType = "Bearer",
|
||||
RefreshToken = refreshToken,
|
||||
@@ -134,11 +315,10 @@ public class OidcProviderService(
|
||||
new Claim(JwtRegisteredClaimNames.Jti, session.Id.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(),
|
||||
ClaimValueTypes.Integer64),
|
||||
new Claim("client_id", client.Id)
|
||||
]),
|
||||
Expires = expiresAt.ToDateTimeUtc(),
|
||||
Issuer = _options.IssuerUri,
|
||||
Audience = client.Id
|
||||
Audience = client.Slug
|
||||
};
|
||||
|
||||
// Try to use RSA signing if keys are available, fall back to HMAC
|
||||
@@ -204,51 +384,6 @@ public class OidcProviderService(
|
||||
return Convert.ToBase64String(session.Id.ToByteArray());
|
||||
}
|
||||
|
||||
private static bool VerifyHashedSecret(string secret, string hashedSecret)
|
||||
{
|
||||
// In a real implementation, you'd use a proper password hashing algorithm like PBKDF2, bcrypt, or Argon2
|
||||
// For now, we'll do a simple comparison, but you should replace this with proper hashing
|
||||
return string.Equals(secret, hashedSecret, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public async Task<string> GenerateAuthorizationCodeForReuseSessionAsync(
|
||||
AuthSession session,
|
||||
Guid clientId,
|
||||
string redirectUri,
|
||||
IEnumerable<string> scopes,
|
||||
string? codeChallenge = null,
|
||||
string? codeChallengeMethod = null,
|
||||
string? nonce = null)
|
||||
{
|
||||
var clock = SystemClock.Instance;
|
||||
var now = clock.GetCurrentInstant();
|
||||
var code = Guid.NewGuid().ToString("N");
|
||||
|
||||
// Update the session's last activity time
|
||||
await db.AuthSessions.Where(s => s.Id == session.Id)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(s => s.LastGrantedAt, now));
|
||||
|
||||
// Create the authorization code info
|
||||
var authCodeInfo = new AuthorizationCodeInfo
|
||||
{
|
||||
ClientId = clientId,
|
||||
AccountId = session.AccountId,
|
||||
RedirectUri = redirectUri,
|
||||
Scopes = scopes.ToList(),
|
||||
CodeChallenge = codeChallenge,
|
||||
CodeChallengeMethod = codeChallengeMethod,
|
||||
Nonce = nonce,
|
||||
CreatedAt = now
|
||||
};
|
||||
|
||||
// Store the code with its metadata in the cache
|
||||
var cacheKey = $"auth:code:{code}";
|
||||
await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime);
|
||||
|
||||
logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, session.AccountId);
|
||||
return code;
|
||||
}
|
||||
|
||||
public async Task<string> GenerateAuthorizationCodeAsync(
|
||||
Guid clientId,
|
||||
Guid userId,
|
||||
@@ -278,7 +413,7 @@ public class OidcProviderService(
|
||||
};
|
||||
|
||||
// Store the code with its metadata in the cache
|
||||
var cacheKey = $"auth:code:{code}";
|
||||
var cacheKey = $"auth:oidc-code:{code}";
|
||||
await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime);
|
||||
|
||||
logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, userId);
|
||||
@@ -292,7 +427,7 @@ public class OidcProviderService(
|
||||
string? codeVerifier = null
|
||||
)
|
||||
{
|
||||
var cacheKey = $"auth:code:{code}";
|
||||
var cacheKey = $"auth:oidc-code:{code}";
|
||||
var (found, authCode) = await cache.GetAsyncWithStatus<AuthorizationCodeInfo>(cacheKey);
|
||||
|
||||
if (!found || authCode == null)
|
||||
|
@@ -1,3 +1,4 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using DysonNetwork.Pass.Wallet;
|
||||
@@ -22,8 +23,9 @@ public class TokenAuthService(
|
||||
/// then cache and return.
|
||||
/// </summary>
|
||||
/// <param name="token">Incoming token string</param>
|
||||
/// <param name="ipAddress">Client IP address, for logging purposes</param>
|
||||
/// <returns>(Valid, Session, Message)</returns>
|
||||
public async Task<(bool Valid, AuthSession? Session, string? Message)> AuthenticateTokenAsync(string token)
|
||||
public async Task<(bool Valid, AuthSession? Session, string? Message)> AuthenticateTokenAsync(string token, string? ipAddress = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -33,6 +35,11 @@ public class TokenAuthService(
|
||||
return (false, null, "No token provided.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(ipAddress))
|
||||
{
|
||||
logger.LogDebug("AuthenticateTokenAsync: client IP: {IpAddress}", ipAddress);
|
||||
}
|
||||
|
||||
// token fingerprint for correlation
|
||||
var tokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
|
||||
var tokenFp = tokenHash[..8];
|
||||
@@ -70,7 +77,7 @@ public class TokenAuthService(
|
||||
"AuthenticateTokenAsync: success via cache (sessionId={SessionId}, accountId={AccountId}, scopes={ScopeCount}, expiresAt={ExpiresAt})",
|
||||
sessionId,
|
||||
session.AccountId,
|
||||
session.Challenge.Scopes.Count,
|
||||
session.Challenge?.Scopes.Count,
|
||||
session.ExpiredAt
|
||||
);
|
||||
return (true, session, null);
|
||||
@@ -103,11 +110,11 @@ public class TokenAuthService(
|
||||
"AuthenticateTokenAsync: DB session loaded (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId}, appId={AppId}, scopes={ScopeCount}, ip={Ip}, uaLen={UaLen})",
|
||||
sessionId,
|
||||
session.AccountId,
|
||||
session.Challenge.ClientId,
|
||||
session.Challenge?.ClientId,
|
||||
session.AppId,
|
||||
session.Challenge.Scopes.Count,
|
||||
session.Challenge.IpAddress,
|
||||
(session.Challenge.UserAgent ?? string.Empty).Length
|
||||
session.Challenge?.Scopes.Count,
|
||||
session.Challenge?.IpAddress,
|
||||
(session.Challenge?.UserAgent ?? string.Empty).Length
|
||||
);
|
||||
|
||||
logger.LogDebug("AuthenticateTokenAsync: enriching account with subscription (accountId={AccountId})", session.AccountId);
|
||||
@@ -136,7 +143,7 @@ public class TokenAuthService(
|
||||
"AuthenticateTokenAsync: success via DB (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId})",
|
||||
sessionId,
|
||||
session.AccountId,
|
||||
session.Challenge.ClientId
|
||||
session.Challenge?.ClientId
|
||||
);
|
||||
return (true, session, null);
|
||||
}
|
||||
|
@@ -60,6 +60,12 @@ const router = createRouter({
|
||||
name: 'authCallback',
|
||||
component: () => import('../views/callback.vue'),
|
||||
},
|
||||
{
|
||||
path: '/auth/authorize',
|
||||
name: 'authAuthorize',
|
||||
component: () => import('../views/authorize.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/:notFound(.*)',
|
||||
name: 'errorNotFound',
|
||||
|
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-center h-full p-4">
|
||||
<n-card class="w-full max-w-md" title="Authorize Application">
|
||||
<n-spin :show="isLoading">
|
||||
<div v-if="error" class="mb-4">
|
||||
<n-alert type="error" :title="error" closable @close="error = null" />
|
||||
</div>
|
||||
|
||||
<!-- App Info Section -->
|
||||
<div v-if="clientInfo" class="mb-6">
|
||||
<div class="flex items-center">
|
||||
<n-avatar
|
||||
v-if="clientInfo.picture"
|
||||
:src="clientInfo.picture.url"
|
||||
:alt="clientInfo.client_name"
|
||||
size="large"
|
||||
class="mr-3"
|
||||
/>
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">
|
||||
{{ clientInfo.client_name || 'Unknown Application' }}
|
||||
</h2>
|
||||
<span v-if="isNewApp">wants to access your Solar Network account</span>
|
||||
<span v-else>wants to access your account</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Requested Permissions -->
|
||||
<n-card size="small" class="mt-4">
|
||||
<h3 class="font-medium mb-2">
|
||||
This will allow {{ clientInfo.client_name || 'the app' }} to:
|
||||
</h3>
|
||||
<ul class="space-y-1">
|
||||
<li v-for="scope in requestedScopes" :key="scope" class="flex items-start">
|
||||
<n-icon :component="CheckBoxFilled" class="mt-1 mr-2" />
|
||||
<span>{{ scope }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</n-card>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="flex gap-3 mt-4">
|
||||
<n-button
|
||||
type="primary"
|
||||
:loading="isAuthorizing"
|
||||
@click="handleAuthorize"
|
||||
class="flex-grow-1 w-1/2"
|
||||
>
|
||||
Authorize
|
||||
</n-button>
|
||||
<n-button
|
||||
type="tertiary"
|
||||
:disabled="isAuthorizing"
|
||||
@click="handleDeny"
|
||||
class="flex-grow-1 w-1/2"
|
||||
>
|
||||
Deny
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-xs text-gray-500 text-center">
|
||||
By authorizing, you agree to the
|
||||
<n-button text type="primary" size="tiny" @click="openTerms" class="px-1">
|
||||
Terms of Service
|
||||
</n-button>
|
||||
and
|
||||
<n-button text type="primary" size="tiny" @click="openPrivacy" class="px-1">
|
||||
Privacy Policy
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</n-spin>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { NCard, NButton, NSpin, NAlert, NAvatar, NIcon } from 'naive-ui'
|
||||
import { CheckBoxFilled } from '@vicons/material'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// State
|
||||
const isLoading = ref(true)
|
||||
const isAuthorizing = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const clientInfo = ref<{
|
||||
client_name?: string
|
||||
home_uri?: string
|
||||
picture?: { url: string }
|
||||
terms_of_service_uri?: string
|
||||
privacy_policy_uri?: string
|
||||
scopes?: string[]
|
||||
} | null>(null)
|
||||
const isNewApp = ref(false)
|
||||
|
||||
// Computed properties
|
||||
const requestedScopes = computed(() => {
|
||||
return clientInfo.value?.scopes || []
|
||||
})
|
||||
|
||||
// Methods
|
||||
async function fetchClientInfo() {
|
||||
try {
|
||||
const response = await fetch(`/api/auth/open/authorize?${window.location.search.slice(1)}`)
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error_description || 'Failed to load authorization request')
|
||||
}
|
||||
clientInfo.value = await response.json()
|
||||
checkIfNewApp()
|
||||
} catch (err: any) {
|
||||
error.value = err.message || 'An error occurred while loading the authorization request'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function checkIfNewApp() {
|
||||
// In a real app, you might want to check if this is the first time authorizing this app
|
||||
// For now, we'll just set it to false
|
||||
isNewApp.value = false
|
||||
}
|
||||
|
||||
async function handleAuthorize() {
|
||||
isAuthorizing.value = true
|
||||
try {
|
||||
// In a real implementation, you would submit the authorization
|
||||
const response = await fetch('/api/auth/open/authorize', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
...route.query,
|
||||
authorize: 'true',
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error_description || 'Authorization failed')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
if (data.redirect_uri) {
|
||||
window.open(data.redirect_uri, '_self')
|
||||
}
|
||||
} catch (err: any) {
|
||||
error.value = err.message || 'An error occurred during authorization'
|
||||
} finally {
|
||||
isAuthorizing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeny() {
|
||||
// Redirect back to the client with an error
|
||||
// Ensure redirect_uri is always a string (not an array)
|
||||
const redirectUriStr = Array.isArray(route.query.redirect_uri)
|
||||
? route.query.redirect_uri[0] || clientInfo.value?.home_uri || '/'
|
||||
: route.query.redirect_uri || clientInfo.value?.home_uri || '/'
|
||||
const redirectUri = new URL(redirectUriStr)
|
||||
// Ensure state is always a string (not an array)
|
||||
const state = Array.isArray(route.query.state)
|
||||
? route.query.state[0] || ''
|
||||
: route.query.state || ''
|
||||
const params = new URLSearchParams({
|
||||
error: 'access_denied',
|
||||
error_description: 'The user denied the authorization request',
|
||||
state: state,
|
||||
})
|
||||
window.open(`${redirectUri}?${params}`, "_self")
|
||||
}
|
||||
|
||||
function openTerms() {
|
||||
window.open(clientInfo.value?.terms_of_service_uri || '#', "_blank")
|
||||
}
|
||||
|
||||
function openPrivacy() {
|
||||
window.open(clientInfo.value?.privacy_policy_uri || '#', "_blank")
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
fetchClientInfo()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Add any custom styles here */
|
||||
</style>
|
||||
|
34
DysonNetwork.Pass/Credit/SocialCreditRecord.cs
Normal file
34
DysonNetwork.Pass/Credit/SocialCreditRecord.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using NodaTime;
|
||||
using NodaTime.Serialization.Protobuf;
|
||||
|
||||
namespace DysonNetwork.Pass.Credit;
|
||||
|
||||
public class SocialCreditRecord : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
[MaxLength(1024)] public string ReasonType { get; set; } = string.Empty;
|
||||
[MaxLength(1024)] public string Reason { get; set; } = string.Empty;
|
||||
public double Delta { get; set; }
|
||||
public Instant? ExpiredAt { get; set; }
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
public Account.Account Account { get; set; } = null!;
|
||||
|
||||
public Shared.Proto.SocialCreditRecord ToProto()
|
||||
{
|
||||
var proto = new Shared.Proto.SocialCreditRecord
|
||||
{
|
||||
Id = Id.ToString(),
|
||||
ReasonType = ReasonType,
|
||||
Reason = Reason,
|
||||
Delta = Delta,
|
||||
AccountId = AccountId.ToString(),
|
||||
CreatedAt = CreatedAt.ToTimestamp(),
|
||||
UpdatedAt = UpdatedAt.ToTimestamp()
|
||||
};
|
||||
|
||||
return proto;
|
||||
}
|
||||
}
|
46
DysonNetwork.Pass/Credit/SocialCreditService.cs
Normal file
46
DysonNetwork.Pass/Credit/SocialCreditService.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DysonNetwork.Pass.Credit;
|
||||
|
||||
public class SocialCreditService(AppDatabase db, ICacheService cache)
|
||||
{
|
||||
private const string CacheKeyPrefix = "account:credits:";
|
||||
|
||||
public async Task<SocialCreditRecord> AddRecord(string reasonType, string reason, double delta, Guid accountId)
|
||||
{
|
||||
var record = new SocialCreditRecord
|
||||
{
|
||||
ReasonType = reasonType,
|
||||
Reason = reason,
|
||||
Delta = delta,
|
||||
AccountId = accountId,
|
||||
};
|
||||
db.SocialCreditRecords.Add(record);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await db.AccountProfiles
|
||||
.Where(p => p.AccountId == accountId)
|
||||
.ExecuteUpdateAsync(p => p.SetProperty(v => v.SocialCredits, v => v.SocialCredits + record.Delta));
|
||||
|
||||
await cache.RemoveAsync($"{CacheKeyPrefix}{accountId}");
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
private const double BaseSocialCredit = 100;
|
||||
|
||||
public async Task<double> GetSocialCredit(Guid accountId)
|
||||
{
|
||||
var cached = await cache.GetAsync<double?>($"{CacheKeyPrefix}{accountId}");
|
||||
if (cached.HasValue) return cached.Value;
|
||||
|
||||
var records = await db.SocialCreditRecords
|
||||
.Where(x => x.AccountId == accountId)
|
||||
.SumAsync(x => x.Delta);
|
||||
records += BaseSocialCredit;
|
||||
|
||||
await cache.SetAsync($"{CacheKeyPrefix}{accountId}", records);
|
||||
return records;
|
||||
}
|
||||
}
|
27
DysonNetwork.Pass/Credit/SocialCreditServiceGrpc.cs
Normal file
27
DysonNetwork.Pass/Credit/SocialCreditServiceGrpc.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Grpc.Core;
|
||||
|
||||
namespace DysonNetwork.Pass.Credit;
|
||||
|
||||
public class SocialCreditServiceGrpc(SocialCreditService creditService) : Shared.Proto.SocialCreditService.SocialCreditServiceBase
|
||||
{
|
||||
public override async Task<Shared.Proto.SocialCreditRecord> AddRecord(AddSocialCreditRecordRequest request, ServerCallContext context)
|
||||
{
|
||||
var accountId = Guid.Parse(request.AccountId);
|
||||
var record = await creditService.AddRecord(
|
||||
request.ReasonType,
|
||||
request.Reason,
|
||||
request.Delta,
|
||||
accountId);
|
||||
|
||||
return record.ToProto();
|
||||
}
|
||||
|
||||
public override async Task<SocialCreditResponse> GetSocialCredit(GetSocialCreditRequest request, ServerCallContext context)
|
||||
{
|
||||
var accountId = Guid.Parse(request.AccountId);
|
||||
var amount = await creditService.GetSocialCredit(accountId);
|
||||
|
||||
return new SocialCreditResponse { Amount = amount };
|
||||
}
|
||||
}
|
@@ -13,6 +13,7 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="NATS.Client.Core" Version="2.6.6" />
|
||||
<PackageReference Include="Nerdbank.GitVersioning" Version="3.7.115">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
@@ -136,19 +137,12 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AdditionalFiles Include="Pages\Emails\AccountDeletionEmail.razor"/>
|
||||
<AdditionalFiles Include="Pages\Emails\ContactVerificationEmail.razor"/>
|
||||
<AdditionalFiles Include="Pages\Emails\EmailLayout.razor"/>
|
||||
<AdditionalFiles Include="Pages\Emails\LandingEmail.razor"/>
|
||||
<AdditionalFiles Include="Pages\Emails\PasswordResetEmail.razor"/>
|
||||
<AdditionalFiles Include="Pages\Emails\VerificationEmail.razor"/>
|
||||
<AdditionalFiles Include="Resources\Localization\AccountEventResource.zh-hans.resx"/>
|
||||
<AdditionalFiles Include="Resources\Localization\EmailResource.resx"/>
|
||||
<AdditionalFiles Include="Resources\Localization\EmailResource.zh-hans.resx"/>
|
||||
<AdditionalFiles Include="Resources\Localization\NotificationResource.resx"/>
|
||||
<AdditionalFiles Include="Resources\Localization\NotificationResource.zh-hans.resx"/>
|
||||
<AdditionalFiles Include="Resources\Localization\SharedResource.resx"/>
|
||||
<AdditionalFiles Include="Resources\Localization\SharedResource.zh-hans.resx"/>
|
||||
<AdditionalFiles Include="Pages\Emails\AccountDeletionEmail.razor" />
|
||||
<AdditionalFiles Include="Pages\Emails\ContactVerificationEmail.razor" />
|
||||
<AdditionalFiles Include="Pages\Emails\EmailLayout.razor" />
|
||||
<AdditionalFiles Include="Pages\Emails\LandingEmail.razor" />
|
||||
<AdditionalFiles Include="Pages\Emails\PasswordResetEmail.razor" />
|
||||
<AdditionalFiles Include="Pages\Emails\VerificationEmail.razor" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
34
DysonNetwork.Pass/Leveling/ExperienceRecord.cs
Normal file
34
DysonNetwork.Pass/Leveling/ExperienceRecord.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using NodaTime.Serialization.Protobuf;
|
||||
|
||||
namespace DysonNetwork.Pass.Leveling;
|
||||
|
||||
public class ExperienceRecord : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
[MaxLength(1024)] public string ReasonType { get; set; } = string.Empty;
|
||||
[MaxLength(1024)] public string Reason { get; set; } = string.Empty;
|
||||
public long Delta { get; set; }
|
||||
public double BonusMultiplier { get; set; }
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
public Account.Account Account { get; set; } = null!;
|
||||
|
||||
public Shared.Proto.ExperienceRecord ToProto()
|
||||
{
|
||||
var proto = new Shared.Proto.ExperienceRecord
|
||||
{
|
||||
Id = Id.ToString(),
|
||||
ReasonType = ReasonType,
|
||||
Reason = Reason,
|
||||
Delta = Delta,
|
||||
BonusMultiplier = BonusMultiplier,
|
||||
AccountId = AccountId.ToString(),
|
||||
CreatedAt = CreatedAt.ToTimestamp(),
|
||||
UpdatedAt = UpdatedAt.ToTimestamp()
|
||||
};
|
||||
|
||||
return proto;
|
||||
}
|
||||
}
|
42
DysonNetwork.Pass/Leveling/ExperienceService.cs
Normal file
42
DysonNetwork.Pass/Leveling/ExperienceService.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using DysonNetwork.Pass.Wallet;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DysonNetwork.Pass.Leveling;
|
||||
|
||||
public class ExperienceService(AppDatabase db, SubscriptionService subscriptions, ICacheService cache)
|
||||
{
|
||||
public async Task<ExperienceRecord> AddRecord(string reasonType, string reason, long delta, Guid accountId)
|
||||
{
|
||||
var record = new ExperienceRecord
|
||||
{
|
||||
ReasonType = reasonType,
|
||||
Reason = reason,
|
||||
Delta = delta,
|
||||
AccountId = accountId,
|
||||
};
|
||||
|
||||
var perkSubscription = await subscriptions.GetPerkSubscriptionAsync(accountId);
|
||||
if (perkSubscription is not null)
|
||||
{
|
||||
record.BonusMultiplier = perkSubscription.Identifier switch
|
||||
{
|
||||
SubscriptionType.Stellar => 1.5,
|
||||
SubscriptionType.Nova => 2,
|
||||
SubscriptionType.Supernova => 2,
|
||||
_ => 1
|
||||
};
|
||||
if (record.Delta >= 0)
|
||||
record.Delta = (long)Math.Floor(record.Delta * record.BonusMultiplier);
|
||||
}
|
||||
|
||||
db.ExperienceRecords.Add(record);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await db.AccountProfiles
|
||||
.Where(p => p.AccountId == accountId)
|
||||
.ExecuteUpdateAsync(p => p.SetProperty(v => v.Experience, v => v.Experience + record.Delta));
|
||||
|
||||
return record;
|
||||
}
|
||||
}
|
19
DysonNetwork.Pass/Leveling/ExperienceServiceGrpc.cs
Normal file
19
DysonNetwork.Pass/Leveling/ExperienceServiceGrpc.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Grpc.Core;
|
||||
|
||||
namespace DysonNetwork.Pass.Leveling;
|
||||
|
||||
public class ExperienceServiceGrpc(ExperienceService experienceService) : Shared.Proto.ExperienceService.ExperienceServiceBase
|
||||
{
|
||||
public override async Task<Shared.Proto.ExperienceRecord> AddRecord(AddExperienceRecordRequest request, ServerCallContext context)
|
||||
{
|
||||
var accountId = Guid.Parse(request.AccountId);
|
||||
var record = await experienceService.AddRecord(
|
||||
request.ReasonType,
|
||||
request.Reason,
|
||||
request.Delta,
|
||||
accountId);
|
||||
|
||||
return record.ToProto();
|
||||
}
|
||||
}
|
1821
DysonNetwork.Pass/Migrations/20250818125540_RemoveChallengeOldDeviceId.Designer.cs
generated
Normal file
1821
DysonNetwork.Pass/Migrations/20250818125540_RemoveChallengeOldDeviceId.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 RemoveChallengeOldDeviceId : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "device_id",
|
||||
table: "auth_challenges");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "device_id",
|
||||
table: "auth_challenges",
|
||||
type: "character varying(1024)",
|
||||
maxLength: 1024,
|
||||
nullable: true);
|
||||
}
|
||||
}
|
||||
}
|
1825
DysonNetwork.Pass/Migrations/20250819162856_AddBotAccount.Designer.cs
generated
Normal file
1825
DysonNetwork.Pass/Migrations/20250819162856_AddBotAccount.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
DysonNetwork.Pass/Migrations/20250819162856_AddBotAccount.cs
Normal file
29
DysonNetwork.Pass/Migrations/20250819162856_AddBotAccount.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Pass.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddBotAccount : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "automated_id",
|
||||
table: "accounts",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "automated_id",
|
||||
table: "accounts");
|
||||
}
|
||||
}
|
||||
}
|
1884
DysonNetwork.Pass/Migrations/20250820104425_AddApiKeys.Designer.cs
generated
Normal file
1884
DysonNetwork.Pass/Migrations/20250820104425_AddApiKeys.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
114
DysonNetwork.Pass/Migrations/20250820104425_AddApiKeys.cs
Normal file
114
DysonNetwork.Pass/Migrations/20250820104425_AddApiKeys.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using NodaTime;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Pass.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddApiKeys : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "fk_auth_sessions_auth_challenges_challenge_id",
|
||||
table: "auth_sessions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "label",
|
||||
table: "auth_sessions");
|
||||
|
||||
migrationBuilder.AlterColumn<Guid>(
|
||||
name: "challenge_id",
|
||||
table: "auth_sessions",
|
||||
type: "uuid",
|
||||
nullable: true,
|
||||
oldClrType: typeof(Guid),
|
||||
oldType: "uuid");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "api_keys",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
label = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||
account_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
session_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_api_keys", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_api_keys_accounts_account_id",
|
||||
column: x => x.account_id,
|
||||
principalTable: "accounts",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_api_keys_auth_sessions_session_id",
|
||||
column: x => x.session_id,
|
||||
principalTable: "auth_sessions",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_api_keys_account_id",
|
||||
table: "api_keys",
|
||||
column: "account_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_api_keys_session_id",
|
||||
table: "api_keys",
|
||||
column: "session_id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "fk_auth_sessions_auth_challenges_challenge_id",
|
||||
table: "auth_sessions",
|
||||
column: "challenge_id",
|
||||
principalTable: "auth_challenges",
|
||||
principalColumn: "id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "fk_auth_sessions_auth_challenges_challenge_id",
|
||||
table: "auth_sessions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "api_keys");
|
||||
|
||||
migrationBuilder.AlterColumn<Guid>(
|
||||
name: "challenge_id",
|
||||
table: "auth_sessions",
|
||||
type: "uuid",
|
||||
nullable: false,
|
||||
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
|
||||
oldClrType: typeof(Guid),
|
||||
oldType: "uuid",
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "label",
|
||||
table: "auth_sessions",
|
||||
type: "character varying(1024)",
|
||||
maxLength: 1024,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "fk_auth_sessions_auth_challenges_challenge_id",
|
||||
table: "auth_sessions",
|
||||
column: "challenge_id",
|
||||
principalTable: "auth_challenges",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
}
|
||||
}
|
2008
DysonNetwork.Pass/Migrations/20250820120632_AddCreditAndLevelingRecords.Designer.cs
generated
Normal file
2008
DysonNetwork.Pass/Migrations/20250820120632_AddCreditAndLevelingRecords.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using NodaTime;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Pass.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCreditAndLevelingRecords : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "experience_records",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
reason_type = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||
reason = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||
delta = table.Column<long>(type: "bigint", nullable: false),
|
||||
account_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_experience_records", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_experience_records_accounts_account_id",
|
||||
column: x => x.account_id,
|
||||
principalTable: "accounts",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "social_credit_records",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
reason_type = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||
reason = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||
delta = table.Column<double>(type: "double precision", nullable: false),
|
||||
expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||
account_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_social_credit_records", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_social_credit_records_accounts_account_id",
|
||||
column: x => x.account_id,
|
||||
principalTable: "accounts",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_experience_records_account_id",
|
||||
table: "experience_records",
|
||||
column: "account_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_social_credit_records_account_id",
|
||||
table: "social_credit_records",
|
||||
column: "account_id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "experience_records");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "social_credit_records");
|
||||
}
|
||||
}
|
||||
}
|
2012
DysonNetwork.Pass/Migrations/20250821093930_AddLevelingBonusMultiplier.Designer.cs
generated
Normal file
2012
DysonNetwork.Pass/Migrations/20250821093930_AddLevelingBonusMultiplier.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Pass.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddLevelingBonusMultiplier : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<double>(
|
||||
name: "bonus_multiplier",
|
||||
table: "experience_records",
|
||||
type: "double precision",
|
||||
nullable: false,
|
||||
defaultValue: 0.0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "bonus_multiplier",
|
||||
table: "experience_records");
|
||||
}
|
||||
}
|
||||
}
|
2016
DysonNetwork.Pass/Migrations/20250822142926_CacheSocialCreditsInProfile.Designer.cs
generated
Normal file
2016
DysonNetwork.Pass/Migrations/20250822142926_CacheSocialCreditsInProfile.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Pass.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class CacheSocialCreditsInProfile : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<double>(
|
||||
name: "social_credits",
|
||||
table: "account_profiles",
|
||||
type: "double precision",
|
||||
nullable: false,
|
||||
defaultValue: 0.0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "social_credits",
|
||||
table: "account_profiles");
|
||||
}
|
||||
}
|
||||
}
|
@@ -98,6 +98,10 @@ namespace DysonNetwork.Pass.Migrations
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("activated_at");
|
||||
|
||||
b.Property<Guid?>("AutomatedId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("automated_id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
@@ -458,6 +462,10 @@ namespace DysonNetwork.Pass.Migrations
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("pronouns");
|
||||
|
||||
b.Property<double>("SocialCredits")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("social_credits");
|
||||
|
||||
b.Property<string>("TimeZone")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
@@ -796,6 +804,51 @@ namespace DysonNetwork.Pass.Migrations
|
||||
b.ToTable("account_statuses", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Pass.Auth.ApiKey", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("account_id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("label");
|
||||
|
||||
b.Property<Guid>("SessionId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("session_id");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_api_keys");
|
||||
|
||||
b.HasIndex("AccountId")
|
||||
.HasDatabaseName("ix_api_keys_account_id");
|
||||
|
||||
b.HasIndex("SessionId")
|
||||
.HasDatabaseName("ix_api_keys_session_id");
|
||||
|
||||
b.ToTable("api_keys", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Pass.Auth.AuthChallenge", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -829,11 +882,6 @@ namespace DysonNetwork.Pass.Migrations
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<string>("DeviceId")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("device_id");
|
||||
|
||||
b.Property<Instant?>("ExpiredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
@@ -962,7 +1010,7 @@ namespace DysonNetwork.Pass.Migrations
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("app_id");
|
||||
|
||||
b.Property<Guid>("ChallengeId")
|
||||
b.Property<Guid?>("ChallengeId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("challenge_id");
|
||||
|
||||
@@ -978,11 +1026,6 @@ namespace DysonNetwork.Pass.Migrations
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("label");
|
||||
|
||||
b.Property<Instant?>("LastGrantedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_granted_at");
|
||||
@@ -1003,6 +1046,110 @@ namespace DysonNetwork.Pass.Migrations
|
||||
b.ToTable("auth_sessions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Pass.Credit.SocialCreditRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("account_id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<double>("Delta")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("delta");
|
||||
|
||||
b.Property<Instant?>("ExpiredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("reason");
|
||||
|
||||
b.Property<string>("ReasonType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("reason_type");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_social_credit_records");
|
||||
|
||||
b.HasIndex("AccountId")
|
||||
.HasDatabaseName("ix_social_credit_records_account_id");
|
||||
|
||||
b.ToTable("social_credit_records", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Pass.Leveling.ExperienceRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("account_id");
|
||||
|
||||
b.Property<double>("BonusMultiplier")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("bonus_multiplier");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<long>("Delta")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("delta");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("reason");
|
||||
|
||||
b.Property<string>("ReasonType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("reason_type");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_experience_records");
|
||||
|
||||
b.HasIndex("AccountId")
|
||||
.HasDatabaseName("ix_experience_records_account_id");
|
||||
|
||||
b.ToTable("experience_records", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Pass.Permission.PermissionGroup", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -1633,6 +1780,27 @@ namespace DysonNetwork.Pass.Migrations
|
||||
b.Navigation("Account");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Pass.Auth.ApiKey", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Pass.Account.Account", "Account")
|
||||
.WithMany()
|
||||
.HasForeignKey("AccountId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_api_keys_accounts_account_id");
|
||||
|
||||
b.HasOne("DysonNetwork.Pass.Auth.AuthSession", "Session")
|
||||
.WithMany()
|
||||
.HasForeignKey("SessionId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_api_keys_auth_sessions_session_id");
|
||||
|
||||
b.Navigation("Account");
|
||||
|
||||
b.Navigation("Session");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Pass.Auth.AuthChallenge", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Pass.Account.Account", "Account")
|
||||
@@ -1676,8 +1844,6 @@ namespace DysonNetwork.Pass.Migrations
|
||||
b.HasOne("DysonNetwork.Pass.Auth.AuthChallenge", "Challenge")
|
||||
.WithMany()
|
||||
.HasForeignKey("ChallengeId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_auth_sessions_auth_challenges_challenge_id");
|
||||
|
||||
b.Navigation("Account");
|
||||
@@ -1685,6 +1851,30 @@ namespace DysonNetwork.Pass.Migrations
|
||||
b.Navigation("Challenge");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Pass.Credit.SocialCreditRecord", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Pass.Account.Account", "Account")
|
||||
.WithMany()
|
||||
.HasForeignKey("AccountId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_social_credit_records_accounts_account_id");
|
||||
|
||||
b.Navigation("Account");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Pass.Leveling.ExperienceRecord", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Pass.Account.Account", "Account")
|
||||
.WithMany()
|
||||
.HasForeignKey("AccountId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_experience_records_accounts_account_id");
|
||||
|
||||
b.Navigation("Account");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Pass.Permission.PermissionGroupMember", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Pass.Permission.PermissionGroup", "Group")
|
||||
|
@@ -1,10 +1,10 @@
|
||||
using DysonNetwork.Pass;
|
||||
using DysonNetwork.Pass.Auth;
|
||||
using DysonNetwork.Pass.Pages.Data;
|
||||
using DysonNetwork.Pass.Startup;
|
||||
using DysonNetwork.Shared.Http;
|
||||
using DysonNetwork.Shared.PageData;
|
||||
using DysonNetwork.Shared.Registry;
|
||||
using DysonNetwork.Shared.Stream;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
@@ -17,6 +17,7 @@ builder.Services.AddAppMetrics();
|
||||
|
||||
// Add application services
|
||||
builder.Services.AddRegistryService(builder.Configuration);
|
||||
builder.Services.AddStreamConnection(builder.Configuration);
|
||||
builder.Services.AddAppServices(builder.Configuration);
|
||||
builder.Services.AddAppRateLimiting();
|
||||
builder.Services.AddAppAuthentication();
|
||||
|
@@ -86,4 +86,10 @@
|
||||
<data name="NewLoginBody" xml:space="preserve">
|
||||
<value>Your account logged on to a device named {0} at {1}</value>
|
||||
</data>
|
||||
<data name="FriendRequestTitle" xml:space="preserve">
|
||||
<value>{0} requested to be your friend</value>
|
||||
</data>
|
||||
<data name="FriendRequestBody" xml:space="preserve">
|
||||
<value>You can go to relationships page and decide accept their request or not.</value>
|
||||
</data>
|
||||
</root>
|
@@ -78,4 +78,10 @@
|
||||
<data name="NewLoginBody" xml:space="preserve">
|
||||
<value>您的帐号在位于 {1} 的设备 {0} 上刚刚登陆了</value>
|
||||
</data>
|
||||
<data name="FriendRequestTitle" xml:space="preserve">
|
||||
<value>{0} 请求成为您的好友</value>
|
||||
</data>
|
||||
<data name="FriendRequestBody" xml:space="preserve">
|
||||
<value>您可以前往人际关系页面来决定时候要接受他们的邀请。</value>
|
||||
</data>
|
||||
</root>
|
@@ -1,6 +1,8 @@
|
||||
using System.Net;
|
||||
using DysonNetwork.Pass.Account;
|
||||
using DysonNetwork.Pass.Auth;
|
||||
using DysonNetwork.Pass.Credit;
|
||||
using DysonNetwork.Pass.Leveling;
|
||||
using DysonNetwork.Pass.Permission;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
@@ -76,6 +78,9 @@ public static class ApplicationConfiguration
|
||||
app.MapGrpcService<AuthServiceGrpc>();
|
||||
app.MapGrpcService<ActionLogServiceGrpc>();
|
||||
app.MapGrpcService<PermissionServiceGrpc>();
|
||||
app.MapGrpcService<SocialCreditServiceGrpc>();
|
||||
app.MapGrpcService<ExperienceServiceGrpc>();
|
||||
app.MapGrpcService<BotAccountReceiverGrpc>();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
@@ -15,7 +15,9 @@ using System.Text.Json;
|
||||
using System.Threading.RateLimiting;
|
||||
using DysonNetwork.Pass.Auth.OidcProvider.Options;
|
||||
using DysonNetwork.Pass.Auth.OidcProvider.Services;
|
||||
using DysonNetwork.Pass.Credit;
|
||||
using DysonNetwork.Pass.Handlers;
|
||||
using DysonNetwork.Pass.Leveling;
|
||||
using DysonNetwork.Pass.Safety;
|
||||
using DysonNetwork.Pass.Wallet.PaymentHandlers;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
@@ -203,6 +205,8 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<PaymentService>();
|
||||
services.AddScoped<AfdianPaymentHandler>();
|
||||
services.AddScoped<SafetyService>();
|
||||
services.AddScoped<SocialCreditService>();
|
||||
services.AddScoped<ExperienceService>();
|
||||
|
||||
services.Configure<OidcProviderOptions>(configuration.GetSection("OidcProvider"));
|
||||
services.AddScoped<OidcProviderService>();
|
||||
|
@@ -12,7 +12,8 @@
|
||||
"ConnectionStrings": {
|
||||
"App": "Host=localhost;Port=5432;Database=dyson_pass;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60",
|
||||
"FastRetrieve": "localhost:6379",
|
||||
"Etcd": "etcd.orb.local:2379"
|
||||
"Etcd": "etcd.orb.local:2379",
|
||||
"Stream": "nats.orb.local:4222"
|
||||
},
|
||||
"Authentication": {
|
||||
"Schemes": {
|
||||
|
@@ -24,7 +24,7 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext>
|
||||
}
|
||||
|
||||
var accountId = currentUser.Id!;
|
||||
var deviceId = currentSession.Challenge.DeviceId!;
|
||||
var deviceId = currentSession.Challenge?.DeviceId ?? Guid.NewGuid().ToString();
|
||||
|
||||
if (string.IsNullOrEmpty(deviceId))
|
||||
{
|
||||
@@ -32,7 +32,8 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext>
|
||||
return;
|
||||
}
|
||||
|
||||
using var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync();
|
||||
var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync(new WebSocketAcceptContext
|
||||
{ KeepAliveInterval = TimeSpan.FromSeconds(60) });
|
||||
var cts = new CancellationTokenSource();
|
||||
var connectionKey = (accountId, deviceId);
|
||||
|
||||
@@ -65,7 +66,12 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext>
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "WebSocket disconnected with user @{UserName}#{UserId} and device #{DeviceId} unexpectedly");
|
||||
logger.LogError(ex,
|
||||
"WebSocket disconnected with user @{UserName}#{UserId} and device #{DeviceId} unexpectedly",
|
||||
currentUser.Name,
|
||||
currentUser.Id,
|
||||
deviceId
|
||||
);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -99,7 +105,7 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext>
|
||||
break;
|
||||
|
||||
var packet = WebSocketPacket.FromBytes(buffer[..receiveResult.Count]);
|
||||
_ = ws.HandlePacket(currentUser, connectionKey.DeviceId, packet, webSocket);
|
||||
await ws.HandlePacket(currentUser, connectionKey.DeviceId, packet, webSocket);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
|
@@ -49,11 +49,18 @@ public class WebSocketService
|
||||
public void Disconnect((string AccountId, string DeviceId) key, string? reason = null)
|
||||
{
|
||||
if (!ActiveConnections.TryGetValue(key, out var data)) return;
|
||||
data.Socket.CloseAsync(
|
||||
WebSocketCloseStatus.NormalClosure,
|
||||
reason ?? "Server just decided to disconnect.",
|
||||
CancellationToken.None
|
||||
);
|
||||
try
|
||||
{
|
||||
data.Socket.CloseAsync(
|
||||
WebSocketCloseStatus.NormalClosure,
|
||||
reason ?? "Server just decided to disconnect.",
|
||||
CancellationToken.None
|
||||
);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error while closing WebSocket for {AccountId}:{DeviceId}", key.AccountId, key.DeviceId);
|
||||
}
|
||||
data.Cts.Cancel();
|
||||
ActiveConnections.TryRemove(key, out _);
|
||||
}
|
||||
|
@@ -1,34 +1,29 @@
|
||||
using CorePush.Apple;
|
||||
using CorePush.Firebase;
|
||||
using DysonNetwork.Pusher.Connection;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using DysonNetwork.Pusher.Services;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using System.Threading.Channels;
|
||||
using WebSocketPacket = DysonNetwork.Pusher.Connection.WebSocketPacket;
|
||||
|
||||
namespace DysonNetwork.Pusher.Notification;
|
||||
|
||||
public class PushService : IDisposable
|
||||
public class PushService
|
||||
{
|
||||
private readonly AppDatabase _db;
|
||||
private readonly FlushBufferService _fbs;
|
||||
private readonly WebSocketService _ws;
|
||||
private readonly QueueService _queueService;
|
||||
private readonly ILogger<PushService> _logger;
|
||||
private readonly FirebaseSender? _fcm;
|
||||
private readonly ApnSender? _apns;
|
||||
private readonly string? _apnsTopic;
|
||||
|
||||
private readonly Channel<PushWorkItem> _channel;
|
||||
private readonly int _maxConcurrency;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private readonly List<Task> _workers = new();
|
||||
|
||||
public PushService(
|
||||
IConfiguration config,
|
||||
AppDatabase db,
|
||||
FlushBufferService fbs,
|
||||
WebSocketService ws,
|
||||
QueueService queueService,
|
||||
IHttpClientFactory httpFactory,
|
||||
ILogger<PushService> logger
|
||||
)
|
||||
@@ -58,48 +53,9 @@ public class PushService : IDisposable
|
||||
}
|
||||
|
||||
_db = db;
|
||||
_fbs = fbs;
|
||||
_ws = ws;
|
||||
_queueService = queueService;
|
||||
_logger = logger;
|
||||
|
||||
// --- Concurrency & channel config ---
|
||||
// Defaults: 8 workers, bounded capacity 2000 items.
|
||||
_maxConcurrency = Math.Max(1, cfgSection.GetValue<int?>("MaxConcurrency") ?? 8);
|
||||
var capacity = Math.Max(1, cfgSection.GetValue<int?>("ChannelCapacity") ?? 2000);
|
||||
|
||||
_channel = Channel.CreateBounded<PushWorkItem>(new BoundedChannelOptions(capacity)
|
||||
{
|
||||
SingleWriter = false,
|
||||
SingleReader = false,
|
||||
FullMode = BoundedChannelFullMode.Wait, // apply backpressure instead of dropping
|
||||
AllowSynchronousContinuations = false
|
||||
});
|
||||
|
||||
// Start background consumers
|
||||
for (int i = 0; i < _maxConcurrency; i++)
|
||||
{
|
||||
_workers.Add(Task.Run(() => WorkerLoop(_cts.Token)));
|
||||
}
|
||||
|
||||
_logger.LogInformation("PushService initialized with {Workers} workers and capacity {Capacity}", _maxConcurrency, capacity);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
_channel.Writer.TryComplete();
|
||||
_cts.Cancel();
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
|
||||
try
|
||||
{
|
||||
Task.WhenAll(_workers).Wait(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
|
||||
_cts.Dispose();
|
||||
}
|
||||
|
||||
public async Task UnsubscribeDevice(string deviceId)
|
||||
@@ -165,7 +121,7 @@ public class PushService : IDisposable
|
||||
{
|
||||
meta ??= [];
|
||||
if (title is null && subtitle is null && content is null)
|
||||
throw new ArgumentException("Unable to send notification that completely empty.");
|
||||
throw new ArgumentException("Unable to send notification that is completely empty.");
|
||||
|
||||
if (actionUri is not null) meta["action_uri"] = actionUri;
|
||||
|
||||
@@ -181,35 +137,63 @@ public class PushService : IDisposable
|
||||
};
|
||||
|
||||
if (save)
|
||||
_fbs.Enqueue(notification);
|
||||
{
|
||||
_db.Notifications.Add(notification);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
if (!isSilent)
|
||||
await DeliveryNotification(notification); // returns quickly (does NOT wait for APNS/FCM)
|
||||
_ = _queueService.EnqueuePushNotification(notification, accountId, save);
|
||||
}
|
||||
|
||||
private async Task DeliveryNotification(Notification notification)
|
||||
public async Task DeliverPushNotification(Notification notification, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Delivering notification: {NotificationTopic} #{NotificationId} with meta {NotificationMeta}",
|
||||
notification.Topic,
|
||||
notification.Id,
|
||||
notification.Meta
|
||||
);
|
||||
|
||||
// WS send: still immediate (fire-and-forget from caller perspective)
|
||||
_ws.SendPacketToAccount(notification.AccountId.ToString(), new Connection.WebSocketPacket
|
||||
_ws.SendPacketToAccount(notification.AccountId.ToString(), new WebSocketPacket()
|
||||
{
|
||||
Type = "notifications.new",
|
||||
Data = notification
|
||||
Data = notification,
|
||||
});
|
||||
|
||||
// Query subscribers and enqueue push work (non-blocking to the HTTP request)
|
||||
var subscribers = await _db.PushSubscriptions
|
||||
.Where(s => s.AccountId == notification.AccountId)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
try
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Delivering push notification: {NotificationTopic} with meta {NotificationMeta}",
|
||||
notification.Topic,
|
||||
notification.Meta
|
||||
);
|
||||
|
||||
await EnqueuePushWork(notification, subscribers);
|
||||
// Get all push subscriptions for the account
|
||||
var subscriptions = await _db.PushSubscriptions
|
||||
.Where(s => s.AccountId == notification.AccountId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (subscriptions.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("No push subscriptions found for account {AccountId}", notification.AccountId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Send push notifications
|
||||
var tasks = new List<Task>();
|
||||
foreach (var subscription in subscriptions)
|
||||
{
|
||||
try
|
||||
{
|
||||
tasks.Add(SendPushNotificationAsync(subscription, notification));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error sending push notification to {DeviceId}", subscription.DeviceId);
|
||||
}
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in DeliverPushNotification");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task MarkNotificationsViewed(ICollection<Notification> notifications)
|
||||
@@ -228,6 +212,7 @@ public class PushService : IDisposable
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
await _db.Notifications
|
||||
.Where(n => n.AccountId == accountId)
|
||||
.Where(n => n.ViewedAt == null)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(n => n.ViewedAt, now));
|
||||
}
|
||||
|
||||
@@ -235,20 +220,25 @@ public class PushService : IDisposable
|
||||
{
|
||||
if (save)
|
||||
{
|
||||
accounts.ForEach(x =>
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var notifications = accounts.Select(accountId => new Notification
|
||||
{
|
||||
var newNotification = new Notification
|
||||
{
|
||||
Topic = notification.Topic,
|
||||
Title = notification.Title,
|
||||
Subtitle = notification.Subtitle,
|
||||
Content = notification.Content,
|
||||
Meta = notification.Meta,
|
||||
Priority = notification.Priority,
|
||||
AccountId = x
|
||||
};
|
||||
_fbs.Enqueue(newNotification);
|
||||
});
|
||||
Topic = notification.Topic,
|
||||
Title = notification.Title,
|
||||
Subtitle = notification.Subtitle,
|
||||
Content = notification.Content,
|
||||
Meta = notification.Meta,
|
||||
Priority = notification.Priority,
|
||||
AccountId = accountId,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
}).ToList();
|
||||
|
||||
if (notifications.Count != 0)
|
||||
{
|
||||
await _db.Notifications.AddRangeAsync(notifications);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
@@ -269,56 +259,10 @@ public class PushService : IDisposable
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch all subscribers once and enqueue to workers
|
||||
var subscribers = await _db.PushSubscriptions
|
||||
.Where(s => accounts.Contains(s.AccountId))
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
await EnqueuePushWork(notification, subscribers);
|
||||
await DeliverPushNotification(notification);
|
||||
}
|
||||
|
||||
private async Task EnqueuePushWork(Notification notification, IEnumerable<PushSubscription> subscriptions)
|
||||
{
|
||||
foreach (var sub in subscriptions)
|
||||
{
|
||||
// Use the current notification reference (no mutation of content after this point).
|
||||
var item = new PushWorkItem(notification, sub);
|
||||
|
||||
// Respect backpressure if channel is full.
|
||||
await _channel.Writer.WriteAsync(item, _cts.Token);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WorkerLoop(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await foreach (var item in _channel.Reader.ReadAllAsync(ct))
|
||||
{
|
||||
try
|
||||
{
|
||||
await _PushSingleNotification(item.Notification, item.Subscription);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Worker handled exception for notification #{Id}", item.Notification.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// normal shutdown
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct PushWorkItem(Notification Notification, PushSubscription Subscription);
|
||||
|
||||
private async Task _PushSingleNotification(Notification notification, PushSubscription subscription)
|
||||
private async Task SendPushNotificationAsync(PushSubscription subscription, Notification notification)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -415,4 +359,27 @@ public class PushService : IDisposable
|
||||
_logger.LogInformation(
|
||||
$"Successfully pushed notification #{notification.Id} to device {subscription.DeviceId} provider {subscription.Provider}");
|
||||
}
|
||||
|
||||
public async Task SaveNotification(Notification notification)
|
||||
{
|
||||
_db.Notifications.Add(notification);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task SaveNotification(Notification notification, List<Guid> accounts)
|
||||
{
|
||||
_db.Notifications.AddRange(accounts.Select(a => new Notification
|
||||
{
|
||||
AccountId = a,
|
||||
Topic = notification.Topic,
|
||||
Content = notification.Content,
|
||||
Title = notification.Title,
|
||||
Subtitle = notification.Subtitle,
|
||||
Meta = notification.Meta,
|
||||
Priority = notification.Priority,
|
||||
CreatedAt = notification.CreatedAt,
|
||||
UpdatedAt = notification.UpdatedAt,
|
||||
}));
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
}
|
@@ -3,6 +3,7 @@ using DysonNetwork.Pusher.Startup;
|
||||
using DysonNetwork.Shared.Auth;
|
||||
using DysonNetwork.Shared.Http;
|
||||
using DysonNetwork.Shared.Registry;
|
||||
using DysonNetwork.Shared.Stream;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
@@ -12,6 +13,7 @@ builder.ConfigureAppKestrel(builder.Configuration);
|
||||
|
||||
// Add application services
|
||||
builder.Services.AddRegistryService(builder.Configuration);
|
||||
builder.Services.AddStreamConnection(builder.Configuration);
|
||||
builder.Services.AddAppServices(builder.Configuration);
|
||||
builder.Services.AddAppRateLimiting();
|
||||
builder.Services.AddAppAuthentication();
|
||||
|
@@ -5,19 +5,19 @@ using DysonNetwork.Shared.Proto;
|
||||
using DysonNetwork.Shared.Registry;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using Grpc.Core;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DysonNetwork.Pusher.Services;
|
||||
|
||||
public class PusherServiceGrpc(
|
||||
EmailService emailService,
|
||||
QueueService queueService,
|
||||
WebSocketService websocket,
|
||||
PushService pushService,
|
||||
AccountClientHelper accountsHelper
|
||||
PushService pushService
|
||||
) : PusherService.PusherServiceBase
|
||||
{
|
||||
public override async Task<Empty> SendEmail(SendEmailRequest request, ServerCallContext context)
|
||||
{
|
||||
await emailService.SendEmailAsync(
|
||||
await queueService.EnqueueEmail(
|
||||
request.Email.ToName,
|
||||
request.Email.ToAddress,
|
||||
request.Email.Subject,
|
||||
@@ -47,13 +47,16 @@ public class PusherServiceGrpc(
|
||||
Data = GrpcTypeHelper.ConvertByteStringToObject<Dictionary<string, object?>>(request.Packet.Data),
|
||||
ErrorMessage = request.Packet.ErrorMessage
|
||||
};
|
||||
|
||||
foreach (var userId in request.UserIds)
|
||||
{
|
||||
websocket.SendPacketToAccount(userId, packet);
|
||||
}
|
||||
|
||||
return Task.FromResult(new Empty());
|
||||
}
|
||||
|
||||
public override Task<Empty> PushWebSocketPacketToDevice(PushWebSocketPacketToDeviceRequest request,
|
||||
public override Task<Empty> PushWebSocketPacketToDevice(PushWebSocketPacketToDeviceRequest request,
|
||||
ServerCallContext context)
|
||||
{
|
||||
var packet = new Connection.WebSocketPacket
|
||||
@@ -75,8 +78,11 @@ public class PusherServiceGrpc(
|
||||
Data = GrpcTypeHelper.ConvertByteStringToObject<Dictionary<string, object?>>(request.Packet.Data),
|
||||
ErrorMessage = request.Packet.ErrorMessage
|
||||
};
|
||||
|
||||
foreach (var deviceId in request.DeviceIds)
|
||||
{
|
||||
websocket.SendPacketToDevice(deviceId, packet);
|
||||
}
|
||||
|
||||
return Task.FromResult(new Empty());
|
||||
}
|
||||
@@ -84,20 +90,30 @@ public class PusherServiceGrpc(
|
||||
public override async Task<Empty> SendPushNotificationToUser(SendPushNotificationToUserRequest request,
|
||||
ServerCallContext context)
|
||||
{
|
||||
var account = await accountsHelper.GetAccount(Guid.Parse(request.UserId));
|
||||
await pushService.SendNotification(
|
||||
account,
|
||||
request.Notification.Topic,
|
||||
request.Notification.Title,
|
||||
request.Notification.Subtitle,
|
||||
request.Notification.Body,
|
||||
request.Notification.HasMeta
|
||||
var notification = new Notification.Notification
|
||||
{
|
||||
Topic = request.Notification.Topic,
|
||||
Title = request.Notification.Title,
|
||||
Subtitle = request.Notification.Subtitle,
|
||||
Content = request.Notification.Body,
|
||||
Meta = request.Notification.HasMeta
|
||||
? GrpcTypeHelper.ConvertByteStringToObject<Dictionary<string, object?>>(request.Notification.Meta) ?? []
|
||||
: [],
|
||||
request.Notification.ActionUri,
|
||||
request.Notification.IsSilent,
|
||||
AccountId = Guid.Parse(request.UserId),
|
||||
};
|
||||
|
||||
if (request.Notification.ActionUri is not null)
|
||||
notification.Meta["action_uri"] = request.Notification.ActionUri;
|
||||
|
||||
if (request.Notification.IsSavable)
|
||||
await pushService.SaveNotification(notification);
|
||||
|
||||
await queueService.EnqueuePushNotification(
|
||||
notification,
|
||||
Guid.Parse(request.UserId),
|
||||
request.Notification.IsSavable
|
||||
);
|
||||
|
||||
return new Empty();
|
||||
}
|
||||
|
||||
@@ -114,10 +130,22 @@ public class PusherServiceGrpc(
|
||||
? GrpcTypeHelper.ConvertByteStringToObject<Dictionary<string, object?>>(request.Notification.Meta) ?? []
|
||||
: [],
|
||||
};
|
||||
|
||||
if (request.Notification.ActionUri is not null)
|
||||
notification.Meta["action_uri"] = request.Notification.ActionUri;
|
||||
var accounts = request.UserIds.Select(Guid.Parse).ToList();
|
||||
await pushService.SendNotificationBatch(notification, accounts, request.Notification.IsSavable);
|
||||
|
||||
var userIds = request.UserIds.Select(Guid.Parse).ToList();
|
||||
if (request.Notification.IsSavable)
|
||||
await pushService.SaveNotification(notification, userIds);
|
||||
|
||||
var tasks = userIds
|
||||
.Select(userId => queueService.EnqueuePushNotification(
|
||||
notification,
|
||||
userId,
|
||||
request.Notification.IsSavable
|
||||
));
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
return new Empty();
|
||||
}
|
||||
|
||||
|
141
DysonNetwork.Pusher/Services/QueueBackgroundService.cs
Normal file
141
DysonNetwork.Pusher/Services/QueueBackgroundService.cs
Normal file
@@ -0,0 +1,141 @@
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Pusher.Email;
|
||||
using DysonNetwork.Pusher.Notification;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using DysonNetwork.Shared.Registry;
|
||||
using Google.Protobuf;
|
||||
using NATS.Client.Core;
|
||||
|
||||
namespace DysonNetwork.Pusher.Services;
|
||||
|
||||
public class QueueBackgroundService(
|
||||
INatsConnection nats,
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<QueueBackgroundService> logger,
|
||||
IConfiguration configuration
|
||||
)
|
||||
: BackgroundService
|
||||
{
|
||||
public const string QueueName = "pusher.queue";
|
||||
public const string QueueGroup = "pusher.workers";
|
||||
private readonly int _consumerCount = configuration.GetValue<int?>("ConsumerCount") ?? Environment.ProcessorCount;
|
||||
private readonly List<Task> _consumerTasks = [];
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
logger.LogInformation("Starting {ConsumerCount} queue consumers", _consumerCount);
|
||||
|
||||
// Start multiple consumers
|
||||
for (var i = 0; i < _consumerCount; i++)
|
||||
_consumerTasks.Add(Task.Run(() => RunConsumerAsync(stoppingToken), stoppingToken));
|
||||
|
||||
// Wait for all consumers to complete
|
||||
await Task.WhenAll(_consumerTasks);
|
||||
}
|
||||
|
||||
private async Task RunConsumerAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
logger.LogInformation("Queue consumer started");
|
||||
|
||||
await foreach (var msg in nats.SubscribeAsync<byte[]>(
|
||||
QueueName,
|
||||
queueGroup: QueueGroup,
|
||||
cancellationToken: stoppingToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
var message = GrpcTypeHelper.ConvertByteStringToObject<QueueMessage>(ByteString.CopyFrom(msg.Data));
|
||||
if (message is not null)
|
||||
{
|
||||
await ProcessMessageAsync(msg, message, stoppingToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogWarning($"Invalid message format for {msg.Subject}");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
// Normal shutdown
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error in queue consumer");
|
||||
// Add a small delay to prevent tight error loops
|
||||
await Task.Delay(1000, stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask ProcessMessageAsync(NatsMsg<byte[]> rawMsg, QueueMessage message,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
|
||||
logger.LogDebug("Processing message of type {MessageType}", message.Type);
|
||||
|
||||
try
|
||||
{
|
||||
switch (message.Type)
|
||||
{
|
||||
case QueueMessageType.Email:
|
||||
await ProcessEmailMessageAsync(message, scope);
|
||||
break;
|
||||
|
||||
case QueueMessageType.PushNotification:
|
||||
await ProcessPushNotificationMessageAsync(message, scope, cancellationToken);
|
||||
break;
|
||||
|
||||
default:
|
||||
logger.LogWarning("Unknown message type: {MessageType}", message.Type);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error processing message of type {MessageType}", message.Type);
|
||||
// Don't rethrow to prevent the message from being retried indefinitely
|
||||
// In a production scenario, you might want to implement a dead-letter queue
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ProcessEmailMessageAsync(QueueMessage message, IServiceScope scope)
|
||||
{
|
||||
var emailService = scope.ServiceProvider.GetRequiredService<EmailService>();
|
||||
var emailMessage = JsonSerializer.Deserialize<EmailMessage>(message.Data)
|
||||
?? throw new InvalidOperationException("Invalid email message format");
|
||||
|
||||
await emailService.SendEmailAsync(
|
||||
emailMessage.ToName,
|
||||
emailMessage.ToAddress,
|
||||
emailMessage.Subject,
|
||||
emailMessage.Body);
|
||||
}
|
||||
|
||||
private static async Task ProcessPushNotificationMessageAsync(QueueMessage message, IServiceScope scope,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var pushService = scope.ServiceProvider.GetRequiredService<PushService>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILogger<QueueBackgroundService>>();
|
||||
|
||||
var notification = JsonSerializer.Deserialize<Notification.Notification>(message.Data);
|
||||
if (notification == null)
|
||||
{
|
||||
logger.LogError("Invalid push notification data format");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
logger.LogDebug("Processing push notification for account {AccountId}", notification.AccountId);
|
||||
await pushService.DeliverPushNotification(notification, cancellationToken);
|
||||
logger.LogDebug("Successfully processed push notification for account {AccountId}", notification.AccountId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error processing push notification for account {AccountId}", notification.AccountId);
|
||||
// Don't rethrow to prevent the message from being retried indefinitely
|
||||
}
|
||||
}
|
||||
}
|
61
DysonNetwork.Pusher/Services/QueueService.cs
Normal file
61
DysonNetwork.Pusher/Services/QueueService.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using NATS.Client.Core;
|
||||
|
||||
namespace DysonNetwork.Pusher.Services;
|
||||
|
||||
public class QueueService(INatsConnection nats)
|
||||
{
|
||||
public async Task EnqueueEmail(string toName, string toAddress, string subject, string body)
|
||||
{
|
||||
var message = new QueueMessage
|
||||
{
|
||||
Type = QueueMessageType.Email,
|
||||
Data = JsonSerializer.Serialize(new EmailMessage
|
||||
{
|
||||
ToName = toName,
|
||||
ToAddress = toAddress,
|
||||
Subject = subject,
|
||||
Body = body
|
||||
})
|
||||
};
|
||||
var rawMessage = GrpcTypeHelper.ConvertObjectToByteString(message).ToByteArray();
|
||||
await nats.PublishAsync(QueueBackgroundService.QueueName, rawMessage);
|
||||
}
|
||||
|
||||
public async Task EnqueuePushNotification(Notification.Notification notification, Guid userId, bool isSavable = false)
|
||||
{
|
||||
// Update the account ID in case it wasn't set
|
||||
notification.AccountId = userId;
|
||||
|
||||
var message = new QueueMessage
|
||||
{
|
||||
Type = QueueMessageType.PushNotification,
|
||||
TargetId = userId.ToString(),
|
||||
Data = JsonSerializer.Serialize(notification)
|
||||
};
|
||||
var rawMessage = GrpcTypeHelper.ConvertObjectToByteString(message).ToByteArray();
|
||||
await nats.PublishAsync(QueueBackgroundService.QueueName, rawMessage);
|
||||
}
|
||||
}
|
||||
|
||||
public class QueueMessage
|
||||
{
|
||||
public QueueMessageType Type { get; set; }
|
||||
public string? TargetId { get; set; }
|
||||
public string Data { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public enum QueueMessageType
|
||||
{
|
||||
Email,
|
||||
PushNotification
|
||||
}
|
||||
|
||||
public class EmailMessage
|
||||
{
|
||||
public string ToName { get; set; } = string.Empty;
|
||||
public string ToAddress { get; set; } = string.Empty;
|
||||
public string Subject { get; set; } = string.Empty;
|
||||
public string Body { get; set; } = string.Empty;
|
||||
}
|
@@ -134,10 +134,16 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
public static IServiceCollection AddAppBusinessServices(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<WebSocketService>();
|
||||
services.AddSingleton<WebSocketService>();
|
||||
services.AddScoped<EmailService>();
|
||||
services.AddScoped<PushService>();
|
||||
|
||||
// Register QueueService as a singleton since it's thread-safe
|
||||
services.AddSingleton<QueueService>();
|
||||
|
||||
// Register the background service
|
||||
services.AddHostedService<QueueBackgroundService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
@@ -11,7 +11,8 @@
|
||||
"ConnectionStrings": {
|
||||
"App": "Host=localhost;Port=5432;Database=dyson_pusher;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60",
|
||||
"FastRetrieve": "localhost:6379",
|
||||
"Etcd": "etcd.orb.local:2379"
|
||||
"Etcd": "etcd.orb.local:2379",
|
||||
"Stream": "nats.orb.local:4222"
|
||||
},
|
||||
"Notifications": {
|
||||
"Push": {
|
||||
|
@@ -33,7 +33,10 @@ public class DysonTokenAuthHandler(
|
||||
AuthSession session;
|
||||
try
|
||||
{
|
||||
session = await ValidateToken(tokenInfo.Token);
|
||||
session = await ValidateToken(
|
||||
tokenInfo.Token,
|
||||
Request.HttpContext.Connection.RemoteIpAddress?.ToString()
|
||||
);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
@@ -58,7 +61,7 @@ public class DysonTokenAuthHandler(
|
||||
};
|
||||
|
||||
// Add scopes as claims
|
||||
session.Challenge.Scopes.ToList().ForEach(scope => claims.Add(new Claim("scope", scope)));
|
||||
session.Challenge?.Scopes.ToList().ForEach(scope => claims.Add(new Claim("scope", scope)));
|
||||
|
||||
// Add superuser claim if applicable
|
||||
if (session.Account.IsSuperuser)
|
||||
@@ -78,12 +81,15 @@ public class DysonTokenAuthHandler(
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<AuthSession> ValidateToken(string token)
|
||||
private async Task<AuthSession> ValidateToken(string token, string? ipAddress)
|
||||
{
|
||||
var resp = await auth.AuthenticateAsync(new AuthenticateRequest { Token = token });
|
||||
var resp = await auth.AuthenticateAsync(new AuthenticateRequest
|
||||
{
|
||||
Token = token,
|
||||
IpAddress = ipAddress
|
||||
});
|
||||
if (!resp.Valid) throw new InvalidOperationException(resp.Message);
|
||||
if (resp.Session == null) throw new InvalidOperationException("Session not found.");
|
||||
return resp.Session;
|
||||
return resp.Session ?? throw new InvalidOperationException("Session not found.");
|
||||
}
|
||||
|
||||
private static byte[] Base64UrlDecode(string base64Url)
|
||||
|
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 PostDelete = "posts.delete";
|
||||
public const string PostReact = "posts.react";
|
||||
public const string PostPin = "posts.pin";
|
||||
public const string PostUnpin = "posts.unpin";
|
||||
public const string MessageCreate = "messages.create";
|
||||
public const string MessageUpdate = "messages.update";
|
||||
public const string MessageDelete = "messages.delete";
|
||||
|
64
DysonNetwork.Shared/Data/Subscription.cs
Normal file
64
DysonNetwork.Shared/Data/Subscription.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using NodaTime;
|
||||
using NodaTime.Serialization.Protobuf;
|
||||
|
||||
namespace DysonNetwork.Shared.Data;
|
||||
|
||||
public class SubscriptionReference
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Identifier { get; set; } = string.Empty;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public bool IsActive { get; set; }
|
||||
public bool IsAvailable { get; set; }
|
||||
public Instant BegunAt { get; set; }
|
||||
public Instant? EndedAt { get; set; }
|
||||
public Instant? RenewalAt { get; set; }
|
||||
public SubscriptionReferenceStatus Status { get; set; }
|
||||
|
||||
public static SubscriptionReference FromProtoValue(Proto.SubscriptionReferenceObject proto)
|
||||
{
|
||||
return new SubscriptionReference
|
||||
{
|
||||
Id = Guid.Parse(proto.Id),
|
||||
Identifier = proto.Identifier,
|
||||
DisplayName = proto.DisplayName,
|
||||
IsActive = proto.IsActive,
|
||||
IsAvailable = proto.IsAvailable,
|
||||
BegunAt = proto.BegunAt.ToInstant(),
|
||||
EndedAt = proto.EndedAt?.ToInstant(),
|
||||
RenewalAt = proto.RenewalAt?.ToInstant(),
|
||||
Status = (SubscriptionReferenceStatus)proto.Status
|
||||
};
|
||||
}
|
||||
|
||||
public Proto.SubscriptionReferenceObject ToProtoValue()
|
||||
{
|
||||
return new Proto.SubscriptionReferenceObject
|
||||
{
|
||||
Id = Id.ToString(),
|
||||
Identifier = Identifier,
|
||||
DisplayName = DisplayName,
|
||||
IsActive = IsActive,
|
||||
IsAvailable = IsAvailable,
|
||||
BegunAt = BegunAt.ToTimestamp(),
|
||||
EndedAt = EndedAt?.ToTimestamp(),
|
||||
RenewalAt = RenewalAt?.ToTimestamp(),
|
||||
Status = Status switch
|
||||
{
|
||||
SubscriptionReferenceStatus.Unpaid => Proto.SubscriptionStatus.Unpaid,
|
||||
SubscriptionReferenceStatus.Active => Proto.SubscriptionStatus.Active,
|
||||
SubscriptionReferenceStatus.Expired => Proto.SubscriptionStatus.Expired,
|
||||
SubscriptionReferenceStatus.Cancelled => Proto.SubscriptionStatus.Cancelled,
|
||||
_ => Proto.SubscriptionStatus.Unpaid
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public enum SubscriptionReferenceStatus
|
||||
{
|
||||
Unpaid = 0,
|
||||
Active = 1,
|
||||
Expired = 2,
|
||||
Cancelled = 3
|
||||
}
|
@@ -21,6 +21,7 @@
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication" Version="2.3.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.7" />
|
||||
<PackageReference Include="NATS.Client.Core" Version="2.6.6" />
|
||||
<PackageReference Include="NetTopologySuite" Version="2.6.0" />
|
||||
<PackageReference Include="NodaTime" Version="3.2.2" />
|
||||
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0" />
|
||||
|
@@ -20,8 +20,6 @@ public static class KestrelConfiguration
|
||||
builder.WebHost.ConfigureKestrel(options =>
|
||||
{
|
||||
options.Limits.MaxRequestBodySize = maxRequestBodySize;
|
||||
options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(2);
|
||||
options.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
var configuredUrl = Environment.GetEnvironmentVariable("ASPNETCORE_URLS");
|
||||
if (!string.IsNullOrEmpty(configuredUrl)) return;
|
||||
|
@@ -21,7 +21,6 @@ public static class GrpcClientHelper
|
||||
? X509Certificate2.CreateFromPemFile(clientCertPath, clientKeyPath)
|
||||
: X509Certificate2.CreateFromEncryptedPemFile(clientCertPath, clientCertPassword, clientKeyPath)
|
||||
);
|
||||
// TODO: Verify the ca in the future
|
||||
handler.ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => true;
|
||||
var httpClient = new HttpClient(handler);
|
||||
httpClient.DefaultRequestVersion = HttpVersion.Version20;
|
||||
@@ -32,12 +31,9 @@ public static class GrpcClientHelper
|
||||
private static async Task<string> GetServiceUrlFromEtcd(IEtcdClient etcdClient, string serviceName)
|
||||
{
|
||||
var response = await etcdClient.GetAsync($"/services/{serviceName}");
|
||||
if (response.Kvs.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Service '{serviceName}' not found in Etcd.");
|
||||
}
|
||||
|
||||
return response.Kvs[0].Value.ToStringUtf8();
|
||||
return response.Kvs.Count == 0
|
||||
? throw new InvalidOperationException($"Service '{serviceName}' not found in Etcd.")
|
||||
: response.Kvs[0].Value.ToStringUtf8();
|
||||
}
|
||||
|
||||
public static async Task<AccountService.AccountServiceClient> CreateAccountServiceClient(
|
||||
@@ -52,6 +48,20 @@ public static class GrpcClientHelper
|
||||
clientCertPassword));
|
||||
}
|
||||
|
||||
public static async Task<BotAccountReceiverService.BotAccountReceiverServiceClient>
|
||||
CreateBotAccountReceiverServiceClient(
|
||||
IEtcdClient etcdClient,
|
||||
string clientCertPath,
|
||||
string clientKeyPath,
|
||||
string? clientCertPassword = null
|
||||
)
|
||||
{
|
||||
var url = await GetServiceUrlFromEtcd(etcdClient, "DysonNetwork.Pass");
|
||||
return new BotAccountReceiverService.BotAccountReceiverServiceClient(CreateCallInvoker(url, clientCertPath,
|
||||
clientKeyPath,
|
||||
clientCertPassword));
|
||||
}
|
||||
|
||||
public static async Task<ActionLogService.ActionLogServiceClient> CreateActionLogServiceClient(
|
||||
IEtcdClient etcdClient,
|
||||
string clientCertPath,
|
||||
@@ -147,4 +157,4 @@ public static class GrpcClientHelper
|
||||
return new CustomAppService.CustomAppServiceClient(CreateCallInvoker(url, clientCertPath, clientKeyPath,
|
||||
clientCertPassword));
|
||||
}
|
||||
}
|
||||
}
|
@@ -32,6 +32,8 @@ message Account {
|
||||
|
||||
google.protobuf.Timestamp created_at = 14;
|
||||
google.protobuf.Timestamp updated_at = 15;
|
||||
|
||||
google.protobuf.StringValue automated_id = 17;
|
||||
}
|
||||
|
||||
// Enum for status attitude
|
||||
@@ -75,6 +77,8 @@ message AccountProfile {
|
||||
int32 experience = 14;
|
||||
int32 level = 15;
|
||||
double leveling_progress = 16;
|
||||
double social_credits = 17;
|
||||
int32 social_credits_level = 18;
|
||||
|
||||
CloudFile picture = 19;
|
||||
CloudFile background = 20;
|
||||
@@ -244,7 +248,9 @@ message GetAccountStatusBatchResponse {
|
||||
service AccountService {
|
||||
// Account Operations
|
||||
rpc GetAccount(GetAccountRequest) returns (Account) {}
|
||||
rpc GetBotAccount(GetBotAccountRequest) returns (Account) {}
|
||||
rpc GetAccountBatch(GetAccountBatchRequest) returns (GetAccountBatchResponse) {}
|
||||
rpc GetBotAccountBatch(GetBotAccountBatchRequest) returns (GetAccountBatchResponse) {}
|
||||
rpc LookupAccountBatch(LookupAccountBatchRequest) returns (GetAccountBatchResponse) {}
|
||||
rpc ListAccounts(ListAccountsRequest) returns (ListAccountsResponse) {}
|
||||
|
||||
@@ -319,10 +325,18 @@ message GetAccountRequest {
|
||||
string id = 1; // Account ID to retrieve
|
||||
}
|
||||
|
||||
message GetBotAccountRequest {
|
||||
string automated_id = 1;
|
||||
}
|
||||
|
||||
message GetAccountBatchRequest {
|
||||
repeated string id = 1; // Account ID to retrieve
|
||||
}
|
||||
|
||||
message GetBotAccountBatchRequest {
|
||||
repeated string automated_id = 1;
|
||||
}
|
||||
|
||||
message LookupAccountBatchRequest {
|
||||
repeated string names = 1;
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user