Compare commits

...

138 Commits

Author SHA1 Message Date
LittleSheep
fb6721cb1b 💄 Optimize punishment reason display 2025-08-26 20:32:07 +08:00
LittleSheep
9fcb169c94 🐛 Fix chat room invites 2025-08-26 19:08:23 +08:00
LittleSheep
572874431d 🐛 Fix sticker perm check 2025-08-26 14:48:30 +08:00
LittleSheep
f595ac8001 🐛 Fix uploading file didn't uploaded 2025-08-26 13:02:51 +08:00
LittleSheep
18674e0e1d Remove /cgi directly handled by gateway 2025-08-26 02:59:51 +08:00
LittleSheep
da4c4d3a84 🐛 Fix bugs 2025-08-26 02:48:16 +08:00
LittleSheep
aec01b117d 🐛 Fix chat service duplicate notifying 2025-08-26 00:15:39 +08:00
LittleSheep
d299c32e35 ♻️ Clean up OIDC provider 2025-08-25 23:53:04 +08:00
LittleSheep
344007af66 🔊 Logging more ip address 2025-08-25 23:42:41 +08:00
LittleSheep
d4de5aeac2 🐛 Fix api key exists cause regular login 500 2025-08-25 23:30:41 +08:00
LittleSheep
8ce5ba50f4 🐛 Fix api key cause 401 in other serivces 2025-08-25 23:20:27 +08:00
LittleSheep
5a44952b27 🐛 Fix oidc token aud 2025-08-25 23:17:40 +08:00
LittleSheep
c30946daf6 🐛 Still bug fixes in auth service 2025-08-25 23:01:17 +08:00
LittleSheep
0221d7b294 🐛 Fix compress GIF wrongly 2025-08-25 22:42:14 +08:00
LittleSheep
c44b0b64c3 🐛 Fix api key auth issue 2025-08-25 22:39:35 +08:00
LittleSheep
442ee3bcfd 🐛 Fixes in auth service 2025-08-25 22:24:18 +08:00
LittleSheep
081815c512 Trying to optimize pusher serivce 2025-08-25 21:48:07 +08:00
LittleSheep
eab2a388ae 🐛 Fixes in authorize 2025-08-25 21:22:04 +08:00
LittleSheep
5f7ab49abb 🛂 Add permission check in post pin / unpin 2025-08-25 20:04:21 +08:00
LittleSheep
4ff89173b2 ♻️ Some optimzations for sync message endpoint 2025-08-25 19:24:42 +08:00
LittleSheep
f2052410c7 Filtered realm posts 2025-08-25 17:47:30 +08:00
LittleSheep
83a49be725 🐛 Fix websocket missing in notification 2025-08-25 17:43:37 +08:00
LittleSheep
9b205a73fd 💄 Optimize post controller 2025-08-25 17:06:21 +08:00
LittleSheep
d5157eb7e3 Post category tags subscriptions 2025-08-25 14:18:14 +08:00
LittleSheep
75c92c51db 🐛 Dozens of bug fixes 2025-08-25 13:43:40 +08:00
LittleSheep
915054fce0 Pinned post 2025-08-25 13:37:25 +08:00
LittleSheep
63653680ba 👔 Update the algorithm to pick featured post 2025-08-25 13:06:09 +08:00
LittleSheep
84c4df6620 👔 Prevent from creating duplicate featured record 2025-08-25 13:05:34 +08:00
LittleSheep
8c748fd57a Bring OIDC back 2025-08-25 02:44:44 +08:00
LittleSheep
4684550ebf App custom secret management 2025-08-24 23:50:57 +08:00
LittleSheep
51db08f374 🐛 Fix develop API permission check 2025-08-24 21:53:41 +08:00
LittleSheep
9f38a288b9 🐛 Fix save notification again.. 2025-08-24 18:05:42 +08:00
LittleSheep
75a975049c 🐛 Fix get subscribed feed 2025-08-24 17:37:30 +08:00
LittleSheep
f8c35c0350 🐛 Fix queue background service in pusher didn't save notification now 2025-08-24 16:59:27 +08:00
LittleSheep
d9a5fed77f 🐛 Fix wrong queue name 2025-08-24 13:19:39 +08:00
LittleSheep
7cb14940d9 🐛 Fix rotate key 2025-08-24 01:49:48 +08:00
LittleSheep
953bf5d4de Bot controller has keys endpoints 2025-08-23 19:52:05 +08:00
LittleSheep
d9620fd6a4 Bot transparency API 2025-08-23 17:55:42 +08:00
LittleSheep
541e2dd14c 🐛 Fix bots errors 2025-08-23 17:06:52 +08:00
LittleSheep
c7925d98c8 🐛 Fix bot account missing created / updated at 2025-08-23 14:25:46 +08:00
LittleSheep
f759b19bcb 🐛 Fixes in bot 2025-08-23 14:20:21 +08:00
LittleSheep
5d7429a416 ♻️ Refind bot account 2025-08-23 13:00:30 +08:00
LittleSheep
fb7e52d6f3 Sticker pack includes preview stickers 2025-08-22 23:02:16 +08:00
LittleSheep
50e888b075 🐛 Fix mark all read will reset the viewed at 2025-08-22 22:42:32 +08:00
LittleSheep
76c8bbf307 🐛 Fix social credit cache didn't have base value 2025-08-22 22:41:38 +08:00
LittleSheep
8f3825e92c Cache user social credits on profile 2025-08-22 22:28:48 +08:00
LittleSheep
d1c3610ec8 🐛 Dozens of bug fixes 2025-08-22 19:55:16 +08:00
LittleSheep
4b958a3c31 🗑️ Remove the old search API 2025-08-22 17:07:22 +08:00
LittleSheep
1f9021d459 🎨 Disassmeble the activity service parts 2025-08-22 16:56:21 +08:00
LittleSheep
7ad9deaf70 🎨 Adjust post shuffle query 2025-08-22 16:50:06 +08:00
LittleSheep
c1c17b5f4e Optimize post categories, tags usage counting 2025-08-21 23:22:59 +08:00
LittleSheep
d92220b4bc ♻️ Refactor NATS message handling 2025-08-21 18:47:23 +08:00
LittleSheep
4d1972bc99 ♻️ Refactored the queue 2025-08-21 17:41:48 +08:00
LittleSheep
83c052ec4e ♻️ Replace check in with recorded experience source 2025-08-21 02:30:59 +08:00
LittleSheep
57a75fe9e6 Done with social credits 2025-08-21 02:28:39 +08:00
LittleSheep
379bc37aff Social credit, leveling service 2025-08-21 01:30:39 +08:00
LittleSheep
0217fbb13b Sorting post categories, tags with order 2025-08-20 19:06:18 +08:00
LittleSheep
4e9943e6a2 🍱 Update database migrations 2025-08-20 18:50:23 +08:00
LittleSheep
b3cc623168 Web feed subscription APIs 2025-08-20 18:41:11 +08:00
LittleSheep
3ee5e5367d Web feed subcription 2025-08-20 14:21:25 +08:00
LittleSheep
85fef30c7f Search with sticker packs 2025-08-20 14:02:34 +08:00
LittleSheep
e8d8dcbb2d 💄 Better sticker marketplace listing 2025-08-20 14:00:15 +08:00
LittleSheep
3b679d6134 API Keys 2025-08-20 13:41:06 +08:00
LittleSheep
ec44b51ab6 Reply and forward gone indicator 2025-08-20 02:14:18 +08:00
LittleSheep
2e52a13c30 🍱 Update migrations 2025-08-20 01:41:37 +08:00
LittleSheep
1e8e2e9ea7 🐛 Fixes DI and lifetimes 2025-08-20 01:41:27 +08:00
LittleSheep
9e8363c004 Drive resource recycler, delete files in batch 2025-08-20 00:11:52 +08:00
LittleSheep
56c40ee001 File references deletion batch 2025-08-19 22:47:20 +08:00
LittleSheep
e3dfccfee3 Account service account deleted broadcast message & sphere service clean up 2025-08-19 22:39:12 +08:00
LittleSheep
d555fcaf17 🐛 Fix org publisher creation missing validation as well 2025-08-19 21:34:27 +08:00
LittleSheep
2fdefae718 🐛 Fix publiser has no validate 2025-08-19 21:24:30 +08:00
LittleSheep
e78858b7b4 Speed up the gateway loopback /cgi route by letting gateway directly handle it 2025-08-19 19:27:18 +08:00
LittleSheep
636b674229 🧱 Add stream (NATS) message queue infra 2025-08-19 19:23:41 +08:00
LittleSheep
fc6cee17d7 Add notification to friend request 2025-08-19 19:06:08 +08:00
LittleSheep
7f7b47fb1c Invoke bot reciever service in Bot 2025-08-19 15:48:19 +08:00
LittleSheep
bf181b88ec Account bot basis 2025-08-19 15:16:35 +08:00
LittleSheep
c056938b6e 👔 Update link preview match regex 2025-08-18 21:17:00 +08:00
LittleSheep
66eadf96b0 🐛 Fix randomly account got logged out 2025-08-18 20:56:25 +08:00
LittleSheep
665595b8b4 Developer projects 2025-08-18 20:49:09 +08:00
LittleSheep
29550401fd Add forwarded header across all gateway routes 2025-08-18 20:14:22 +08:00
LittleSheep
1bb0012c40 🐛 Fix logout 2025-08-18 17:57:14 +08:00
LittleSheep
2cea391ebf 🐛 Fix logout session 2025-08-18 17:52:40 +08:00
LittleSheep
32e91da0b2 🐛 Fix circular dependecy 2025-08-18 16:34:07 +08:00
LittleSheep
69b56b9658 🔊 Logging auth flow 2025-08-18 16:19:21 +08:00
LittleSheep
83e3d77f79 🐛 Add forwarded headers to Gateway 2025-08-18 13:20:31 +08:00
LittleSheep
38a8eecd50 🐛 Fix listing members with missing accounts 2025-08-18 11:38:45 +08:00
LittleSheep
bd77137714 🐛 Fixes of withStatus 2025-08-18 01:39:33 +08:00
LittleSheep
201126e5d0 🧱 Add new ApiError system 2025-08-18 01:10:49 +08:00
LittleSheep
d4a2e5ef5b ♻️ Refactored auth controller 2025-08-18 00:14:18 +08:00
LittleSheep
2761abf405 Login now send a notification 2025-08-17 23:43:13 +08:00
LittleSheep
add16ffdad 👔 Post listing API now include the Realm 2025-08-17 23:33:25 +08:00
LittleSheep
b49cd1c382 Realm and chat with status listing member API 2025-08-17 23:32:58 +08:00
LittleSheep
aa9ae5c11e Account status GRPC API 2025-08-17 22:30:17 +08:00
LittleSheep
8e8965eb3d 👔 Send factor code no longer requires hint 2025-08-17 21:20:42 +08:00
LittleSheep
a0fe8fd0f0 👔 Remove replies in activities 2025-08-17 02:49:32 +08:00
LittleSheep
855031a4fe 💄 Optimize get activities 2025-08-17 02:49:16 +08:00
LittleSheep
adc2b20aeb 🐛 Fix activity post listing do not contains realm info 2025-08-17 02:41:44 +08:00
c860f10cf9 🔀 Merge pull request '更新 DysonNetwork.Pass/Resources/Localization/AccountEventResource.resx' (#6) from a123lsw-patch-2 into master
Reviewed-on: Solar/Swarm#6
2025-08-16 17:53:31 +00:00
d441eff2d2 Merge branch 'master' into a123lsw-patch-2 2025-08-16 17:53:25 +00:00
d31f36d3dc 🔀 Merge pull request '更新 DysonNetwork.Pass/Resources/Localization/AccountEventResource.zh-hans.resx' (#5) from a123lsw-patch-1 into master
Reviewed-on: Solar/Swarm#5
2025-08-16 17:53:21 +00:00
4fc7bd47f9 Merge branch 'master' into a123lsw-patch-1 2025-08-16 17:52:41 +00:00
LittleSheep
a66037d947 Optimize push service 2025-08-17 00:27:51 +08:00
LittleSheep
bb4e04df0b 🔊 Add websocket logger back 2025-08-17 00:19:16 +08:00
LittleSheep
d3752caf1d 🐛 Fixes and optimize deliver message 2025-08-16 23:38:47 +08:00
LittleSheep
614c77d7ce 🐛 Fix compile failed 2025-08-16 14:35:06 +08:00
LittleSheep
5d13f08d47 Post include realm data 2025-08-16 14:31:06 +08:00
LittleSheep
07ba148d9b 🐛 Fix challege pickup 2025-08-16 14:30:58 +08:00
LittleSheep
917e2d5393 🐛 Fix post get API missing the reference post 2025-08-16 11:59:29 +08:00
LittleSheep
e384763faf 🚨 Fix complier warnings 2025-08-16 01:12:38 +08:00
LittleSheep
7fb199b187 🐛 Make send notification await 2025-08-16 00:03:41 +08:00
LittleSheep
924e31aad5 🐛 Trying to fix chat invite 2025-08-15 16:46:08 +08:00
LittleSheep
48f776e6ff Post slug 🐛 Fix duplicate device id 2025-08-15 12:19:36 +08:00
LittleSheep
a27bda4720 🐛 Fix web didn't has device name 2025-08-15 12:10:59 +08:00
LittleSheep
a7e0e1e369 💄 Update path param 2025-08-15 03:26:15 +08:00
LittleSheep
5bb5018cc0 🐛 Fix logout device 2025-08-15 03:06:33 +08:00
LittleSheep
a9aab6b7e5 🐛 Add missing logout device 2025-08-15 03:00:13 +08:00
LittleSheep
651c06caac 🐛 Fix query without vector failed in post 2025-08-15 02:52:58 +08:00
LittleSheep
e0d58085f3 Filter with the realm 2025-08-15 02:44:00 +08:00
LittleSheep
cb420c2262 Realm post 2025-08-15 02:42:35 +08:00
LittleSheep
6211f546b1 Post list shuffle mode 2025-08-15 02:23:14 +08:00
LittleSheep
9070fe7fa3 Post controller has media filter 2025-08-15 02:22:11 +08:00
LittleSheep
c86d7275ec New features to post listing API ♻️ Merge search and
listing API
2025-08-15 02:14:04 +08:00
9e1178b7a1 更新 DysonNetwork.Pass/Resources/Localization/AccountEventResource.resx 2025-08-14 16:04:28 +00:00
LittleSheep
cd76cedb7b Optimize the post notification 2025-08-14 23:20:30 +08:00
LittleSheep
f273445451 🗑️ Remove the client id migration code 2025-08-14 21:05:56 +08:00
LittleSheep
740d9a33cf 🐛 Fix pusher service 2025-08-14 20:46:19 +08:00
LittleSheep
792d703b6f 🐛 Disable data part to fcm to trying fix INVALID_ARGUMENT 2025-08-14 18:06:13 +08:00
LittleSheep
f09832404d 🐛 Fix compile issue in Pusher 2025-08-14 17:43:41 +08:00
LittleSheep
134b11e7f0 🐛 Fix notification missing websocket 2025-08-14 17:39:20 +08:00
LittleSheep
8c01ec364c 🔊 Add more logging to push notification 2025-08-14 17:25:06 +08:00
LittleSheep
27e6dde7c4 Mark all notifications read 2025-08-14 15:33:48 +08:00
LittleSheep
b04b17c8ae Optimize push notification saving by introducing the flush buffer 2025-08-14 15:31:33 +08:00
LittleSheep
b037ecad79 🔇 Lower some log level in pusher service 2025-08-14 15:10:41 +08:00
LittleSheep
7ec3f25d43 🐛 Fix action logs 2025-08-14 02:29:16 +08:00
LittleSheep
1778ab112d Authorized device 2025-08-14 02:21:59 +08:00
LittleSheep
5f70d53c94 New authorized device 2025-08-14 02:10:32 +08:00
LittleSheep
4b66e97bda 🐛 Bug fixes with ws controller 2025-08-13 17:32:13 +08:00
LittleSheep
f8d8e485f1 ♻️ Refactored the authorized device (now client) 2025-08-13 15:27:31 +08:00
172 changed files with 38459 additions and 1314 deletions

View File

@@ -1,4 +1,6 @@
using System.Text.Json;
using DysonNetwork.Develop.Identity; using DysonNetwork.Develop.Identity;
using DysonNetwork.Develop.Project;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design; using Microsoft.EntityFrameworkCore.Design;
@@ -9,10 +11,13 @@ public class AppDatabase(
IConfiguration configuration IConfiguration configuration
) : DbContext(options) ) : DbContext(options)
{ {
public DbSet<Developer> Developers { get; set; } public DbSet<Developer> Developers { get; set; } = null!;
public DbSet<CustomApp> CustomApps { get; set; } public DbSet<DevProject> DevProjects { get; set; } = null!;
public DbSet<CustomAppSecret> CustomAppSecrets { get; set; }
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) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {

View 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;
}
}

View 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);
}
}

View File

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

View File

@@ -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> ?? [];
}
}

View File

@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using DysonNetwork.Develop.Project;
using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using Google.Protobuf; using Google.Protobuf;
@@ -8,9 +9,6 @@ using Google.Protobuf.WellKnownTypes;
using NodaTime.Serialization.Protobuf; using NodaTime.Serialization.Protobuf;
using NodaTime; using NodaTime;
using DysonNetwork.Shared.Proto;
using Google.Protobuf.WellKnownTypes;
using NodaTime.Serialization.Protobuf;
using VerificationMark = DysonNetwork.Shared.Data.VerificationMark; using VerificationMark = DysonNetwork.Shared.Data.VerificationMark;
namespace DysonNetwork.Develop.Identity; namespace DysonNetwork.Develop.Identity;
@@ -34,14 +32,17 @@ public class CustomApp : ModelBase, IIdentifiedResource
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; } [Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; }
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; } [Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; }
[Column(TypeName = "jsonb")] public DysonNetwork.Shared.Data.VerificationMark? Verification { get; set; } [Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; }
[Column(TypeName = "jsonb")] public CustomAppOauthConfig? OauthConfig { get; set; } [Column(TypeName = "jsonb")] public CustomAppOauthConfig? OauthConfig { get; set; }
[Column(TypeName = "jsonb")] public CustomAppLinks? Links { get; set; } [Column(TypeName = "jsonb")] public CustomAppLinks? Links { get; set; }
[JsonIgnore] public ICollection<CustomAppSecret> Secrets { get; set; } = new List<CustomAppSecret>(); [JsonIgnore] public ICollection<CustomAppSecret> Secrets { get; set; } = new List<CustomAppSecret>();
public Guid DeveloperId { get; set; } public Guid ProjectId { get; set; }
public Developer Developer { get; set; } = null!; public DevProject Project { get; set; } = null!;
[NotMapped]
public Developer Developer => Project.Developer;
[NotMapped] public string ResourceIdentifier => "custom-app:" + Id; [NotMapped] public string ResourceIdentifier => "custom-app:" + Id;
@@ -61,21 +62,26 @@ public class CustomApp : ModelBase, IIdentifiedResource
CustomAppStatus.Suspended => Shared.Proto.CustomAppStatus.Suspended, CustomAppStatus.Suspended => Shared.Proto.CustomAppStatus.Suspended,
_ => Shared.Proto.CustomAppStatus.Unspecified _ => Shared.Proto.CustomAppStatus.Unspecified
}, },
Picture = Picture is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Picture)), Picture = Picture?.ToProtoValue(),
Background = Background is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Background)), Background = Background?.ToProtoValue(),
Verification = Verification is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Verification)), Verification = Verification?.ToProtoValue(),
Links = Links is null ? ByteString.Empty : ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(Links)), Links = Links is null ? null : new DysonNetwork.Shared.Proto.CustomAppLinks
{
HomePage = Links.HomePage ?? string.Empty,
PrivacyPolicy = Links.PrivacyPolicy ?? string.Empty,
TermsOfService = Links.TermsOfService ?? string.Empty
},
OauthConfig = OauthConfig is null ? null : new DysonNetwork.Shared.Proto.CustomAppOauthConfig OauthConfig = OauthConfig is null ? null : new DysonNetwork.Shared.Proto.CustomAppOauthConfig
{ {
ClientUri = OauthConfig.ClientUri ?? string.Empty, ClientUri = OauthConfig.ClientUri ?? string.Empty,
RedirectUris = { OauthConfig.RedirectUris ?? Array.Empty<string>() }, RedirectUris = { OauthConfig.RedirectUris ?? [] },
PostLogoutRedirectUris = { OauthConfig.PostLogoutRedirectUris ?? Array.Empty<string>() }, PostLogoutRedirectUris = { OauthConfig.PostLogoutRedirectUris ?? [] },
AllowedScopes = { OauthConfig.AllowedScopes ?? Array.Empty<string>() }, AllowedScopes = { OauthConfig.AllowedScopes ?? [] },
AllowedGrantTypes = { OauthConfig.AllowedGrantTypes ?? Array.Empty<string>() }, AllowedGrantTypes = { OauthConfig.AllowedGrantTypes ?? [] },
RequirePkce = OauthConfig.RequirePkce, RequirePkce = OauthConfig.RequirePkce,
AllowOfflineAccess = OauthConfig.AllowOfflineAccess AllowOfflineAccess = OauthConfig.AllowOfflineAccess
}, },
DeveloperId = DeveloperId.ToString(), ProjectId = ProjectId.ToString(),
CreatedAt = CreatedAt.ToTimestamp(), CreatedAt = CreatedAt.ToTimestamp(),
UpdatedAt = UpdatedAt.ToTimestamp() UpdatedAt = UpdatedAt.ToTimestamp()
}; };
@@ -95,13 +101,21 @@ public class CustomApp : ModelBase, IIdentifiedResource
Shared.Proto.CustomAppStatus.Suspended => CustomAppStatus.Suspended, Shared.Proto.CustomAppStatus.Suspended => CustomAppStatus.Suspended,
_ => CustomAppStatus.Developing _ => 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(); CreatedAt = p.CreatedAt.ToInstant();
UpdatedAt = p.UpdatedAt.ToInstant(); UpdatedAt = p.UpdatedAt.ToInstant();
if (p.Picture.Length > 0) Picture = System.Text.Json.JsonSerializer.Deserialize<CloudFileReferenceObject>(p.Picture.ToStringUtf8()); if (p.Picture is not null) Picture = CloudFileReferenceObject.FromProtoValue(p.Picture);
if (p.Background.Length > 0) Background = System.Text.Json.JsonSerializer.Deserialize<CloudFileReferenceObject>(p.Background.ToStringUtf8()); if (p.Background is not null) Background = CloudFileReferenceObject.FromProtoValue(p.Background);
if (p.Verification.Length > 0) Verification = System.Text.Json.JsonSerializer.Deserialize<DysonNetwork.Shared.Data.VerificationMark>(p.Verification.ToStringUtf8()); if (p.Verification is not null) Verification = VerificationMark.FromProtoValue(p.Verification);
if (p.Links.Length > 0) Links = System.Text.Json.JsonSerializer.Deserialize<CustomAppLinks>(p.Links.ToStringUtf8()); if (p.Links is not null)
{
Links = new CustomAppLinks
{
HomePage = string.IsNullOrEmpty(p.Links.HomePage) ? null : p.Links.HomePage,
PrivacyPolicy = string.IsNullOrEmpty(p.Links.PrivacyPolicy) ? null : p.Links.PrivacyPolicy,
TermsOfService = string.IsNullOrEmpty(p.Links.TermsOfService) ? null : p.Links.TermsOfService
};
}
return this; return this;
} }
} }

View File

@@ -1,13 +1,16 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Develop.Project;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using NodaTime;
namespace DysonNetwork.Develop.Identity; namespace DysonNetwork.Develop.Identity;
[ApiController] [ApiController]
[Route("/api/developers/{pubName}/apps")] [Route("/api/developers/{pubName}/projects/{projectId:guid}/apps")]
public class CustomAppController(CustomAppService customApps, DeveloperService ds) : ControllerBase public class CustomAppController(CustomAppService customApps, DeveloperService ds, DevProjectService projectService)
: ControllerBase
{ {
public record CustomAppRequest( public record CustomAppRequest(
[MaxLength(1024)] string? Slug, [MaxLength(1024)] string? Slug,
@@ -20,22 +23,62 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
CustomAppOauthConfig? OauthConfig CustomAppOauthConfig? OauthConfig
); );
public record CreateSecretRequest(
[MaxLength(4096)] string? Description,
TimeSpan? ExpiresIn = null,
bool IsOidc = false
);
public record SecretResponse(
string Id,
string? Secret,
string? Description,
Instant? ExpiresAt,
bool IsOidc,
Instant CreatedAt,
Instant UpdatedAt
);
[HttpGet] [HttpGet]
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); var developer = await ds.GetDeveloperByName(pubName);
if (developer is null) return NotFound(); 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); return Ok(apps);
} }
[HttpGet("{id:guid}")] [HttpGet("{appId:guid}")]
public async Task<IActionResult> GetApp([FromRoute] string pubName, Guid id) [Authorize]
public async Task<IActionResult> GetApp([FromRoute] string pubName, [FromRoute] Guid projectId,
[FromRoute] Guid appId)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var developer = await ds.GetDeveloperByName(pubName); var developer = await ds.GetDeveloperByName(pubName);
if (developer is null) return NotFound(); if (developer is null) return NotFound();
var 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) if (app == null)
return NotFound(); return NotFound();
@@ -44,23 +87,39 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
[HttpPost] [HttpPost]
[Authorize] [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 (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");
var developer = await ds.GetDeveloperByName(pubName); 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)) 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"); 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 try
{ {
var app = await customApps.CreateAppAsync(developer, request); var app = await customApps.CreateAppAsync(projectId, request);
return Ok(app); if (app == null)
return BadRequest("Failed to create app");
return CreatedAtAction(
nameof(GetApp),
new { pubName, projectId, appId = app.Id },
app
);
} }
catch (InvalidOperationException ex) catch (InvalidOperationException ex)
{ {
@@ -68,23 +127,30 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
} }
} }
[HttpPatch("{id:guid}")] [HttpPatch("{appId:guid}")]
[Authorize] [Authorize]
public async Task<IActionResult> UpdateApp( public async Task<IActionResult> UpdateApp(
[FromRoute] string pubName, [FromRoute] string pubName,
[FromRoute] Guid id, [FromRoute] Guid projectId,
[FromRoute] Guid appId,
[FromBody] CustomAppRequest request [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); 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)) if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to update a custom app"); return StatusCode(403, "You must be an editor of the developer to update a custom app");
var 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) if (app == null)
return NotFound(); return NotFound();
@@ -99,28 +165,267 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
} }
} }
[HttpDelete("{id:guid}")] [HttpDelete("{appId:guid}")]
[Authorize] [Authorize]
public async Task<IActionResult> DeleteApp( public async Task<IActionResult> DeleteApp(
[FromRoute] string pubName, [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); 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)) if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to delete a custom app"); return StatusCode(403, "You must be an editor of the developer to delete a custom app");
var 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) if (app == null)
return NotFound(); return NotFound();
var result = await customApps.DeleteAppAsync(id); var result = await customApps.DeleteAppAsync(appId);
if (!result) if (!result)
return NotFound(); return NotFound();
return NoContent(); return NoContent();
} }
[HttpGet("{appId:guid}/secrets")]
[Authorize]
public async Task<IActionResult> ListSecrets(
[FromRoute] string pubName,
[FromRoute] Guid projectId,
[FromRoute] Guid appId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var developer = await ds.GetDeveloperByName(pubName);
if (developer is null)
return NotFound("Developer not found");
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to view app secrets");
var project = await projectService.GetProjectAsync(projectId, developer.Id);
if (project is null)
return NotFound("Project not found or you don't have access");
var app = await customApps.GetAppAsync(appId, projectId);
if (app == null)
return NotFound("App not found");
var secrets = await customApps.GetAppSecretsAsync(appId);
return Ok(secrets.Select(s => new SecretResponse(
s.Id.ToString(),
null,
s.Description,
s.ExpiredAt,
s.IsOidc,
s.CreatedAt,
s.UpdatedAt
)));
}
[HttpPost("{appId:guid}/secrets")]
[Authorize]
public async Task<IActionResult> CreateSecret(
[FromRoute] string pubName,
[FromRoute] Guid projectId,
[FromRoute] Guid appId,
[FromBody] CreateSecretRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var developer = await ds.GetDeveloperByName(pubName);
if (developer is null)
return NotFound("Developer not found");
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to create app secrets");
var project = await projectService.GetProjectAsync(projectId, developer.Id);
if (project is null)
return NotFound("Project not found or you don't have access");
var app = await customApps.GetAppAsync(appId, projectId);
if (app == null)
return NotFound("App not found");
try
{
var secret = await customApps.CreateAppSecretAsync(new CustomAppSecret
{
AppId = appId,
Description = request.Description,
ExpiredAt = request.ExpiresIn.HasValue
? NodaTime.SystemClock.Instance.GetCurrentInstant()
.Plus(Duration.FromTimeSpan(request.ExpiresIn.Value))
: (NodaTime.Instant?)null,
IsOidc = request.IsOidc
});
return CreatedAtAction(
nameof(GetSecret),
new { pubName, projectId, appId, secretId = secret.Id },
new SecretResponse(
secret.Id.ToString(),
secret.Secret,
secret.Description,
secret.ExpiredAt,
secret.IsOidc,
secret.CreatedAt,
secret.UpdatedAt
)
);
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
[HttpGet("{appId:guid}/secrets/{secretId:guid}")]
[Authorize]
public async Task<IActionResult> GetSecret(
[FromRoute] string pubName,
[FromRoute] Guid projectId,
[FromRoute] Guid appId,
[FromRoute] Guid secretId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var developer = await ds.GetDeveloperByName(pubName);
if (developer is null)
return NotFound("Developer not found");
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to view app secrets");
var project = await projectService.GetProjectAsync(projectId, developer.Id);
if (project is null)
return NotFound("Project not found or you don't have access");
var app = await customApps.GetAppAsync(appId, projectId);
if (app == null)
return NotFound("App not found");
var secret = await customApps.GetAppSecretAsync(secretId, appId);
if (secret == null)
return NotFound("Secret not found");
return Ok(new SecretResponse(
secret.Id.ToString(),
null,
secret.Description,
secret.ExpiredAt,
secret.IsOidc,
secret.CreatedAt,
secret.UpdatedAt
));
}
[HttpDelete("{appId:guid}/secrets/{secretId:guid}")]
[Authorize]
public async Task<IActionResult> DeleteSecret(
[FromRoute] string pubName,
[FromRoute] Guid projectId,
[FromRoute] Guid appId,
[FromRoute] Guid secretId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var developer = await ds.GetDeveloperByName(pubName);
if (developer is null)
return NotFound("Developer not found");
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to delete app secrets");
var project = await projectService.GetProjectAsync(projectId, developer.Id);
if (project is null)
return NotFound("Project not found or you don't have access");
var app = await customApps.GetAppAsync(appId, projectId);
if (app == null)
return NotFound("App not found");
var secret = await customApps.GetAppSecretAsync(secretId, appId);
if (secret == null)
return NotFound("Secret not found");
var result = await customApps.DeleteAppSecretAsync(secretId, appId);
if (!result)
return NotFound("Failed to delete secret");
return NoContent();
}
[HttpPost("{appId:guid}/secrets/{secretId:guid}/rotate")]
[Authorize]
public async Task<IActionResult> RotateSecret(
[FromRoute] string pubName,
[FromRoute] Guid projectId,
[FromRoute] Guid appId,
[FromRoute] Guid secretId,
[FromBody] CreateSecretRequest? request = null)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
return Unauthorized();
var developer = await ds.GetDeveloperByName(pubName);
if (developer is null)
return NotFound("Developer not found");
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor))
return StatusCode(403, "You must be an editor of the developer to rotate app secrets");
var project = await projectService.GetProjectAsync(projectId, developer.Id);
if (project is null)
return NotFound("Project not found or you don't have access");
var app = await customApps.GetAppAsync(appId, projectId);
if (app == null)
return NotFound("App not found");
try
{
var secret = await customApps.RotateAppSecretAsync(new CustomAppSecret
{
Id = secretId,
AppId = appId,
Description = request?.Description,
ExpiredAt = request?.ExpiresIn.HasValue == true
? NodaTime.SystemClock.Instance.GetCurrentInstant()
.Plus(Duration.FromTimeSpan(request.ExpiresIn.Value))
: (NodaTime.Instant?)null,
IsOidc = request?.IsOidc ?? false
});
return Ok(new SecretResponse(
secret.Id.ToString(),
secret.Secret,
secret.Description,
secret.ExpiredAt,
secret.IsOidc,
secret.CreatedAt,
secret.UpdatedAt
));
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
} }

View File

@@ -1,6 +1,9 @@
using DysonNetwork.Develop.Project;
using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
using System.Text;
namespace DysonNetwork.Develop.Identity; namespace DysonNetwork.Develop.Identity;
@@ -11,10 +14,17 @@ public class CustomAppService(
) )
{ {
public async Task<CustomApp?> CreateAppAsync( public async Task<CustomApp?> CreateAppAsync(
Developer pub, Guid projectId,
CustomAppController.CustomAppRequest request 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 var app = new CustomApp
{ {
Slug = request.Slug!, Slug = request.Slug!,
@@ -23,7 +33,7 @@ public class CustomAppService(
Status = request.Status ?? CustomAppStatus.Developing, Status = request.Status ?? CustomAppStatus.Developing,
Links = request.Links, Links = request.Links,
OauthConfig = request.OauthConfig, OauthConfig = request.OauthConfig,
DeveloperId = pub.Id ProjectId = projectId
}; };
if (request.PictureId is not null) if (request.PictureId is not null)
@@ -74,17 +84,104 @@ public class CustomAppService(
return app; 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(); var query = db.CustomApps.AsQueryable();
if (developerId.HasValue)
query = query.Where(a => a.DeveloperId == developerId.Value); if (projectId.HasValue)
return await query.FirstOrDefaultAsync(); {
query = query.Where(a => a.ProjectId == projectId.Value);
} }
public async Task<List<CustomApp>> GetAppsByPublisherAsync(Guid publisherId) return await query.FirstOrDefaultAsync(a => a.Id == id);
}
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) public async Task<CustomApp?> UpdateAppAsync(CustomApp app, CustomAppController.CustomAppRequest request)

View File

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

View File

@@ -33,7 +33,8 @@ public class DeveloperController(
// Get custom apps count // Get custom apps count
var customAppsCount = await db.CustomApps var customAppsCount = await db.CustomApps
.Where(a => a.DeveloperId == developer.Id) .Include(a => a.Project)
.Where(a => a.Project.DeveloperId == developer.Id)
.CountAsync(); .CountAsync();
var stats = new DeveloperStats var stats = new DeveloperStats

View File

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

View 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
}
}
}

View File

@@ -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);
}
}
}

View File

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

View File

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

View File

@@ -25,6 +25,48 @@ namespace DysonNetwork.Develop.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DysonNetwork.Develop.Identity.BotAccount", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<bool>("IsActive")
.HasColumnType("boolean")
.HasColumnName("is_active");
b.Property<Guid>("ProjectId")
.HasColumnType("uuid")
.HasColumnName("project_id");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("slug");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_bot_accounts");
b.HasIndex("ProjectId")
.HasDatabaseName("ix_bot_accounts_project_id");
b.ToTable("bot_accounts", (string)null);
});
modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b => modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -49,10 +91,6 @@ namespace DysonNetwork.Develop.Migrations
.HasColumnType("character varying(4096)") .HasColumnType("character varying(4096)")
.HasColumnName("description"); .HasColumnName("description");
b.Property<Guid>("DeveloperId")
.HasColumnType("uuid")
.HasColumnName("developer_id");
b.Property<CustomAppLinks>("Links") b.Property<CustomAppLinks>("Links")
.HasColumnType("jsonb") .HasColumnType("jsonb")
.HasColumnName("links"); .HasColumnName("links");
@@ -71,6 +109,10 @@ namespace DysonNetwork.Develop.Migrations
.HasColumnType("jsonb") .HasColumnType("jsonb")
.HasColumnName("picture"); .HasColumnName("picture");
b.Property<Guid>("ProjectId")
.HasColumnType("uuid")
.HasColumnName("project_id");
b.Property<string>("Slug") b.Property<string>("Slug")
.IsRequired() .IsRequired()
.HasMaxLength(1024) .HasMaxLength(1024)
@@ -92,8 +134,8 @@ namespace DysonNetwork.Develop.Migrations
b.HasKey("Id") b.HasKey("Id")
.HasName("pk_custom_apps"); .HasName("pk_custom_apps");
b.HasIndex("DeveloperId") b.HasIndex("ProjectId")
.HasDatabaseName("ix_custom_apps_developer_id"); .HasDatabaseName("ix_custom_apps_project_id");
b.ToTable("custom_apps", (string)null); b.ToTable("custom_apps", (string)null);
}); });
@@ -166,16 +208,78 @@ namespace DysonNetwork.Develop.Migrations
b.ToTable("developers", (string)null); 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() .WithMany()
.HasForeignKey("DeveloperId") .HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired() .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 => modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomAppSecret", b =>
@@ -190,10 +294,27 @@ namespace DysonNetwork.Develop.Migrations
b.Navigation("App"); 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 => modelBuilder.Entity("DysonNetwork.Develop.Identity.CustomApp", b =>
{ {
b.Navigation("Secrets"); b.Navigation("Secrets");
}); });
modelBuilder.Entity("DysonNetwork.Develop.Identity.Developer", b =>
{
b.Navigation("Projects");
});
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }
} }

View File

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

View 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; }
}

View 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();
}
}

View 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;
}
}

View File

@@ -4,6 +4,7 @@ using NodaTime;
using NodaTime.Serialization.SystemTextJson; using NodaTime.Serialization.SystemTextJson;
using System.Text.Json; using System.Text.Json;
using DysonNetwork.Develop.Identity; using DysonNetwork.Develop.Identity;
using DysonNetwork.Develop.Project;
using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Cache;
using StackExchange.Redis; using StackExchange.Redis;
@@ -50,6 +51,8 @@ public static class ServiceCollectionExtensions
services.AddScoped<DeveloperService>(); services.AddScoped<DeveloperService>();
services.AddScoped<CustomAppService>(); services.AddScoped<CustomAppService>();
services.AddScoped<DevProjectService>();
services.AddScoped<BotAccountService>();
return services; return services;
} }

View File

@@ -12,7 +12,8 @@
"ConnectionStrings": { "ConnectionStrings": {
"App": "Host=localhost;Port=5432;Database=dyson_network_dev;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60", "App": "Host=localhost;Port=5432;Database=dyson_network_dev;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60",
"FastRetrieve": "localhost:6379", "FastRetrieve": "localhost:6379",
"Etcd": "etcd.orb.local:2379" "Etcd": "etcd.orb.local:2379",
"Stream": "nats.orb.local:4222"
}, },
"KnownProxies": [ "KnownProxies": [
"127.0.0.1", "127.0.0.1",
@@ -23,7 +24,7 @@
}, },
"Service": { "Service": {
"Name": "DysonNetwork.Develop", "Name": "DysonNetwork.Develop",
"Url": "https://localhost:7099", "Url": "https://localhost:7192",
"ClientCert": "../Certificates/client.crt", "ClientCert": "../Certificates/client.crt",
"ClientKey": "../Certificates/client.key" "ClientKey": "../Certificates/client.key"
} }

View File

@@ -0,0 +1,404 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using DysonNetwork.Drive;
using DysonNetwork.Drive.Storage;
using DysonNetwork.Shared.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DysonNetwork.Drive.Migrations
{
[DbContext(typeof(AppDatabase))]
[Migration("20250819164302_RemoveUploadedTo")]
partial class RemoveUploadedTo
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DysonNetwork.Drive.Billing.QuotaRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text")
.HasColumnName("description");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<long>("Quota")
.HasColumnType("bigint")
.HasColumnName("quota");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_quota_records");
b.ToTable("quota_records", (string)null);
});
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
{
b.Property<string>("Id")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Guid?>("BundleId")
.HasColumnType("uuid")
.HasColumnName("bundle_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("Description")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("description");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<Dictionary<string, object>>("FileMeta")
.HasColumnType("jsonb")
.HasColumnName("file_meta");
b.Property<bool>("HasCompression")
.HasColumnType("boolean")
.HasColumnName("has_compression");
b.Property<bool>("HasThumbnail")
.HasColumnType("boolean")
.HasColumnName("has_thumbnail");
b.Property<string>("Hash")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("hash");
b.Property<bool>("IsEncrypted")
.HasColumnType("boolean")
.HasColumnName("is_encrypted");
b.Property<bool>("IsMarkedRecycle")
.HasColumnType("boolean")
.HasColumnName("is_marked_recycle");
b.Property<string>("MimeType")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("mime_type");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("name");
b.Property<Guid?>("PoolId")
.HasColumnType("uuid")
.HasColumnName("pool_id");
b.Property<List<ContentSensitiveMark>>("SensitiveMarks")
.HasColumnType("jsonb")
.HasColumnName("sensitive_marks");
b.Property<long>("Size")
.HasColumnType("bigint")
.HasColumnName("size");
b.Property<string>("StorageId")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("storage_id");
b.Property<string>("StorageUrl")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("storage_url");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<Instant?>("UploadedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("uploaded_at");
b.Property<Dictionary<string, object>>("UserMeta")
.HasColumnType("jsonb")
.HasColumnName("user_meta");
b.HasKey("Id")
.HasName("pk_files");
b.HasIndex("BundleId")
.HasDatabaseName("ix_files_bundle_id");
b.HasIndex("PoolId")
.HasDatabaseName("ix_files_pool_id");
b.ToTable("files", (string)null);
});
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<string>("FileId")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("file_id");
b.Property<string>("ResourceId")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("resource_id");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<string>("Usage")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("usage");
b.HasKey("Id")
.HasName("pk_file_references");
b.HasIndex("FileId")
.HasDatabaseName("ix_file_references_file_id");
b.ToTable("file_references", (string)null);
});
modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("Description")
.HasMaxLength(8192)
.HasColumnType("character varying(8192)")
.HasColumnName("description");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("name");
b.Property<string>("Passcode")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("passcode");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("slug");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_bundles");
b.HasIndex("Slug")
.IsUnique()
.HasDatabaseName("ix_bundles_slug");
b.ToTable("bundles", (string)null);
});
modelBuilder.Entity("DysonNetwork.Drive.Storage.FilePool", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid?>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<BillingConfig>("BillingConfig")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("billing_config");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("character varying(8192)")
.HasColumnName("description");
b.Property<bool>("IsHidden")
.HasColumnType("boolean")
.HasColumnName("is_hidden");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("name");
b.Property<PolicyConfig>("PolicyConfig")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("policy_config");
b.Property<RemoteStorageConfig>("StorageConfig")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("storage_config");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_pools");
b.ToTable("pools", (string)null);
});
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
{
b.HasOne("DysonNetwork.Drive.Storage.FileBundle", "Bundle")
.WithMany("Files")
.HasForeignKey("BundleId")
.HasConstraintName("fk_files_bundles_bundle_id");
b.HasOne("DysonNetwork.Drive.Storage.FilePool", "Pool")
.WithMany()
.HasForeignKey("PoolId")
.HasConstraintName("fk_files_pools_pool_id");
b.Navigation("Bundle");
b.Navigation("Pool");
});
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
{
b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File")
.WithMany("References")
.HasForeignKey("FileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_file_references_files_file_id");
b.Navigation("File");
});
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
{
b.Navigation("References");
});
modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b =>
{
b.Navigation("Files");
});
#pragma warning restore 612, 618
}
}
}

View File

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

View File

@@ -172,11 +172,6 @@ namespace DysonNetwork.Drive.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("uploaded_at"); .HasColumnName("uploaded_at");
b.Property<string>("UploadedTo")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("uploaded_to");
b.Property<Dictionary<string, object>>("UserMeta") b.Property<Dictionary<string, object>>("UserMeta")
.HasColumnType("jsonb") .HasColumnType("jsonb")
.HasColumnName("user_meta"); .HasColumnName("user_meta");
@@ -382,7 +377,7 @@ namespace DysonNetwork.Drive.Migrations
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b => modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
{ {
b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File") b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File")
.WithMany() .WithMany("References")
.HasForeignKey("FileId") .HasForeignKey("FileId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired() .IsRequired()
@@ -391,6 +386,11 @@ namespace DysonNetwork.Drive.Migrations
b.Navigation("File"); b.Navigation("File");
}); });
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
{
b.Navigation("References");
});
modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b => modelBuilder.Entity("DysonNetwork.Drive.Storage.FileBundle", b =>
{ {
b.Navigation("Files"); b.Navigation("Files");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,7 +20,6 @@ namespace DysonNetwork.Drive.Storage;
public class FileService( public class FileService(
AppDatabase db, AppDatabase db,
IConfiguration configuration, IConfiguration configuration,
TusDiskStore store,
ILogger<FileService> logger, ILogger<FileService> logger,
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
ICacheService cache ICacheService cache
@@ -103,6 +102,7 @@ public class FileService(
private static readonly string[] AnimatedImageTypes = private static readonly string[] AnimatedImageTypes =
["image/gif", "image/apng", "image/avif"]; ["image/gif", "image/apng", "image/avif"];
private static readonly string[] AnimatedImageExtensions = private static readonly string[] AnimatedImageExtensions =
[".gif", ".apng", ".avif"]; [".gif", ".apng", ".avif"];
@@ -268,12 +268,24 @@ public class FileService(
// Add detailed stream information // Add detailed stream information
["video_streams"] = mediaInfo.VideoStreams.Select(s => new ["video_streams"] = mediaInfo.VideoStreams.Select(s => new
{ {
s.AvgFrameRate, s.BitRate, s.CodecName, s.Duration, s.Height, s.Width, s.Language, s.AvgFrameRate,
s.PixelFormat, s.Rotation s.BitRate,
s.CodecName,
s.Duration,
s.Height,
s.Width,
s.Language,
s.PixelFormat,
s.Rotation
}).Where(s => double.IsNormal(s.AvgFrameRate)).ToList(), }).Where(s => double.IsNormal(s.AvgFrameRate)).ToList(),
["audio_streams"] = mediaInfo.AudioStreams.Select(s => new ["audio_streams"] = mediaInfo.AudioStreams.Select(s => new
{ {
s.BitRate, s.Channels, s.ChannelLayout, s.CodecName, s.Duration, s.Language, s.BitRate,
s.Channels,
s.ChannelLayout,
s.CodecName,
s.Duration,
s.Language,
s.SampleRateHz s.SampleRateHz
}) })
.ToList(), .ToList(),
@@ -325,7 +337,14 @@ public class FileService(
if (!pool.PolicyConfig.NoOptimization) if (!pool.PolicyConfig.NoOptimization)
switch (contentType.Split('/')[0]) switch (contentType.Split('/')[0])
{ {
case "image" when !AnimatedImageTypes.Contains(contentType) && !AnimatedImageExtensions.Contains(fileExtension): case "image":
if (AnimatedImageTypes.Contains(contentType) || AnimatedImageExtensions.Contains(fileExtension))
{
logger.LogInformation("Skip optimize file {FileId} due to it is animated...", fileId);
uploads.Add((originalFilePath, string.Empty, contentType, false));
break;
}
newMimeType = "image/webp"; newMimeType = "image/webp";
using (var vipsImage = Image.NewFromFile(originalFilePath)) using (var vipsImage = Image.NewFromFile(originalFilePath))
{ {
@@ -632,7 +651,44 @@ public class FileService(
} }
} }
public async Task<FileBundle?> GetBundleAsync(Guid id, Guid accountId) /// <summary>
/// The most efficent way to delete file data (stored files) in batch.
/// But this DO NOT check the storage id, so use with caution!
/// </summary>
/// <param name="files">Files to delete</param>
/// <exception cref="InvalidOperationException">Something went wrong</exception>
public async Task DeleteFileDataBatchAsync(List<CloudFile> files)
{
files = files.Where(f => f.PoolId.HasValue).ToList();
foreach (var fileGroup in files.GroupBy(f => f.PoolId!.Value))
{
// If any other file with the same storage ID is referenced, don't delete the actual file data
var dest = await GetRemoteStorageConfig(fileGroup.Key);
if (dest is null)
throw new InvalidOperationException($"No remote storage configured for pool {fileGroup.Key}");
var client = CreateMinioClient(dest);
if (client is null)
throw new InvalidOperationException(
$"Failed to configure client for remote destination '{fileGroup.Key}'"
);
List<string> objectsToDelete = [];
foreach (var file in fileGroup)
{
objectsToDelete.Add(file.StorageId ?? file.Id);
if (file.HasCompression) objectsToDelete.Add(file.StorageId ?? file.Id + ".compressed");
if (file.HasThumbnail) objectsToDelete.Add(file.StorageId ?? file.Id + ".thumbnail");
}
await client.RemoveObjectsAsync(
new RemoveObjectsArgs().WithBucket(dest.Bucket).WithObjects(objectsToDelete)
);
}
}
private async Task<FileBundle?> GetBundleAsync(Guid id, Guid accountId)
{ {
var bundle = await db.Bundles var bundle = await db.Bundles
.Where(e => e.Id == id) .Where(e => e.Id == id)

View File

@@ -12,7 +12,8 @@
"ConnectionStrings": { "ConnectionStrings": {
"App": "Host=localhost;Port=5432;Database=dyson_drive;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60", "App": "Host=localhost;Port=5432;Database=dyson_drive;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60",
"FastRetrieve": "localhost:6379", "FastRetrieve": "localhost:6379",
"Etcd": "etcd.orb.local:2379" "Etcd": "etcd.orb.local:2379",
"Stream": "nats.orb.local:4222"
}, },
"Authentication": { "Authentication": {
"Schemes": { "Schemes": {

View File

@@ -1,4 +1,5 @@
using DysonNetwork.Gateway.Startup; using DysonNetwork.Gateway.Startup;
using Microsoft.AspNetCore.HttpOverrides;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -16,6 +17,11 @@ builder.Services.AddControllers();
var app = builder.Build(); var app = builder.Build();
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
});
app.UseRequestTimeouts(); app.UseRequestTimeouts();
app.UseCors(opts => app.UseCors(opts =>
opts.SetIsOriginAllowed(_ => true) opts.SetIsOriginAllowed(_ => true)

View File

@@ -2,6 +2,7 @@ using System.Net.Security;
using System.Security.Cryptography.X509Certificates; using System.Security.Cryptography.X509Certificates;
using DysonNetwork.Shared.Registry; using DysonNetwork.Shared.Registry;
using Yarp.ReverseProxy.Configuration; using Yarp.ReverseProxy.Configuration;
using Yarp.ReverseProxy.Transforms;
namespace DysonNetwork.Gateway.Startup; namespace DysonNetwork.Gateway.Startup;
@@ -15,11 +16,15 @@ public static class ServiceCollectionExtensions
.AddReverseProxy() .AddReverseProxy()
.ConfigureHttpClient((context, handler) => .ConfigureHttpClient((context, handler) =>
{ {
var caCert = X509CertificateLoader.LoadCertificateFromFile(configuration["CaCert"]!); // var caCert = X509CertificateLoader.LoadCertificateFromFile(configuration["CaCert"]!);
handler.SslOptions = new SslClientAuthenticationOptions handler.SslOptions = new SslClientAuthenticationOptions
{ {
RemoteCertificateValidationCallback = (sender, cert, chain, errors) => true RemoteCertificateValidationCallback = (sender, cert, chain, errors) => true
}; };
})
.AddTransforms(context =>
{
context.AddForwarded();
}); });
services.AddRegistryService(configuration, addForwarder: false); services.AddRegistryService(configuration, addForwarder: false);

View File

@@ -21,6 +21,9 @@ public class Account : ModelBase
public Instant? ActivatedAt { get; set; } public Instant? ActivatedAt { get; set; }
public bool IsSuperuser { get; set; } = false; public bool IsSuperuser { get; set; } = false;
// The ID is the BotAccount ID in the DysonNetwork.Develop
public Guid? AutomatedId { get; set; }
public AccountProfile Profile { get; set; } = null!; public AccountProfile Profile { get; set; } = null!;
public ICollection<AccountContact> Contacts { get; set; } = new List<AccountContact>(); public ICollection<AccountContact> Contacts { get; set; } = new List<AccountContact>();
public ICollection<AccountBadge> Badges { get; set; } = new List<AccountBadge>(); public ICollection<AccountBadge> Badges { get; set; } = new List<AccountBadge>();
@@ -48,7 +51,8 @@ public class Account : ModelBase
Profile = Profile.ToProtoValue(), Profile = Profile.ToProtoValue(),
PerkSubscription = PerkSubscription?.ToProtoValue(), PerkSubscription = PerkSubscription?.ToProtoValue(),
CreatedAt = CreatedAt.ToTimestamp(), CreatedAt = CreatedAt.ToTimestamp(),
UpdatedAt = UpdatedAt.ToTimestamp() UpdatedAt = UpdatedAt.ToTimestamp(),
AutomatedId = AutomatedId?.ToString()
}; };
// Add contacts // Add contacts
@@ -78,10 +82,10 @@ public class Account : ModelBase
: null, : null,
CreatedAt = proto.CreatedAt.ToInstant(), CreatedAt = proto.CreatedAt.ToInstant(),
UpdatedAt = proto.UpdatedAt.ToInstant(), UpdatedAt = proto.UpdatedAt.ToInstant(),
AutomatedId = proto.AutomatedId is not null ? Guid.Parse(proto.AutomatedId) : null,
Profile = AccountProfile.FromProtoValue(proto.Profile)
}; };
account.Profile = AccountProfile.FromProtoValue(proto.Profile);
foreach (var contactProto in proto.Contacts) foreach (var contactProto in proto.Contacts)
account.Contacts.Add(AccountContact.FromProtoValue(contactProto)); account.Contacts.Add(AccountContact.FromProtoValue(contactProto));
@@ -116,7 +120,7 @@ public abstract class Leveling
public class AccountProfile : ModelBase, IIdentifiedResource public class AccountProfile : ModelBase, IIdentifiedResource
{ {
public Guid Id { get; set; } public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(256)] public string? FirstName { get; set; } [MaxLength(256)] public string? FirstName { get; set; }
[MaxLength(256)] public string? MiddleName { get; set; } [MaxLength(256)] public string? MiddleName { get; set; }
[MaxLength(256)] public string? LastName { get; set; } [MaxLength(256)] public string? LastName { get; set; }
@@ -132,9 +136,20 @@ public class AccountProfile : ModelBase, IIdentifiedResource
[Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; } [Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; }
[Column(TypeName = "jsonb")] public BadgeReferenceObject? ActiveBadge { get; set; } [Column(TypeName = "jsonb")] public BadgeReferenceObject? ActiveBadge { get; set; }
public int Experience { get; set; } = 0; public int Experience { get; set; }
[NotMapped] public int Level => Leveling.ExperiencePerLevel.Count(xp => Experience >= xp) - 1; [NotMapped] public int Level => Leveling.ExperiencePerLevel.Count(xp => Experience >= xp) - 1;
public double SocialCredits { get; set; } = 100;
[NotMapped]
public int SocialCreditsLevel => SocialCredits switch
{
< 100 => -1,
> 100 and < 200 => 0,
< 200 => 1,
_ => 2
};
[NotMapped] [NotMapped]
public double LevelingProgress => Level >= Leveling.ExperiencePerLevel.Count - 1 public double LevelingProgress => Level >= Leveling.ExperiencePerLevel.Count - 1
? 100 ? 100
@@ -165,6 +180,8 @@ public class AccountProfile : ModelBase, IIdentifiedResource
Experience = Experience, Experience = Experience,
Level = Level, Level = Level,
LevelingProgress = LevelingProgress, LevelingProgress = LevelingProgress,
SocialCredits = SocialCredits,
SocialCreditsLevel = SocialCreditsLevel,
Picture = Picture?.ToProtoValue(), Picture = Picture?.ToProtoValue(),
Background = Background?.ToProtoValue(), Background = Background?.ToProtoValue(),
AccountId = AccountId.ToString(), AccountId = AccountId.ToString(),
@@ -195,6 +212,7 @@ public class AccountProfile : ModelBase, IIdentifiedResource
Verification = proto.Verification is null ? null : VerificationMark.FromProtoValue(proto.Verification), Verification = proto.Verification is null ? null : VerificationMark.FromProtoValue(proto.Verification),
ActiveBadge = proto.ActiveBadge is null ? null : BadgeReferenceObject.FromProtoValue(proto.ActiveBadge), ActiveBadge = proto.ActiveBadge is null ? null : BadgeReferenceObject.FromProtoValue(proto.ActiveBadge),
Experience = proto.Experience, Experience = proto.Experience,
SocialCredits = proto.SocialCredits,
Picture = proto.Picture is null ? null : CloudFileReferenceObject.FromProtoValue(proto.Picture), Picture = proto.Picture is null ? null : CloudFileReferenceObject.FromProtoValue(proto.Picture),
Background = proto.Background is null ? null : CloudFileReferenceObject.FromProtoValue(proto.Background), Background = proto.Background is null ? null : CloudFileReferenceObject.FromProtoValue(proto.Background),
AccountId = Guid.Parse(proto.AccountId), AccountId = Guid.Parse(proto.AccountId),

View File

@@ -1,6 +1,8 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Pass.Auth; using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Credit;
using DysonNetwork.Pass.Wallet; using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Error;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
@@ -14,7 +16,8 @@ public class AccountController(
AuthService auth, AuthService auth,
AccountService accounts, AccountService accounts,
SubscriptionService subscriptions, SubscriptionService subscriptions,
AccountEventService events AccountEventService events,
SocialCreditService socialCreditService
) : ControllerBase ) : ControllerBase
{ {
[HttpGet("{name}")] [HttpGet("{name}")]
@@ -28,7 +31,7 @@ public class AccountController(
.Include(e => e.Contacts.Where(c => c.IsPublic)) .Include(e => e.Contacts.Where(c => c.IsPublic))
.Where(a => a.Name == name) .Where(a => a.Name == name)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (account is null) return new NotFoundResult(); if (account is null) return NotFound(ApiError.NotFound(name, traceId: HttpContext.TraceIdentifier));
var perk = await subscriptions.GetPerkSubscriptionAsync(account.Id); var perk = await subscriptions.GetPerkSubscriptionAsync(account.Id);
account.PerkSubscription = perk?.ToReference(); account.PerkSubscription = perk?.ToReference();
@@ -45,7 +48,26 @@ public class AccountController(
.Include(e => e.Badges) .Include(e => e.Badges)
.Where(a => a.Name == name) .Where(a => a.Name == name)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
return account is null ? NotFound() : account.Badges.ToList(); return account is null ? NotFound(ApiError.NotFound(name, traceId: HttpContext.TraceIdentifier)) : account.Badges.ToList();
}
[HttpGet("{name}/credits")]
[ProducesResponseType<double>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<double>> GetSocialCredits(string name)
{
var account = await db.Accounts
.Where(a => a.Name == name)
.Select(a => new { a.Id })
.FirstOrDefaultAsync();
if (account is null)
{
return NotFound(ApiError.NotFound(name, traceId: HttpContext.TraceIdentifier));
}
var credits = await socialCreditService.GetSocialCredit(account.Id);
return credits;
} }
public class AccountCreateRequest public class AccountCreateRequest
@@ -81,7 +103,11 @@ public class AccountController(
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<Account>> CreateAccount([FromBody] AccountCreateRequest request) public async Task<ActionResult<Account>> CreateAccount([FromBody] AccountCreateRequest request)
{ {
if (!await auth.ValidateCaptcha(request.CaptchaToken)) return BadRequest("Invalid captcha token."); if (!await auth.ValidateCaptcha(request.CaptchaToken))
return BadRequest(ApiError.Validation(new Dictionary<string, string[]>
{
[nameof(request.CaptchaToken)] = ["Invalid captcha token."]
}, traceId: HttpContext.TraceIdentifier));
try try
{ {
@@ -96,7 +122,14 @@ public class AccountController(
} }
catch (Exception ex) catch (Exception ex)
{ {
return BadRequest(ex.Message); return BadRequest(new ApiError
{
Code = "BAD_REQUEST",
Message = "Failed to create account.",
Detail = ex.Message,
Status = 400,
TraceId = HttpContext.TraceIdentifier
});
} }
} }
@@ -109,10 +142,22 @@ public class AccountController(
[HttpPost("recovery/password")] [HttpPost("recovery/password")]
public async Task<ActionResult> RequestResetPassword([FromBody] RecoveryPasswordRequest request) public async Task<ActionResult> RequestResetPassword([FromBody] RecoveryPasswordRequest request)
{ {
if (!await auth.ValidateCaptcha(request.CaptchaToken)) return BadRequest("Invalid captcha token."); if (!await auth.ValidateCaptcha(request.CaptchaToken))
return BadRequest(ApiError.Validation(new Dictionary<string, string[]>
{
[nameof(request.CaptchaToken)] = new[] { "Invalid captcha token." }
}, traceId: HttpContext.TraceIdentifier));
var account = await accounts.LookupAccount(request.Account); var account = await accounts.LookupAccount(request.Account);
if (account is null) return BadRequest("Unable to find the account."); if (account is null)
return BadRequest(new ApiError
{
Code = "NOT_FOUND",
Message = "Unable to find the account.",
Detail = request.Account,
Status = 400,
TraceId = HttpContext.TraceIdentifier
});
try try
{ {
@@ -120,7 +165,13 @@ public class AccountController(
} }
catch (InvalidOperationException) catch (InvalidOperationException)
{ {
return BadRequest("You already requested password reset within 24 hours."); return BadRequest(new ApiError
{
Code = "TOO_MANY_REQUESTS",
Message = "You already requested password reset within 24 hours.",
Status = 400,
TraceId = HttpContext.TraceIdentifier
});
} }
return Ok(); return Ok();
@@ -139,7 +190,15 @@ public class AccountController(
public async Task<ActionResult<Status>> GetOtherStatus(string name) public async Task<ActionResult<Status>> GetOtherStatus(string name)
{ {
var account = await db.Accounts.FirstOrDefaultAsync(a => a.Name == name); var account = await db.Accounts.FirstOrDefaultAsync(a => a.Name == name);
if (account is null) return BadRequest(); if (account is null)
return BadRequest(new ApiError
{
Code = "NOT_FOUND",
Message = "Account not found.",
Detail = name,
Status = 400,
TraceId = HttpContext.TraceIdentifier
});
var status = await events.GetStatus(account.Id); var status = await events.GetStatus(account.Id);
status.IsInvisible = false; // Keep the invisible field not available for other users status.IsInvisible = false; // Keep the invisible field not available for other users
return Ok(status); return Ok(status);
@@ -156,11 +215,27 @@ public class AccountController(
month ??= currentDate.Month; month ??= currentDate.Month;
year ??= currentDate.Year; year ??= currentDate.Year;
if (month is < 1 or > 12) return BadRequest("Invalid month."); if (month is < 1 or > 12)
if (year < 1) return BadRequest("Invalid year."); return BadRequest(ApiError.Validation(new Dictionary<string, string[]>
{
[nameof(month)] = new[] { "Month must be between 1 and 12." }
}, traceId: HttpContext.TraceIdentifier));
if (year < 1)
return BadRequest(ApiError.Validation(new Dictionary<string, string[]>
{
[nameof(year)] = new[] { "Year must be a positive integer." }
}, traceId: HttpContext.TraceIdentifier));
var account = await db.Accounts.FirstOrDefaultAsync(a => a.Name == name); var account = await db.Accounts.FirstOrDefaultAsync(a => a.Name == name);
if (account is null) return BadRequest(); if (account is null)
return BadRequest(new ApiError
{
Code = "not_found",
Message = "Account not found.",
Detail = name,
Status = 400,
TraceId = HttpContext.TraceIdentifier
});
var calendar = await events.GetEventCalendar(account, month.Value, year.Value, replaceInvisible: true); var calendar = await events.GetEventCalendar(account, month.Value, year.Value, replaceInvisible: true);
return Ok(calendar); return Ok(calendar);

View File

@@ -1,7 +1,9 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Permission; using DysonNetwork.Pass.Permission;
using DysonNetwork.Pass.Wallet; using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Error;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -9,7 +11,6 @@ using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using AuthService = DysonNetwork.Pass.Auth.AuthService; using AuthService = DysonNetwork.Pass.Auth.AuthService;
using AuthSession = DysonNetwork.Pass.Auth.AuthSession; using AuthSession = DysonNetwork.Pass.Auth.AuthSession;
using ChallengePlatform = DysonNetwork.Pass.Auth.ChallengePlatform;
namespace DysonNetwork.Pass.Account; namespace DysonNetwork.Pass.Account;
@@ -23,11 +24,13 @@ public class AccountCurrentController(
AccountEventService events, AccountEventService events,
AuthService auth, AuthService auth,
FileService.FileServiceClient files, FileService.FileServiceClient files,
FileReferenceService.FileReferenceServiceClient fileRefs FileReferenceService.FileReferenceServiceClient fileRefs,
Credit.SocialCreditService creditService
) : ControllerBase ) : ControllerBase
{ {
[HttpGet] [HttpGet]
[ProducesResponseType<Account>(StatusCodes.Status200OK)] [ProducesResponseType<Account>(StatusCodes.Status200OK)]
[ProducesResponseType<ApiError>(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<Account>> GetCurrentIdentity() public async Task<ActionResult<Account>> GetCurrentIdentity()
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
@@ -92,7 +95,14 @@ public class AccountCurrentController(
var profile = await db.AccountProfiles var profile = await db.AccountProfiles
.Where(p => p.Account.Id == userId) .Where(p => p.Account.Id == userId)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (profile is null) return BadRequest("Unable to get your account."); if (profile is null)
return BadRequest(new ApiError
{
Code = "NOT_FOUND",
Message = "Unable to get your account.",
Status = 400,
TraceId = HttpContext.TraceIdentifier
});
if (request.FirstName is not null) profile.FirstName = request.FirstName; if (request.FirstName is not null) profile.FirstName = request.FirstName;
if (request.MiddleName is not null) profile.MiddleName = request.MiddleName; if (request.MiddleName is not null) profile.MiddleName = request.MiddleName;
@@ -160,7 +170,13 @@ public class AccountCurrentController(
} }
catch (InvalidOperationException) catch (InvalidOperationException)
{ {
return BadRequest("You already requested account deletion within 24 hours."); return BadRequest(new ApiError
{
Code = "TOO_MANY_REQUESTS",
Message = "You already requested account deletion within 24 hours.",
Status = 400,
TraceId = HttpContext.TraceIdentifier
});
} }
return Ok(); return Ok();
@@ -186,7 +202,7 @@ public class AccountCurrentController(
.Where(e => e.ClearedAt == null || e.ClearedAt > now) .Where(e => e.ClearedAt == null || e.ClearedAt > now)
.OrderByDescending(e => e.CreatedAt) .OrderByDescending(e => e.CreatedAt)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (status is null) return NotFound(); if (status is null) return NotFound(ApiError.NotFound("status", traceId: HttpContext.TraceIdentifier));
status.Attitude = request.Attitude; status.Attitude = request.Attitude;
status.IsInvisible = request.IsInvisible; status.IsInvisible = request.IsInvisible;
@@ -254,7 +270,9 @@ public class AccountCurrentController(
.OrderByDescending(x => x.CreatedAt) .OrderByDescending(x => x.CreatedAt)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
return result is null ? NotFound() : Ok(result); return result is null
? NotFound(ApiError.NotFound("check-in", traceId: HttpContext.TraceIdentifier))
: Ok(result);
} }
[HttpPost("check-in")] [HttpPost("check-in")]
@@ -269,15 +287,30 @@ public class AccountCurrentController(
{ {
var isAvailable = await events.CheckInDailyIsAvailable(currentUser); var isAvailable = await events.CheckInDailyIsAvailable(currentUser);
if (!isAvailable) if (!isAvailable)
return BadRequest("Check-in is not available for today."); return BadRequest(new ApiError
{
Code = "BAD_REQUEST",
Message = "Check-in is not available for today.",
Status = 400,
TraceId = HttpContext.TraceIdentifier
});
} }
else else
{ {
if (currentUser.PerkSubscription is null) if (currentUser.PerkSubscription is null)
return StatusCode(403, "You need to have a subscription to check-in backdated."); return StatusCode(403, ApiError.Unauthorized(
message: "You need to have a subscription to check-in backdated.",
forbidden: true,
traceId: HttpContext.TraceIdentifier));
var isAvailable = await events.CheckInBackdatedIsAvailable(currentUser, backdated.Value); var isAvailable = await events.CheckInBackdatedIsAvailable(currentUser, backdated.Value);
if (!isAvailable) if (!isAvailable)
return BadRequest("Check-in is not available for this date."); return BadRequest(new ApiError
{
Code = "BAD_REQUEST",
Message = "Check-in is not available for this date.",
Status = 400,
TraceId = HttpContext.TraceIdentifier
});
} }
try try
@@ -286,15 +319,32 @@ public class AccountCurrentController(
return needsCaptcha switch return needsCaptcha switch
{ {
true when string.IsNullOrWhiteSpace(captchaToken) => StatusCode(423, true when string.IsNullOrWhiteSpace(captchaToken) => StatusCode(423,
"Captcha is required for this check-in." new ApiError
{
Code = "CAPTCHA_REQUIRED",
Message = "Captcha is required for this check-in.",
Status = 423,
TraceId = HttpContext.TraceIdentifier
}
), ),
true when !await auth.ValidateCaptcha(captchaToken!) => BadRequest("Invalid captcha token."), 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) _ => await events.CheckInDaily(currentUser, backdated)
}; };
} }
catch (InvalidOperationException ex) catch (InvalidOperationException ex)
{ {
return BadRequest(ex.Message); return BadRequest(new ApiError
{
Code = "BAD_REQUEST",
Message = "Check-in failed.",
Detail = ex.Message,
Status = 400,
TraceId = HttpContext.TraceIdentifier
});
} }
} }
@@ -308,8 +358,16 @@ public class AccountCurrentController(
month ??= currentDate.Month; month ??= currentDate.Month;
year ??= currentDate.Year; year ??= currentDate.Year;
if (month is < 1 or > 12) return BadRequest("Invalid month."); if (month is < 1 or > 12)
if (year < 1) return BadRequest("Invalid year."); return BadRequest(ApiError.Validation(new Dictionary<string, string[]>
{
[nameof(month)] = new[] { "Month must be between 1 and 12." }
}, traceId: HttpContext.TraceIdentifier));
if (year < 1)
return BadRequest(ApiError.Validation(new Dictionary<string, string[]>
{
[nameof(year)] = new[] { "Year must be a positive integer." }
}, traceId: HttpContext.TraceIdentifier));
var calendar = await events.GetEventCalendar(currentUser, month.Value, year.Value); var calendar = await events.GetEventCalendar(currentUser, month.Value, year.Value);
return Ok(calendar); return Ok(calendar);
@@ -365,7 +423,13 @@ public class AccountCurrentController(
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (await accounts.CheckAuthFactorExists(currentUser, request.Type)) if (await accounts.CheckAuthFactorExists(currentUser, request.Type))
return BadRequest($"Auth factor with type {request.Type} is already exists."); return BadRequest(new ApiError
{
Code = "ALREADY_EXISTS",
Message = $"Auth factor with type {request.Type} already exists.",
Status = 400,
TraceId = HttpContext.TraceIdentifier
});
var factor = await accounts.CreateAuthFactor(currentUser, request.Type, request.Secret); var factor = await accounts.CreateAuthFactor(currentUser, request.Type, request.Secret);
return Ok(factor); return Ok(factor);
@@ -380,7 +444,7 @@ public class AccountCurrentController(
var factor = await db.AccountAuthFactors var factor = await db.AccountAuthFactors
.Where(f => f.AccountId == currentUser.Id && f.Id == id) .Where(f => f.AccountId == currentUser.Id && f.Id == id)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (factor is null) return NotFound(); if (factor is null) return NotFound(ApiError.NotFound(id.ToString(), traceId: HttpContext.TraceIdentifier));
try try
{ {
@@ -389,7 +453,14 @@ public class AccountCurrentController(
} }
catch (Exception ex) catch (Exception ex)
{ {
return BadRequest(ex.Message); return BadRequest(new ApiError
{
Code = "BAD_REQUEST",
Message = "Failed to enable auth factor.",
Detail = ex.Message,
Status = 400,
TraceId = HttpContext.TraceIdentifier
});
} }
} }
@@ -437,29 +508,31 @@ public class AccountCurrentController(
} }
} }
public class AuthorizedDevice
{
public string? Label { get; set; }
public string UserAgent { get; set; } = null!;
public string DeviceId { get; set; } = null!;
public ChallengePlatform Platform { get; set; }
public List<AuthSession> Sessions { get; set; } = [];
}
[HttpGet("devices")] [HttpGet("devices")]
[Authorize] [Authorize]
public async Task<ActionResult<List<AuthorizedDevice>>> GetDevices() public async Task<ActionResult<List<AuthClientWithChallenge>>> GetDevices()
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser || if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized(); HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
Response.Headers.Append("X-Auth-Session", currentSession.Id.ToString()); Response.Headers.Append("X-Auth-Session", currentSession.Id.ToString());
var devices = await db.AuthDevices var devices = await db.AuthClients
.Where(device => device.AccountId == currentUser.Id) .Where(device => device.AccountId == currentUser.Id)
.ToListAsync(); .ToListAsync();
return Ok(devices); var challengeDevices = devices.Select(AuthClientWithChallenge.FromClient).ToList();
var deviceIds = challengeDevices.Select(x => x.Id).ToList();
var authChallenges = await db.AuthChallenges
.Where(c => c.ClientId != null && deviceIds.Contains(c.ClientId.Value))
.GroupBy(c => c.ClientId)
.ToDictionaryAsync(c => c.Key!.Value, c => c.ToList());
foreach (var challengeDevice in challengeDevices)
if (authChallenges.TryGetValue(challengeDevice.Id, out var challenge))
challengeDevice.Challenges = challenge;
return Ok(challengeDevices);
} }
[HttpGet("sessions")] [HttpGet("sessions")]
@@ -507,6 +580,23 @@ public class AccountCurrentController(
} }
} }
[HttpDelete("devices/{deviceId}")]
[Authorize]
public async Task<ActionResult<AuthSession>> DeleteDevice(string deviceId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
try
{
await accounts.DeleteDevice(currentUser, deviceId);
return NoContent();
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
[HttpDelete("sessions/current")] [HttpDelete("sessions/current")]
[Authorize] [Authorize]
public async Task<ActionResult<AuthSession>> DeleteCurrentSession() public async Task<ActionResult<AuthSession>> DeleteCurrentSession()
@@ -525,14 +615,15 @@ public class AccountCurrentController(
} }
} }
[HttpPatch("sessions/{id:guid}/label")] [HttpPatch("devices/{deviceId}/label")]
public async Task<ActionResult<AuthSession>> UpdateSessionLabel(Guid id, [FromBody] string label) [Authorize]
public async Task<ActionResult<AuthSession>> UpdateDeviceLabel(string deviceId, [FromBody] string label)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
try try
{ {
await accounts.UpdateSessionLabel(currentUser, id, label); await accounts.UpdateDeviceName(currentUser, deviceId, label);
return NoContent(); return NoContent();
} }
catch (Exception ex) catch (Exception ex)
@@ -541,15 +632,19 @@ public class AccountCurrentController(
} }
} }
[HttpPatch("sessions/current/label")] [HttpPatch("devices/current/label")]
public async Task<ActionResult<AuthSession>> UpdateCurrentSessionLabel([FromBody] string label) [Authorize]
public async Task<ActionResult<AuthSession>> UpdateCurrentDeviceLabel([FromBody] string label)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser || if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized(); HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
var device = await db.AuthClients.FirstOrDefaultAsync(d => d.Id == currentSession.Challenge.ClientId);
if (device is null) return NotFound();
try try
{ {
await accounts.UpdateSessionLabel(currentUser, currentSession.Id, label); await accounts.UpdateDeviceName(currentUser, device.DeviceId, label);
return NoContent(); return NoContent();
} }
catch (Exception ex) catch (Exception ex)
@@ -733,4 +828,60 @@ public class AccountCurrentController(
return BadRequest(ex.Message); return BadRequest(ex.Message);
} }
} }
[HttpGet("leveling")]
[Authorize]
public async Task<ActionResult<ExperienceRecord>> GetLevelingHistory(
[FromQuery] int take = 20,
[FromQuery] int offset = 0
)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var queryable = db.ExperienceRecords
.Where(r => r.AccountId == currentUser.Id)
.OrderByDescending(r => r.CreatedAt)
.AsQueryable();
var totalCount = await queryable.CountAsync();
Response.Headers["X-Total"] = totalCount.ToString();
var records = await queryable
.Skip(offset)
.Take(take)
.ToListAsync();
return Ok(records);
}
[HttpGet("credits")]
public async Task<ActionResult<bool>> GetSocialCredit()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var credit = await creditService.GetSocialCredit(currentUser.Id);
return Ok(credit);
}
[HttpGet("credits/history")]
public async Task<ActionResult<SocialCreditRecord>> GetCreditHistory(
[FromQuery] int take = 20,
[FromQuery] int offset = 0
)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var queryable = db.SocialCreditRecords
.Where(r => r.AccountId == currentUser.Id)
.OrderByDescending(r => r.CreatedAt)
.AsQueryable();
var totalCount = await queryable.CountAsync();
Response.Headers["X-Total"] = totalCount.ToString();
var records = await queryable
.Skip(offset)
.Take(take)
.ToListAsync();
return Ok(records);
}
} }

View File

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

View File

@@ -1,29 +1,36 @@
using System.Globalization; using System.Globalization;
using System.Text.Json;
using DysonNetwork.Pass.Auth; using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Auth.OpenId; using DysonNetwork.Pass.Auth.OpenId;
using DysonNetwork.Pass.Email; using DysonNetwork.Pass.Email;
using DysonNetwork.Pass.Localization; using DysonNetwork.Pass.Localization;
using DysonNetwork.Pass.Permission; using DysonNetwork.Pass.Permission;
using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Stream;
using EFCore.BulkExtensions; using EFCore.BulkExtensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
using NATS.Client.Core;
using NodaTime; using NodaTime;
using OtpNet; using OtpNet;
using AuthSession = DysonNetwork.Pass.Auth.AuthSession; using AuthService = DysonNetwork.Pass.Auth.AuthService;
namespace DysonNetwork.Pass.Account; namespace DysonNetwork.Pass.Account;
public class AccountService( public class AccountService(
AppDatabase db, AppDatabase db,
MagicSpellService spells, MagicSpellService spells,
FileService.FileServiceClient files,
FileReferenceService.FileReferenceServiceClient fileRefs,
AccountUsernameService uname, AccountUsernameService uname,
EmailService mailer, EmailService mailer,
PusherService.PusherServiceClient pusher, PusherService.PusherServiceClient pusher,
IStringLocalizer<NotificationResource> localizer, IStringLocalizer<NotificationResource> localizer,
ICacheService cache, ICacheService cache,
ILogger<AccountService> logger ILogger<AccountService> logger,
INatsConnection nats
) )
{ {
public static void SetCultureInfo(Account account) public static void SetCultureInfo(Account account)
@@ -178,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) public async Task RequestAccountDeletion(Account account)
{ {
var spell = await spells.CreateMagicSpell( var spell = await spells.CreateMagicSpell(
@@ -336,12 +396,10 @@ public class AccountService(
/// <summary> /// <summary>
/// Send the auth factor verification code to users, for factors like in-app code and email. /// Send the auth factor verification code to users, for factors like in-app code and email.
/// Sometimes it requires a hint, like a part of the user's email address to ensure the user is who own the account.
/// </summary> /// </summary>
/// <param name="account">The owner of the auth factor</param> /// <param name="account">The owner of the auth factor</param>
/// <param name="factor">The auth factor needed to send code</param> /// <param name="factor">The auth factor needed to send code</param>
/// <param name="hint">The part of the contact method for verification</param> public async Task SendFactorCode(Account account, AccountAuthFactor factor)
public async Task SendFactorCode(Account account, AccountAuthFactor factor, string? hint = null)
{ {
var code = new Random().Next(100000, 999999).ToString("000000"); var code = new Random().Next(100000, 999999).ToString("000000");
@@ -370,30 +428,16 @@ public class AccountService(
if (await _GetFactorCode(factor) is not null) if (await _GetFactorCode(factor) is not null)
throw new InvalidOperationException("A factor code has been sent and in active duration."); throw new InvalidOperationException("A factor code has been sent and in active duration.");
ArgumentNullException.ThrowIfNull(hint);
hint = hint.Replace("@", "").Replace(".", "").Replace("+", "").Replace("%", "");
if (string.IsNullOrWhiteSpace(hint))
{
logger.LogWarning(
"Unable to send factor code to #{FactorId} with hint {Hint}, due to invalid hint...",
factor.Id,
hint
);
return;
}
var contact = await db.AccountContacts var contact = await db.AccountContacts
.Where(c => c.Type == AccountContactType.Email) .Where(c => c.Type == AccountContactType.Email)
.Where(c => c.VerifiedAt != null) .Where(c => c.VerifiedAt != null)
.Where(c => EF.Functions.ILike(c.Content, $"%{hint}%")) .Where(c => c.IsPrimary)
.Include(c => c.Account) .Include(c => c.Account)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (contact is null) if (contact is null)
{ {
logger.LogWarning( logger.LogWarning(
"Unable to send factor code to #{FactorId} with hint {Hint}, due to no contact method found according to hint...", "Unable to send factor code to #{FactorId} with, due to no contact method was found..."
factor.Id,
hint
); );
return; return;
} }
@@ -456,61 +500,80 @@ public class AccountService(
); );
} }
public async Task<bool> IsDeviceActive(Guid id) private async Task<bool> IsDeviceActive(Guid id)
{ {
return await db.AuthChallenges.AnyAsync(d => d.DeviceId == id); return await db.AuthSessions
.Include(s => s.Challenge)
.AnyAsync(s => s.Challenge.ClientId == id);
} }
public async Task<AuthSession> UpdateSessionLabel(Account account, Guid sessionId, string label) public async Task<AuthClient> UpdateDeviceName(Account account, string deviceId, string label)
{ {
var session = await db.AuthSessions var device = await db.AuthClients.FirstOrDefaultAsync(c => c.DeviceId == deviceId && c.AccountId == account.Id
.Include(s => s.Challenge) );
.Where(s => s.Id == sessionId && s.AccountId == account.Id) if (device is null) throw new InvalidOperationException("Device was not found.");
.FirstOrDefaultAsync();
if (session is null) throw new InvalidOperationException("Session was not found.");
await db.AuthSessions device.DeviceLabel = label;
.Include(s => s.Challenge) db.Update(device);
.Where(s => s.Challenge.DeviceId == session.Challenge.DeviceId) await db.SaveChangesAsync();
.ExecuteUpdateAsync(p => p.SetProperty(s => s.Label, label));
var sessions = await db.AuthSessions return device;
.Include(s => s.Challenge)
.Where(s => s.AccountId == session.Id && s.Challenge.DeviceId == session.Challenge.DeviceId)
.ToListAsync();
foreach (var item in sessions)
await cache.RemoveAsync($"{DysonTokenAuthHandler.AuthCachePrefix}{item.Id}");
return session;
} }
public async Task DeleteSession(Account account, Guid sessionId) public async Task DeleteSession(Account account, Guid sessionId)
{ {
var session = await db.AuthSessions var session = await db.AuthSessions
.Include(s => s.Challenge) .Include(s => s.Challenge)
.ThenInclude(s => s.Device) .ThenInclude(s => s.Client)
.Where(s => s.Id == sessionId && s.AccountId == account.Id) .Where(s => s.Id == sessionId && s.AccountId == account.Id)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (session is null) throw new InvalidOperationException("Session was not found."); if (session is null) throw new InvalidOperationException("Session was not found.");
var sessions = await db.AuthSessions // The current session should be included in the sessions' list
.Include(s => s.Challenge) db.AuthSessions.Remove(session);
.Where(s => s.AccountId == session.Id && s.Challenge.DeviceId == session.Challenge.DeviceId) await db.SaveChangesAsync();
.ToListAsync();
if (!await IsDeviceActive(session.Challenge.DeviceId)) if (session.Challenge.ClientId.HasValue)
{
if (!await IsDeviceActive(session.Challenge.ClientId.Value))
await pusher.UnsubscribePushNotificationsAsync(new UnsubscribePushNotificationsRequest() await pusher.UnsubscribePushNotificationsAsync(new UnsubscribePushNotificationsRequest()
{ DeviceId = session.Challenge.Device.DeviceId } { DeviceId = session.Challenge.Client!.DeviceId }
);
}
logger.LogInformation("Deleted session #{SessionId}", session.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
);
if (device is null)
throw new InvalidOperationException("Device not found.");
await pusher.UnsubscribePushNotificationsAsync(
new UnsubscribePushNotificationsRequest { DeviceId = device.DeviceId }
); );
var sessions = await db.AuthSessions
.Include(s => s.Challenge)
.Where(s => s.Challenge.ClientId == device.Id && s.AccountId == account.Id)
.ToListAsync();
// The current session should be included in the sessions' list // The current session should be included in the sessions' list
var now = SystemClock.Instance.GetCurrentInstant();
await db.AuthSessions await db.AuthSessions
.Include(s => s.Challenge) .Include(s => s.Challenge)
.Where(s => s.Challenge.DeviceId == session.Challenge.DeviceId) .Where(s => s.Challenge.ClientId == device.Id)
.ExecuteDeleteAsync(); .ExecuteUpdateAsync(p => p.SetProperty(s => s.DeletedAt, s => now));
db.AuthClients.Remove(device);
await db.SaveChangesAsync();
foreach (var item in sessions) foreach (var item in sessions)
await cache.RemoveAsync($"{DysonTokenAuthHandler.AuthCachePrefix}{item.Id}"); await cache.RemoveAsync($"{AuthService.AuthCachePrefix}{item.Id}");
} }
public async Task<AccountContact> CreateContactMethod(Account account, AccountContactType type, string content) public async Task<AccountContact> CreateContactMethod(Account account, AccountContactType type, string content)
@@ -662,21 +725,19 @@ public class AccountService(
} }
} }
/// <summary> public async Task DeleteAccount(Account account)
/// 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()
{ {
var accountsId = await db.Accounts.Select(a => a.Id).ToListAsync(); await db.AuthSessions
var existingId = await db.AccountProfiles.Select(p => p.AccountId).ToListAsync(); .Where(s => s.AccountId == account.Id)
var missingId = accountsId.Except(existingId).ToList(); .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 }) AccountId = account.Id,
.ToList(); DeletedAt = SystemClock.Instance.GetCurrentInstant()
await db.BulkInsertAsync(newProfiles); }));
}
} }
} }

View File

@@ -9,6 +9,7 @@ namespace DysonNetwork.Pass.Account;
public class AccountServiceGrpc( public class AccountServiceGrpc(
AppDatabase db, AppDatabase db,
AccountEventService accountEvents,
RelationshipService relationships, RelationshipService relationships,
SubscriptionService subscriptions, SubscriptionService subscriptions,
IClock clock, IClock clock,
@@ -41,6 +42,26 @@ public class AccountServiceGrpc(
return account.ToProtoValue(); return account.ToProtoValue();
} }
public override async Task<Shared.Proto.Account> GetBotAccount(GetBotAccountRequest request,
ServerCallContext context)
{
if (!Guid.TryParse(request.AutomatedId, out var automatedId))
throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid automated ID format"));
var account = await _db.Accounts
.AsNoTracking()
.Include(a => a.Profile)
.FirstOrDefaultAsync(a => a.AutomatedId == automatedId);
if (account == null)
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, $"Account with automated ID {request.AutomatedId} not found"));
var perk = await subscriptions.GetPerkSubscriptionAsync(account.Id);
account.PerkSubscription = perk?.ToReference();
return account.ToProtoValue();
}
public override async Task<GetAccountBatchResponse> GetAccountBatch(GetAccountBatchRequest request, public override async Task<GetAccountBatchResponse> GetAccountBatch(GetAccountBatchRequest request,
ServerCallContext context) ServerCallContext context)
{ {
@@ -68,6 +89,55 @@ public class AccountServiceGrpc(
return response; 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);
var status = await accountEvents.GetStatus(accountId);
return status.ToProtoValue();
}
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)
.Where(id => id.HasValue)
.Select(id => id!.Value)
.ToList();
var statuses = await accountEvents.GetStatuses(accountIds);
var response = new GetAccountStatusBatchResponse();
response.Statuses.AddRange(statuses.Select(s => s.Value.ToProtoValue()));
return response;
}
public override async Task<GetAccountBatchResponse> LookupAccountBatch(LookupAccountBatchRequest request, public override async Task<GetAccountBatchResponse> LookupAccountBatch(LookupAccountBatchRequest request,
ServerCallContext context) ServerCallContext context)
{ {

View 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 };
}
}

View File

@@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Data;
using NodaTime; using NodaTime;
using NodaTime.Serialization.Protobuf;
namespace DysonNetwork.Pass.Account; namespace DysonNetwork.Pass.Account;
@@ -25,6 +26,54 @@ public class Status : ModelBase
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
public Account Account { get; set; } = null!; public Account Account { get; set; } = null!;
public Shared.Proto.AccountStatus ToProtoValue()
{
var proto = new Shared.Proto.AccountStatus
{
Id = Id.ToString(),
Attitude = Attitude switch
{
StatusAttitude.Positive => Shared.Proto.StatusAttitude.Positive,
StatusAttitude.Negative => Shared.Proto.StatusAttitude.Negative,
StatusAttitude.Neutral => Shared.Proto.StatusAttitude.Neutral,
_ => Shared.Proto.StatusAttitude.Unspecified
},
IsOnline = IsOnline,
IsCustomized = IsCustomized,
IsInvisible = IsInvisible,
IsNotDisturb = IsNotDisturb,
Label = Label ?? string.Empty,
ClearedAt = ClearedAt?.ToTimestamp(),
AccountId = AccountId.ToString()
};
return proto;
}
public static Status FromProtoValue(Shared.Proto.AccountStatus proto)
{
var status = new Status
{
Id = Guid.Parse(proto.Id),
Attitude = proto.Attitude switch
{
Shared.Proto.StatusAttitude.Positive => StatusAttitude.Positive,
Shared.Proto.StatusAttitude.Negative => StatusAttitude.Negative,
Shared.Proto.StatusAttitude.Neutral => StatusAttitude.Neutral,
_ => StatusAttitude.Neutral
},
IsOnline = proto.IsOnline,
IsCustomized = proto.IsCustomized,
IsInvisible = proto.IsInvisible,
IsNotDisturb = proto.IsNotDisturb,
Label = proto.Label,
ClearedAt = proto.ClearedAt?.ToInstant(),
AccountId = Guid.Parse(proto.AccountId)
};
return status;
}
} }
public enum CheckInResultLevel public enum CheckInResultLevel

View File

@@ -1,10 +1,18 @@
using DysonNetwork.Pass.Localization;
using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Proto;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
using NodaTime; using NodaTime;
namespace DysonNetwork.Pass.Account; 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 UserFriendsCacheKeyPrefix = "accounts:friends:";
private const string UserBlockedCacheKeyPrefix = "accounts:blocked:"; private const string UserBlockedCacheKeyPrefix = "accounts:blocked:";
@@ -92,6 +100,19 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
db.AccountRelationships.Add(relationship); db.AccountRelationships.Add(relationship);
await db.SaveChangesAsync(); 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; return relationship;
} }

View File

@@ -2,6 +2,8 @@ using System.Linq.Expressions;
using System.Reflection; using System.Reflection;
using DysonNetwork.Pass.Account; using DysonNetwork.Pass.Account;
using DysonNetwork.Pass.Auth; using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Credit;
using DysonNetwork.Pass.Leveling;
using DysonNetwork.Pass.Permission; using DysonNetwork.Pass.Permission;
using DysonNetwork.Pass.Wallet; using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Data;
@@ -18,35 +20,39 @@ public class AppDatabase(
IConfiguration configuration IConfiguration configuration
) : DbContext(options) ) : DbContext(options)
{ {
public DbSet<PermissionNode> PermissionNodes { get; set; } public DbSet<PermissionNode> PermissionNodes { get; set; } = null!;
public DbSet<PermissionGroup> PermissionGroups { get; set; } public DbSet<PermissionGroup> PermissionGroups { get; set; } = null!;
public DbSet<PermissionGroupMember> PermissionGroupMembers { get; set; } public DbSet<PermissionGroupMember> PermissionGroupMembers { get; set; } = null!;
public DbSet<MagicSpell> MagicSpells { get; set; } public DbSet<MagicSpell> MagicSpells { get; set; } = null!;
public DbSet<Account.Account> Accounts { get; set; } public DbSet<Account.Account> Accounts { get; set; } = null!;
public DbSet<AccountConnection> AccountConnections { get; set; } public DbSet<AccountConnection> AccountConnections { get; set; } = null!;
public DbSet<AccountProfile> AccountProfiles { get; set; } public DbSet<AccountProfile> AccountProfiles { get; set; } = null!;
public DbSet<AccountContact> AccountContacts { get; set; } public DbSet<AccountContact> AccountContacts { get; set; } = null!;
public DbSet<AccountAuthFactor> AccountAuthFactors { get; set; } public DbSet<AccountAuthFactor> AccountAuthFactors { get; set; } = null!;
public DbSet<Relationship> AccountRelationships { get; set; } public DbSet<Relationship> AccountRelationships { get; set; } = null!;
public DbSet<Status> AccountStatuses { get; set; } public DbSet<Status> AccountStatuses { get; set; } = null!;
public DbSet<CheckInResult> AccountCheckInResults { get; set; } public DbSet<CheckInResult> AccountCheckInResults { get; set; } = null!;
public DbSet<AccountBadge> Badges { get; set; } public DbSet<AccountBadge> Badges { get; set; } = null!;
public DbSet<ActionLog> ActionLogs { get; set; } public DbSet<ActionLog> ActionLogs { get; set; } = null!;
public DbSet<AbuseReport> AbuseReports { get; set; } public DbSet<AbuseReport> AbuseReports { get; set; } = null!;
public DbSet<AuthSession> AuthSessions { get; set; } public DbSet<AuthSession> AuthSessions { get; set; } = null!;
public DbSet<AuthChallenge> AuthChallenges { get; set; } public DbSet<AuthChallenge> AuthChallenges { get; set; } = null!;
public DbSet<AuthDevice> AuthDevices { get; set; } public DbSet<AuthClient> AuthClients { get; set; } = null!;
public DbSet<ApiKey> ApiKeys { get; set; } = null!;
public DbSet<Wallet.Wallet> Wallets { get; set; } public DbSet<Wallet.Wallet> Wallets { get; set; } = null!;
public DbSet<WalletPocket> WalletPockets { get; set; } public DbSet<WalletPocket> WalletPockets { get; set; } = null!;
public DbSet<Order> PaymentOrders { get; set; } public DbSet<Order> PaymentOrders { get; set; } = null!;
public DbSet<Transaction> PaymentTransactions { get; set; } public DbSet<Transaction> PaymentTransactions { get; set; } = null!;
public DbSet<Subscription> WalletSubscriptions { get; set; } public DbSet<Subscription> WalletSubscriptions { get; set; } = null!;
public DbSet<Coupon> WalletCoupons { get; set; } public DbSet<Coupon> WalletCoupons { get; set; } = null!;
public DbSet<Punishment> Punishments { get; set; } public DbSet<Punishment> Punishments { get; set; } = null!;
public DbSet<SocialCreditRecord> SocialCreditRecords { get; set; } = null!;
public DbSet<ExperienceRecord> ExperienceRecords { get; set; } = null!;
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {

View File

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

View File

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

View File

@@ -1,13 +1,8 @@
using System.Security.Claims; using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Encodings.Web; using System.Text.Encodings.Web;
using DysonNetwork.Pass.Account;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using DysonNetwork.Pass.Auth.OidcProvider.Services;
using DysonNetwork.Pass.Handlers; using DysonNetwork.Pass.Handlers;
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Cache;
using SystemClock = NodaTime.SystemClock; using SystemClock = NodaTime.SystemClock;
@@ -38,19 +33,13 @@ public class DysonTokenAuthOptions : AuthenticationSchemeOptions;
public class DysonTokenAuthHandler( public class DysonTokenAuthHandler(
IOptionsMonitor<DysonTokenAuthOptions> options, IOptionsMonitor<DysonTokenAuthOptions> options,
IConfiguration configuration,
ILoggerFactory logger, ILoggerFactory logger,
UrlEncoder encoder, UrlEncoder encoder,
AppDatabase database, TokenAuthService token,
OidcProviderService oidc,
SubscriptionService subscriptions,
ICacheService cache,
FlushBufferService fbs FlushBufferService fbs
) )
: AuthenticationHandler<DysonTokenAuthOptions>(options, logger, encoder) : AuthenticationHandler<DysonTokenAuthOptions>(options, logger, encoder)
{ {
public const string AuthCachePrefix = "auth:";
protected override async Task<AuthenticateResult> HandleAuthenticateAsync() protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{ {
var tokenInfo = _ExtractToken(Request); var tokenInfo = _ExtractToken(Request);
@@ -60,47 +49,12 @@ public class DysonTokenAuthHandler(
try try
{ {
var now = SystemClock.Instance.GetCurrentInstant(); // Get client IP address
var ipAddress = Context.Connection.RemoteIpAddress?.ToString();
// Validate token and extract session ID var (valid, session, message) = await token.AuthenticateTokenAsync(tokenInfo.Token, ipAddress);
if (!ValidateToken(tokenInfo.Token, out var sessionId)) if (!valid || session is null)
return AuthenticateResult.Fail("Invalid token."); return AuthenticateResult.Fail(message ?? "Authentication failed.");
// Try to get session from cache first
var session = await cache.GetAsync<AuthSession>($"{AuthCachePrefix}{sessionId}");
// If not in cache, load from database
if (session is null)
{
session = await database.AuthSessions
.Where(e => e.Id == sessionId)
.Include(e => e.Challenge)
.Include(e => e.Account)
.ThenInclude(e => e.Profile)
.FirstOrDefaultAsync();
if (session is not null)
{
var perk = await subscriptions.GetPerkSubscriptionAsync(session.AccountId);
session.Account.PerkSubscription = perk?.ToReference();
// Store in cache for future requests
await cache.SetWithGroupsAsync(
$"auth:{sessionId}",
session,
[$"{AccountService.AccountCachePrefix}{session.Account.Id}"],
TimeSpan.FromHours(1)
);
}
}
// Check if the session exists
if (session == null)
return AuthenticateResult.Fail("Session not found.");
// Check if the session is expired
if (session.ExpiredAt.HasValue && session.ExpiredAt.Value < now)
return AuthenticateResult.Fail("Session expired.");
// Store user and session in the HttpContext.Items for easy access in controllers // Store user and session in the HttpContext.Items for easy access in controllers
Context.Items["CurrentUser"] = session.Account; Context.Items["CurrentUser"] = session.Account;
@@ -116,7 +70,7 @@ public class DysonTokenAuthHandler(
}; };
// Add scopes as claims // Add scopes as claims
session.Challenge.Scopes.ForEach(scope => claims.Add(new Claim("scope", scope))); session.Challenge?.Scopes.ForEach(scope => claims.Add(new Claim("scope", scope)));
// Add superuser claim if applicable // Add superuser claim if applicable
if (session.Account.IsSuperuser) if (session.Account.IsSuperuser)
@@ -144,79 +98,6 @@ public class DysonTokenAuthHandler(
} }
} }
private bool ValidateToken(string token, out Guid sessionId)
{
sessionId = Guid.Empty;
try
{
var parts = token.Split('.');
switch (parts.Length)
{
// Handle JWT tokens (3 parts)
case 3:
{
var (isValid, jwtResult) = oidc.ValidateToken(token);
if (!isValid) return false;
var jti = jwtResult?.Claims.FirstOrDefault(c => c.Type == "jti")?.Value;
if (jti is null) return false;
return Guid.TryParse(jti, out sessionId);
}
// Handle compact tokens (2 parts)
case 2:
// Original compact token validation logic
try
{
// Decode the payload
var payloadBytes = Base64UrlDecode(parts[0]);
// Extract session ID
sessionId = new Guid(payloadBytes);
// Load public key for verification
var publicKeyPem = File.ReadAllText(configuration["AuthToken:PublicKeyPath"]!);
using var rsa = RSA.Create();
rsa.ImportFromPem(publicKeyPem);
// Verify signature
var signature = Base64UrlDecode(parts[1]);
return rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
}
catch
{
return false;
}
break;
default:
return false;
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Token validation failed");
return false;
}
}
private static byte[] Base64UrlDecode(string base64Url)
{
var padded = base64Url
.Replace('-', '+')
.Replace('_', '/');
switch (padded.Length % 4)
{
case 2: padded += "=="; break;
case 3: padded += "="; break;
}
return Convert.FromBase64String(padded);
}
private TokenInfo? _ExtractToken(HttpRequest request) private TokenInfo? _ExtractToken(HttpRequest request)
{ {
// Check for token in query parameters // Check for token in query parameters

View File

@@ -0,0 +1,8 @@
using NodaTime;
namespace DysonNetwork.Pass.Auth;
public static class AuthCacheConstants
{
public const string Prefix = "auth:";
}

View File

@@ -3,8 +3,14 @@ using Microsoft.AspNetCore.Mvc;
using NodaTime; using NodaTime;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using DysonNetwork.Pass.Account; using DysonNetwork.Pass.Account;
using DysonNetwork.Pass.Localization;
using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.GeoIp; using DysonNetwork.Shared.GeoIp;
using DysonNetwork.Shared.Proto;
using Microsoft.Extensions.Localization;
using AccountAuthFactor = DysonNetwork.Pass.Account.AccountAuthFactor;
using AccountService = DysonNetwork.Pass.Account.AccountService;
using ActionLogService = DysonNetwork.Pass.Account.ActionLogService;
namespace DysonNetwork.Pass.Auth; namespace DysonNetwork.Pass.Auth;
@@ -16,22 +22,25 @@ public class AuthController(
AuthService auth, AuthService auth,
GeoIpService geo, GeoIpService geo,
ActionLogService als, ActionLogService als,
IConfiguration configuration PusherService.PusherServiceClient pusher,
IConfiguration configuration,
IStringLocalizer<NotificationResource> localizer
) : ControllerBase ) : ControllerBase
{ {
private readonly string _cookieDomain = configuration["AuthToken:CookieDomain"]!; private readonly string _cookieDomain = configuration["AuthToken:CookieDomain"]!;
public class ChallengeRequest public class ChallengeRequest
{ {
[Required] public ChallengePlatform Platform { get; set; } [Required] public ClientPlatform Platform { get; set; }
[Required] [MaxLength(256)] public string Account { get; set; } = null!; [Required] [MaxLength(256)] public string Account { get; set; } = null!;
[Required] [MaxLength(512)] public string DeviceId { get; set; } = null!; [Required] [MaxLength(512)] public string DeviceId { get; set; } = null!;
[MaxLength(1024)] public string? DeviceName { get; set; }
public List<string> Audiences { get; set; } = new(); public List<string> Audiences { get; set; } = new();
public List<string> Scopes { get; set; } = new(); public List<string> Scopes { get; set; } = new();
} }
[HttpPost("challenge")] [HttpPost("challenge")]
public async Task<ActionResult<AuthChallenge>> StartChallenge([FromBody] ChallengeRequest request) public async Task<ActionResult<AuthChallenge>> CreateChallenge([FromBody] ChallengeRequest request)
{ {
var account = await accounts.LookupAccount(request.Account); var account = await accounts.LookupAccount(request.Account);
if (account is null) return NotFound("Account was not found."); if (account is null) return NotFound("Account was not found.");
@@ -42,11 +51,20 @@ public class AuthController(
.Where(e => e.Type == PunishmentType.BlockLogin || e.Type == PunishmentType.DisableAccount) .Where(e => e.Type == PunishmentType.BlockLogin || e.Type == PunishmentType.DisableAccount)
.Where(e => e.ExpiredAt == null || now < e.ExpiredAt) .Where(e => e.ExpiredAt == null || now < e.ExpiredAt)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (punishment is not null) return StatusCode(423, punishment); if (punishment is not null)
return StatusCode(
423,
$"Your account has been suspended. Reason: {punishment.Reason}. Expired at: {punishment.ExpiredAt?.ToString() ?? "never"}"
);
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString(); var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
var userAgent = HttpContext.Request.Headers.UserAgent.ToString(); var userAgent = HttpContext.Request.Headers.UserAgent.ToString();
request.DeviceName ??= userAgent;
var device =
await auth.GetOrCreateDeviceAsync(account.Id, request.DeviceId, request.DeviceName, request.Platform);
// Trying to pick up challenges from the same IP address and user agent // Trying to pick up challenges from the same IP address and user agent
var existingChallenge = await db.AuthChallenges var existingChallenge = await db.AuthChallenges
.Where(e => e.AccountId == account.Id) .Where(e => e.AccountId == account.Id)
@@ -54,21 +72,26 @@ public class AuthController(
.Where(e => e.UserAgent == userAgent) .Where(e => e.UserAgent == userAgent)
.Where(e => e.StepRemain > 0) .Where(e => e.StepRemain > 0)
.Where(e => e.ExpiredAt != null && now < e.ExpiredAt) .Where(e => e.ExpiredAt != null && now < e.ExpiredAt)
.Where(e => e.Type == ChallengeType.Login)
.Where(e => e.ClientId == device.Id)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (existingChallenge is not null) return existingChallenge; if (existingChallenge is not null)
{
var existingSession = await db.AuthSessions.Where(e => e.ChallengeId == existingChallenge.Id)
.FirstOrDefaultAsync();
if (existingSession is null) return existingChallenge;
}
var device = await auth.GetOrCreateDeviceAsync(account.Id, request.DeviceId);
var challenge = new AuthChallenge var challenge = new AuthChallenge
{ {
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddHours(1)), ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddHours(1)),
StepTotal = await auth.DetectChallengeRisk(Request, account), StepTotal = await auth.DetectChallengeRisk(Request, account),
Platform = request.Platform,
Audiences = request.Audiences, Audiences = request.Audiences,
Scopes = request.Scopes, Scopes = request.Scopes,
IpAddress = ipAddress, IpAddress = ipAddress,
UserAgent = userAgent, UserAgent = userAgent,
Location = geo.GetPointFromIp(ipAddress), Location = geo.GetPointFromIp(ipAddress),
DeviceId = device.Id, ClientId = device.Id,
AccountId = account.Id AccountId = account.Id
}.Normalize(); }.Normalize();
@@ -111,8 +134,7 @@ public class AuthController(
[HttpPost("challenge/{id:guid}/factors/{factorId:guid}")] [HttpPost("challenge/{id:guid}/factors/{factorId:guid}")]
public async Task<ActionResult> RequestFactorCode( public async Task<ActionResult> RequestFactorCode(
[FromRoute] Guid id, [FromRoute] Guid id,
[FromRoute] Guid factorId, [FromRoute] Guid factorId
[FromBody] string? hint
) )
{ {
var challenge = await db.AuthChallenges var challenge = await db.AuthChallenges
@@ -126,7 +148,7 @@ public class AuthController(
try try
{ {
await accounts.SendFactorCode(challenge.Account, factor, hint); await accounts.SendFactorCode(challenge.Account, factor);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -148,18 +170,29 @@ public class AuthController(
[FromBody] PerformChallengeRequest request [FromBody] PerformChallengeRequest request
) )
{ {
var challenge = await db.AuthChallenges.Include(e => e.Account).FirstOrDefaultAsync(e => e.Id == id); var challenge = await db.AuthChallenges
.Include(e => e.Account)
.Include(authChallenge => authChallenge.Client)
.FirstOrDefaultAsync(e => e.Id == id);
if (challenge is null) return NotFound("Auth challenge was not found."); if (challenge is null) return NotFound("Auth challenge was not found.");
var factor = await db.AccountAuthFactors.FindAsync(request.FactorId); var factor = await db.AccountAuthFactors
.Where(f => f.Id == request.FactorId)
.Where(f => f.AccountId == challenge.AccountId)
.FirstOrDefaultAsync();
if (factor is null) return NotFound("Auth factor was not found."); if (factor is null) return NotFound("Auth factor was not found.");
if (factor.EnabledAt is null) return BadRequest("Auth factor is not enabled."); if (factor.EnabledAt is null) return BadRequest("Auth factor is not enabled.");
if (factor.Trustworthy <= 0) return BadRequest("Auth factor is not trustworthy."); if (factor.Trustworthy <= 0) return BadRequest("Auth factor is not trustworthy.");
if (challenge.StepRemain == 0) return challenge; if (challenge.StepRemain == 0) return challenge;
if (challenge.ExpiredAt.HasValue && challenge.ExpiredAt.Value < Instant.FromDateTimeUtc(DateTime.UtcNow)) var now = SystemClock.Instance.GetCurrentInstant();
if (challenge.ExpiredAt.HasValue && now > challenge.ExpiredAt.Value)
return BadRequest(); return BadRequest();
// prevent reusing the same factor in one challenge
if (challenge.BlacklistFactors.Contains(factor.Id))
return BadRequest("Auth factor already used.");
try try
{ {
if (await accounts.VerifyFactorCode(factor, request.Password)) if (await accounts.VerifyFactorCode(factor, request.Password))
@@ -198,6 +231,19 @@ public class AuthController(
if (challenge.StepRemain == 0) if (challenge.StepRemain == 0)
{ {
AccountService.SetCultureInfo(challenge.Account);
await pusher.SendPushNotificationToUserAsync(new SendPushNotificationToUserRequest
{
Notification = new PushNotification()
{
Topic = "auth.login",
Title = localizer["NewLoginTitle"],
Body = localizer["NewLoginBody", challenge.Client?.DeviceName ?? "unknown",
challenge.IpAddress ?? "unknown"],
IsSavable = true
},
UserId = challenge.AccountId.ToString()
});
als.CreateActionLogFromRequest(ActionLogType.NewLogin, als.CreateActionLogFromRequest(ActionLogType.NewLogin,
new Dictionary<string, object> new Dictionary<string, object>
{ {
@@ -238,37 +284,15 @@ public class AuthController(
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (challenge is null) if (challenge is null)
return BadRequest("Authorization code not found or expired."); return BadRequest("Authorization code not found or expired.");
if (challenge.StepRemain != 0) try
return BadRequest("Challenge not yet completed.");
var session = await db.AuthSessions
.Where(e => e.Challenge == challenge)
.FirstOrDefaultAsync();
if (session is not null)
return BadRequest("Session already exists for this challenge.");
session = new AuthSession
{ {
LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow), var tk = await auth.CreateSessionAndIssueToken(challenge);
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(30)),
Account = challenge.Account,
Challenge = challenge,
};
db.AuthSessions.Add(session);
await db.SaveChangesAsync();
var tk = auth.CreateToken(session);
Response.Cookies.Append(AuthConstants.CookieTokenName, tk, new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Lax,
Domain = _cookieDomain,
Expires = DateTime.UtcNow.AddDays(30)
});
return Ok(new TokenExchangeResponse { Token = tk }); return Ok(new TokenExchangeResponse { Token = tk });
}
catch (ArgumentException ex)
{
return BadRequest(ex.Message);
}
default: default:
// Since we no longer need the refresh token // Since we no longer need the refresh token
// This case is blank for now, thinking to mock it if the OIDC standard requires it // This case is blank for now, thinking to mock it if the OIDC standard requires it

View File

@@ -1,17 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using DysonNetwork.Shared.Data;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Pass.Auth;
[Index(nameof(DeviceId), IsUnique = true)]
public class AuthDevice : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(1024)] public string DeviceName { get; set; } = string.Empty;
[MaxLength(1024)] public string DeviceId { get; set; } = string.Empty;
public Guid AccountId { get; set; }
[JsonIgnore] public Account.Account Account { get; set; } = null!;
}

View File

@@ -12,10 +12,12 @@ public class AuthService(
IConfiguration config, IConfiguration config,
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
IHttpContextAccessor httpContextAccessor, IHttpContextAccessor httpContextAccessor,
ICacheService cache ICacheService cache,
ILogger<AuthService> logger
) )
{ {
private HttpContext HttpContext => httpContextAccessor.HttpContext!; private HttpContext HttpContext => httpContextAccessor.HttpContext!;
public const string AuthCachePrefix = "auth:";
/// <summary> /// <summary>
/// Detect the risk of the current request to login /// Detect the risk of the current request to login
@@ -50,7 +52,7 @@ public class AuthService(
riskScore += 1; riskScore += 1;
else else
{ {
if (!string.IsNullOrEmpty(lastActiveInfo?.Challenge.IpAddress) && if (!string.IsNullOrEmpty(lastActiveInfo?.Challenge?.IpAddress) &&
!lastActiveInfo.Challenge.IpAddress.Equals(ipAddress, StringComparison.OrdinalIgnoreCase)) !lastActiveInfo.Challenge.IpAddress.Equals(ipAddress, StringComparison.OrdinalIgnoreCase))
riskScore += 1; riskScore += 1;
} }
@@ -73,7 +75,8 @@ public class AuthService(
return totalRequiredSteps; return totalRequiredSteps;
} }
public async Task<AuthSession> CreateSessionForOidcAsync(Account.Account account, Instant time, Guid? customAppId = null) public async Task<AuthSession> CreateSessionForOidcAsync(Account.Account account, Instant time,
Guid? customAppId = null)
{ {
var challenge = new AuthChallenge var challenge = new AuthChallenge
{ {
@@ -101,16 +104,23 @@ public class AuthService(
return session; return session;
} }
public async Task<AuthDevice> GetOrCreateDeviceAsync(Guid accountId, string deviceId) public async Task<AuthClient> GetOrCreateDeviceAsync(
Guid accountId,
string deviceId,
string? deviceName = null,
ClientPlatform platform = ClientPlatform.Unidentified
)
{ {
var device = await db.AuthDevices.FirstOrDefaultAsync(d => d.DeviceId == deviceId && d.AccountId == accountId); var device = await db.AuthClients.FirstOrDefaultAsync(d => d.DeviceId == deviceId && d.AccountId == accountId);
if (device is not null) return device; if (device is not null) return device;
device = new AuthDevice device = new AuthClient
{ {
Platform = platform,
DeviceId = deviceId, DeviceId = deviceId,
AccountId = accountId AccountId = accountId
}; };
db.AuthDevices.Add(device); if (deviceName is not null) device.DeviceName = deviceName;
db.AuthClients.Add(device);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return device; return device;
@@ -180,6 +190,53 @@ public class AuthService(
return CreateCompactToken(session.Id, rsa); return CreateCompactToken(session.Id, rsa);
} }
/// <summary>
/// Create a session for a completed challenge, persist it, issue a token, and set the auth cookie.
/// Keeps behavior identical to previous controller implementation.
/// </summary>
/// <param name="challenge">Completed challenge</param>
/// <returns>Signed compact token</returns>
/// <exception cref="ArgumentException">If challenge not completed or session already exists</exception>
public async Task<string> CreateSessionAndIssueToken(AuthChallenge challenge)
{
if (challenge.StepRemain != 0)
throw new ArgumentException("Challenge not yet completed.");
var hasSession = await db.AuthSessions
.AnyAsync(e => e.ChallengeId == challenge.Id);
if (hasSession)
throw new ArgumentException("Session already exists for this challenge.");
var now = SystemClock.Instance.GetCurrentInstant();
var session = new AuthSession
{
LastGrantedAt = now,
// Never expire server-side
ExpiredAt = null,
AccountId = challenge.AccountId,
ChallengeId = challenge.Id
};
db.AuthSessions.Add(session);
await db.SaveChangesAsync();
var tk = CreateToken(session);
// Set cookie using HttpContext
var cookieDomain = config["AuthToken:CookieDomain"]!;
HttpContext.Response.Cookies.Append(AuthConstants.CookieTokenName, tk, new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Lax,
Domain = cookieDomain,
// Effectively never expire client-side (20 years)
Expires = DateTime.UtcNow.AddYears(20)
});
return tk;
}
private string CreateCompactToken(Guid sessionId, RSA rsa) private string CreateCompactToken(Guid sessionId, RSA rsa)
{ {
// Create the payload: just the session ID // Create the payload: just the session ID
@@ -261,35 +318,84 @@ public class AuthService(
return factor.VerifyPassword(pinCode); return factor.VerifyPassword(pinCode);
} }
public bool ValidateToken(string token, out Guid sessionId) public async Task<ApiKey?> GetApiKey(Guid id, Guid? accountId = null)
{ {
sessionId = Guid.Empty; 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 try
{ {
// Split the token var oldSessionId = key.SessionId;
var parts = token.Split('.');
if (parts.Length != 2)
return false;
// Decode the payload // Create new session
var payloadBytes = Base64UrlDecode(parts[0]); var newSession = new AuthSession
{
AccountId = key.AccountId,
ExpiredAt = key.Session?.ExpiredAt
};
// Extract session ID db.AuthSessions.Add(newSession);
sessionId = new Guid(payloadBytes); await db.SaveChangesAsync();
// Load public key for verification // Update ApiKey to point to new session
var publicKeyPem = File.ReadAllText(config["AuthToken:PublicKeyPath"]!); key.SessionId = newSession.Id;
using var rsa = RSA.Create(); key.Session = newSession;
rsa.ImportFromPem(publicKeyPem); db.ApiKeys.Update(key);
await db.SaveChangesAsync();
// Verify signature // Delete old session
var signature = Base64UrlDecode(parts[1]); await db.AuthSessions.Where(s => s.Id == oldSessionId).ExecuteDeleteAsync();
return rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
await transaction.CommitAsync();
return key;
} }
catch catch
{ {
return false; await transaction.RollbackAsync();
throw;
} }
} }
@@ -304,7 +410,7 @@ public class AuthService(
private static byte[] Base64UrlDecode(string base64Url) private static byte[] Base64UrlDecode(string base64Url)
{ {
string padded = base64Url var padded = base64Url
.Replace('-', '+') .Replace('-', '+')
.Replace('_', '/'); .Replace('_', '/');

View File

@@ -1,17 +1,11 @@
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using Grpc.Core; using Grpc.Core;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Pass.Auth; namespace DysonNetwork.Pass.Auth;
public class AuthServiceGrpc( public class AuthServiceGrpc(
AuthService authService, TokenAuthService token,
SubscriptionService subscriptions, AuthService auth
ICacheService cache,
AppDatabase db
) )
: Shared.Proto.AuthService.AuthServiceBase : Shared.Proto.AuthService.AuthServiceBase
{ {
@@ -20,34 +14,9 @@ public class AuthServiceGrpc(
ServerCallContext context ServerCallContext context
) )
{ {
if (!authService.ValidateToken(request.Token, out var sessionId)) var (valid, session, message) = await token.AuthenticateTokenAsync(request.Token, request.IpAddress);
return new AuthenticateResponse { Valid = false, Message = "Invalid token." }; if (!valid || session is null)
return new AuthenticateResponse { Valid = false, Message = message ?? "Authentication failed." };
var session = await cache.GetAsync<AuthSession>($"{DysonTokenAuthHandler.AuthCachePrefix}{sessionId}");
if (session is not null)
return new AuthenticateResponse { Valid = true, Session = session.ToProtoValue() };
session = await db.AuthSessions
.AsNoTracking()
.Include(e => e.Challenge)
.Include(e => e.Account)
.ThenInclude(e => e.Profile)
.FirstOrDefaultAsync(s => s.Id == sessionId);
if (session == null)
return new AuthenticateResponse { Valid = false, Message = "Session was not found." };
var now = SystemClock.Instance.GetCurrentInstant();
if (session.ExpiredAt.HasValue && session.ExpiredAt < now)
return new AuthenticateResponse { Valid = false, Message = "Session has been expired." };
var perk = await subscriptions.GetPerkSubscriptionAsync(session.AccountId);
session.Account.PerkSubscription = perk?.ToReference();
await cache.SetWithGroupsAsync(
$"auth:{sessionId}",
session,
[$"{Account.AccountService.AccountCachePrefix}{session.Account.Id}"],
TimeSpan.FromHours(1)
);
return new AuthenticateResponse { Valid = true, Session = session.ToProtoValue() }; return new AuthenticateResponse { Valid = true, Session = session.ToProtoValue() };
} }
@@ -55,13 +24,13 @@ public class AuthServiceGrpc(
public override async Task<ValidateResponse> ValidatePin(ValidatePinRequest request, ServerCallContext context) public override async Task<ValidateResponse> ValidatePin(ValidatePinRequest request, ServerCallContext context)
{ {
var accountId = Guid.Parse(request.AccountId); var accountId = Guid.Parse(request.AccountId);
var valid = await authService.ValidatePinCode(accountId, request.Pin); var valid = await auth.ValidatePinCode(accountId, request.Pin);
return new ValidateResponse { Valid = valid }; return new ValidateResponse { Valid = valid };
} }
public override async Task<ValidateResponse> ValidateCaptcha(ValidateCaptchaRequest request, ServerCallContext context) public override async Task<ValidateResponse> ValidateCaptcha(ValidateCaptchaRequest request, ServerCallContext context)
{ {
var valid = await authService.ValidateCaptcha(request.Token); var valid = await auth.ValidateCaptcha(request.Token);
return new ValidateResponse { Valid = valid }; return new ValidateResponse { Valid = valid };
} }
} }

View File

@@ -11,26 +11,28 @@ namespace DysonNetwork.Pass.Auth;
public class AuthSession : ModelBase public class AuthSession : ModelBase
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(1024)] public string? Label { get; set; }
public Instant? LastGrantedAt { get; set; } public Instant? LastGrantedAt { get; set; }
public Instant? ExpiredAt { get; set; } public Instant? ExpiredAt { get; set; }
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
[JsonIgnore] public Account.Account Account { get; set; } = null!; [JsonIgnore] public Account.Account Account { get; set; } = null!;
public Guid ChallengeId { get; set; }
public AuthChallenge Challenge { get; set; } = null!; // When the challenge is null, indicates the session is for an API Key
public Guid? ChallengeId { get; set; }
public AuthChallenge? Challenge { get; set; } = null!;
// Indicates the session is for an OIDC connection
public Guid? AppId { get; set; } public Guid? AppId { get; set; }
public Shared.Proto.AuthSession ToProtoValue() => new() public Shared.Proto.AuthSession ToProtoValue() => new()
{ {
Id = Id.ToString(), Id = Id.ToString(),
Label = Label,
LastGrantedAt = LastGrantedAt?.ToTimestamp(), LastGrantedAt = LastGrantedAt?.ToTimestamp(),
ExpiredAt = ExpiredAt?.ToTimestamp(), ExpiredAt = ExpiredAt?.ToTimestamp(),
AccountId = AccountId.ToString(), AccountId = AccountId.ToString(),
Account = Account.ToProtoValue(), Account = Account.ToProtoValue(),
ChallengeId = ChallengeId.ToString(), ChallengeId = ChallengeId.ToString(),
Challenge = Challenge.ToProtoValue(), Challenge = Challenge?.ToProtoValue(),
AppId = AppId?.ToString() AppId = AppId?.ToString()
}; };
} }
@@ -42,7 +44,7 @@ public enum ChallengeType
Oidc // Trying to connect other platforms Oidc // Trying to connect other platforms
} }
public enum ChallengePlatform public enum ClientPlatform
{ {
Unidentified, Unidentified,
Web, Web,
@@ -60,7 +62,6 @@ public class AuthChallenge : ModelBase
public int StepRemain { get; set; } public int StepRemain { get; set; }
public int StepTotal { get; set; } public int StepTotal { get; set; }
public int FailedAttempts { get; set; } public int FailedAttempts { get; set; }
public ChallengePlatform Platform { get; set; } = ChallengePlatform.Unidentified;
public ChallengeType Type { get; set; } = ChallengeType.Login; public ChallengeType Type { get; set; } = ChallengeType.Login;
[Column(TypeName = "jsonb")] public List<Guid> BlacklistFactors { get; set; } = new(); [Column(TypeName = "jsonb")] public List<Guid> BlacklistFactors { get; set; } = new();
[Column(TypeName = "jsonb")] public List<string> Audiences { get; set; } = new(); [Column(TypeName = "jsonb")] public List<string> Audiences { get; set; } = new();
@@ -72,8 +73,8 @@ public class AuthChallenge : ModelBase
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
[JsonIgnore] public Account.Account Account { get; set; } = null!; [JsonIgnore] public Account.Account Account { get; set; } = null!;
public Guid DeviceId { get; set; } public Guid? ClientId { get; set; }
public AuthDevice Device { get; set; } = null!; public AuthClient? Client { get; set; } = null!;
public AuthChallenge Normalize() public AuthChallenge Normalize()
{ {
@@ -88,15 +89,44 @@ public class AuthChallenge : ModelBase
StepRemain = StepRemain, StepRemain = StepRemain,
StepTotal = StepTotal, StepTotal = StepTotal,
FailedAttempts = FailedAttempts, FailedAttempts = FailedAttempts,
Platform = (Shared.Proto.ChallengePlatform)Platform,
Type = (Shared.Proto.ChallengeType)Type, Type = (Shared.Proto.ChallengeType)Type,
BlacklistFactors = { BlacklistFactors.Select(x => x.ToString()) }, BlacklistFactors = { BlacklistFactors.Select(x => x.ToString()) },
Audiences = { Audiences }, Audiences = { Audiences },
Scopes = { Scopes }, Scopes = { Scopes },
IpAddress = IpAddress, IpAddress = IpAddress,
UserAgent = UserAgent, UserAgent = UserAgent,
DeviceId = DeviceId.ToString(), DeviceId = Client!.DeviceId,
Nonce = Nonce, Nonce = Nonce,
AccountId = AccountId.ToString() AccountId = AccountId.ToString()
}; };
} }
public class AuthClient : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
public ClientPlatform Platform { get; set; } = ClientPlatform.Unidentified;
[MaxLength(1024)] public string DeviceName { get; set; } = string.Empty;
[MaxLength(1024)] public string? DeviceLabel { get; set; }
[MaxLength(1024)] public string DeviceId { get; set; } = string.Empty;
public Guid AccountId { get; set; }
[JsonIgnore] public Account.Account Account { get; set; } = null!;
}
public class AuthClientWithChallenge : AuthClient
{
public List<AuthChallenge> Challenges { get; set; } = [];
public static AuthClientWithChallenge FromClient(AuthClient client)
{
return new AuthClientWithChallenge
{
Id = client.Id,
Platform = client.Platform,
DeviceName = client.DeviceName,
DeviceLabel = client.DeviceLabel,
DeviceId = client.DeviceId,
AccountId = client.AccountId,
};
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ public class AppleMobileConnectRequest
public class AppleMobileSignInRequest : AppleMobileConnectRequest public class AppleMobileSignInRequest : AppleMobileConnectRequest
{ {
[Required] [Required] [MaxLength(512)]
public required string DeviceId { get; set; } public required string DeviceId { get; set; }
[MaxLength(1024)] public string? DeviceName { get; set; }
} }

View File

@@ -96,7 +96,8 @@ public class OidcController(
userInfo, userInfo,
account, account,
HttpContext, HttpContext,
request.DeviceId request.DeviceId,
request.DeviceName
); );
return Ok(challenge); return Ok(challenge);

View File

@@ -191,7 +191,8 @@ public abstract class OidcService(
OidcUserInfo userInfo, OidcUserInfo userInfo,
Account.Account account, Account.Account account,
HttpContext request, HttpContext request,
string deviceId string deviceId,
string? deviceName = null
) )
{ {
// Create or update the account connection // Create or update the account connection
@@ -217,17 +218,16 @@ public abstract class OidcService(
// Create a challenge that's already completed // Create a challenge that's already completed
var now = SystemClock.Instance.GetCurrentInstant(); var now = SystemClock.Instance.GetCurrentInstant();
var device = await auth.GetOrCreateDeviceAsync(account.Id, deviceId); var device = await auth.GetOrCreateDeviceAsync(account.Id, deviceId, deviceName, ClientPlatform.Ios);
var challenge = new AuthChallenge var challenge = new AuthChallenge
{ {
ExpiredAt = now.Plus(Duration.FromHours(1)), ExpiredAt = now.Plus(Duration.FromHours(1)),
StepTotal = await auth.DetectChallengeRisk(request.Request, account), StepTotal = await auth.DetectChallengeRisk(request.Request, account),
Type = ChallengeType.Oidc, Type = ChallengeType.Oidc,
Platform = ChallengePlatform.Unidentified,
Audiences = [ProviderName], Audiences = [ProviderName],
Scopes = ["*"], Scopes = ["*"],
AccountId = account.Id, AccountId = account.Id,
DeviceId = device.Id, ClientId = device.Id,
IpAddress = request.Connection.RemoteIpAddress?.ToString() ?? null, IpAddress = request.Connection.RemoteIpAddress?.ToString() ?? null,
UserAgent = request.Request.Headers.UserAgent, UserAgent = request.Request.Headers.UserAgent,
}; };

View File

@@ -0,0 +1,213 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Cryptography;
using System.Text;
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Cache;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Pass.Auth;
public class TokenAuthService(
AppDatabase db,
IConfiguration config,
ICacheService cache,
ILogger<TokenAuthService> logger,
OidcProvider.Services.OidcProviderService oidc,
SubscriptionService subscriptions
)
{
/// <summary>
/// Universal authenticate method: validate token (JWT or compact),
/// load session from cache/DB, check expiry, enrich with subscription,
/// 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, string? ipAddress = null)
{
try
{
if (string.IsNullOrWhiteSpace(token))
{
logger.LogWarning("AuthenticateTokenAsync: no token provided");
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];
var partsLen = token.Split('.').Length;
var format = partsLen switch
{
3 => "JWT",
2 => "Compact",
_ => "Unknown"
};
logger.LogDebug("AuthenticateTokenAsync: token format detected: {Format} (fp={TokenFp})", format, tokenFp);
if (!ValidateToken(token, out var sessionId))
{
logger.LogWarning("AuthenticateTokenAsync: token validation failed (format={Format}, fp={TokenFp})", format, tokenFp);
return (false, null, "Invalid token.");
}
logger.LogDebug("AuthenticateTokenAsync: token validated, sessionId={SessionId} (fp={TokenFp})", sessionId, tokenFp);
// Try cache first
var cacheKey = $"{AuthCacheConstants.Prefix}{sessionId}";
var session = await cache.GetAsync<AuthSession>(cacheKey);
if (session is not null)
{
logger.LogDebug("AuthenticateTokenAsync: cache hit for {CacheKey}", cacheKey);
var nowHit = SystemClock.Instance.GetCurrentInstant();
if (session.ExpiredAt.HasValue && session.ExpiredAt < nowHit)
{
logger.LogWarning("AuthenticateTokenAsync: cached session expired (sessionId={SessionId})", sessionId);
return (false, null, "Session has been expired.");
}
logger.LogInformation(
"AuthenticateTokenAsync: success via cache (sessionId={SessionId}, accountId={AccountId}, scopes={ScopeCount}, expiresAt={ExpiresAt})",
sessionId,
session.AccountId,
session.Challenge?.Scopes.Count,
session.ExpiredAt
);
return (true, session, null);
}
logger.LogDebug("AuthenticateTokenAsync: cache miss for {CacheKey}, loading from DB", cacheKey);
session = await db.AuthSessions
.AsNoTracking()
.Include(e => e.Challenge)
.ThenInclude(e => e.Client)
.Include(e => e.Account)
.ThenInclude(e => e.Profile)
.FirstOrDefaultAsync(s => s.Id == sessionId);
if (session is null)
{
logger.LogWarning("AuthenticateTokenAsync: session not found (sessionId={SessionId})", sessionId);
return (false, null, "Session was not found.");
}
var now = SystemClock.Instance.GetCurrentInstant();
if (session.ExpiredAt.HasValue && session.ExpiredAt < now)
{
logger.LogWarning("AuthenticateTokenAsync: session expired (sessionId={SessionId}, expiredAt={ExpiredAt}, now={Now})", sessionId, session.ExpiredAt, now);
return (false, null, "Session has been expired.");
}
logger.LogInformation(
"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.AppId,
session.Challenge?.Scopes.Count,
session.Challenge?.IpAddress,
(session.Challenge?.UserAgent ?? string.Empty).Length
);
logger.LogDebug("AuthenticateTokenAsync: enriching account with subscription (accountId={AccountId})", session.AccountId);
var perk = await subscriptions.GetPerkSubscriptionAsync(session.AccountId);
session.Account.PerkSubscription = perk?.ToReference();
logger.LogInformation(
"AuthenticateTokenAsync: subscription attached (accountId={AccountId}, hasPerk={HasPerk}, identifier={Identifier}, status={Status}, available={Available})",
session.AccountId,
perk is not null,
perk?.Identifier,
perk?.Status,
perk?.IsAvailable
);
await cache.SetWithGroupsAsync(
cacheKey,
session,
[$"{AuthCacheConstants.Prefix}{session.Account.Id}"],
TimeSpan.FromHours(1)
);
logger.LogDebug("AuthenticateTokenAsync: cached session with key {CacheKey} (groups=[{GroupKey}])",
cacheKey,
$"{AuthCacheConstants.Prefix}{session.Account.Id}");
logger.LogInformation(
"AuthenticateTokenAsync: success via DB (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId})",
sessionId,
session.AccountId,
session.Challenge?.ClientId
);
return (true, session, null);
}
catch (Exception ex)
{
logger.LogError(ex, "AuthenticateTokenAsync: unexpected error");
return (false, null, "Authentication error.");
}
}
public bool ValidateToken(string token, out Guid sessionId)
{
sessionId = Guid.Empty;
try
{
var parts = token.Split('.');
switch (parts.Length)
{
case 3:
{
// JWT via OIDC
var (isValid, jwtResult) = oidc.ValidateToken(token);
if (!isValid) return false;
var jti = jwtResult?.Claims.FirstOrDefault(c => c.Type == "jti")?.Value;
if (jti is null) return false;
return Guid.TryParse(jti, out sessionId);
}
case 2:
{
// Compact token
var payloadBytes = Base64UrlDecode(parts[0]);
sessionId = new Guid(payloadBytes);
var publicKeyPem = File.ReadAllText(config["AuthToken:PublicKeyPath"]!);
using var rsa = RSA.Create();
rsa.ImportFromPem(publicKeyPem);
var signature = Base64UrlDecode(parts[1]);
return rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
}
default:
return false;
}
}
catch
{
return false;
}
}
private static byte[] Base64UrlDecode(string base64Url)
{
var padded = base64Url
.Replace('-', '+')
.Replace('_', '/');
switch (padded.Length % 4)
{
case 2: padded += "=="; break;
case 3: padded += "="; break;
}
return Convert.FromBase64String(padded);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="NATS.Client.Core" Version="2.6.6" />
<PackageReference Include="Nerdbank.GitVersioning" Version="3.7.115"> <PackageReference Include="Nerdbank.GitVersioning" Version="3.7.115">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
@@ -142,13 +143,6 @@
<AdditionalFiles Include="Pages\Emails\LandingEmail.razor" /> <AdditionalFiles Include="Pages\Emails\LandingEmail.razor" />
<AdditionalFiles Include="Pages\Emails\PasswordResetEmail.razor" /> <AdditionalFiles Include="Pages\Emails\PasswordResetEmail.razor" />
<AdditionalFiles Include="Pages\Emails\VerificationEmail.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"/>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -1,6 +1,7 @@
using DysonNetwork.Pass.Account; using DysonNetwork.Pass.Account;
using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Cache;
using EFCore.BulkExtensions; using EFCore.BulkExtensions;
using NodaTime;
using Quartz; using Quartz;
namespace DysonNetwork.Pass.Handlers; namespace DysonNetwork.Pass.Handlers;
@@ -12,7 +13,12 @@ public class ActionLogFlushHandler(IServiceProvider serviceProvider) : IFlushHan
using var scope = serviceProvider.CreateScope(); using var scope = serviceProvider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>(); var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
await db.BulkInsertAsync(items, config => config.ConflictOption = ConflictOption.Ignore); await db.BulkInsertAsync(items.Select(x =>
{
x.CreatedAt = SystemClock.Instance.GetCurrentInstant();
x.UpdatedAt = x.CreatedAt;
return x;
}), config => config.ConflictOption = ConflictOption.Ignore);
} }
} }

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,110 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class AddAuthorizeDevice : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "device_id",
table: "auth_challenges",
type: "character varying(1024)",
maxLength: 1024,
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(256)",
oldMaxLength: 256,
oldNullable: true);
migrationBuilder.AddColumn<Guid>(
name: "client_id",
table: "auth_challenges",
type: "uuid",
nullable: true);
migrationBuilder.CreateTable(
name: "auth_clients",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
device_name = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
device_label = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
device_id = table.Column<string>(type: "character varying(1024)", maxLength: 1024, 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_auth_clients", x => x.id);
table.ForeignKey(
name: "fk_auth_clients_accounts_account_id",
column: x => x.account_id,
principalTable: "accounts",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_auth_challenges_client_id",
table: "auth_challenges",
column: "client_id");
migrationBuilder.CreateIndex(
name: "ix_auth_clients_account_id",
table: "auth_clients",
column: "account_id");
migrationBuilder.CreateIndex(
name: "ix_auth_clients_device_id",
table: "auth_clients",
column: "device_id",
unique: true);
migrationBuilder.AddForeignKey(
name: "fk_auth_challenges_auth_clients_client_id",
table: "auth_challenges",
column: "client_id",
principalTable: "auth_clients",
principalColumn: "id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_auth_challenges_auth_clients_client_id",
table: "auth_challenges");
migrationBuilder.DropTable(
name: "auth_clients");
migrationBuilder.DropIndex(
name: "ix_auth_challenges_client_id",
table: "auth_challenges");
migrationBuilder.DropColumn(
name: "client_id",
table: "auth_challenges");
migrationBuilder.AlterColumn<string>(
name: "device_id",
table: "auth_challenges",
type: "character varying(256)",
maxLength: 256,
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(1024)",
oldMaxLength: 1024,
oldNullable: true);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class AddAuthDevicePlatform : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "platform",
table: "auth_challenges");
migrationBuilder.AddColumn<int>(
name: "platform",
table: "auth_clients",
type: "integer",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "platform",
table: "auth_clients");
migrationBuilder.AddColumn<int>(
name: "platform",
table: "auth_challenges",
type: "integer",
nullable: false,
defaultValue: 0);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class RemoveAuthClientIndex : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "ix_auth_clients_device_id",
table: "auth_clients");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "ix_auth_clients_device_id",
table: "auth_clients",
column: "device_id",
unique: true);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class 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);
}
}
}

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -98,6 +98,10 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("activated_at"); .HasColumnName("activated_at");
b.Property<Guid?>("AutomatedId")
.HasColumnType("uuid")
.HasColumnName("automated_id");
b.Property<Instant>("CreatedAt") b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("created_at"); .HasColumnName("created_at");
@@ -435,7 +439,7 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("last_seen_at"); .HasColumnName("last_seen_at");
b.Property<Dictionary<string, string>>("Links") b.Property<List<ProfileLink>>("Links")
.HasColumnType("jsonb") .HasColumnType("jsonb")
.HasColumnName("links"); .HasColumnName("links");
@@ -458,6 +462,10 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("character varying(1024)") .HasColumnType("character varying(1024)")
.HasColumnName("pronouns"); .HasColumnName("pronouns");
b.Property<double>("SocialCredits")
.HasColumnType("double precision")
.HasColumnName("social_credits");
b.Property<string>("TimeZone") b.Property<string>("TimeZone")
.HasMaxLength(1024) .HasMaxLength(1024)
.HasColumnType("character varying(1024)") .HasColumnType("character varying(1024)")
@@ -796,6 +804,51 @@ namespace DysonNetwork.Pass.Migrations
b.ToTable("account_statuses", (string)null); b.ToTable("account_statuses", (string)null);
}); });
modelBuilder.Entity("DysonNetwork.Pass.Auth.ApiKey", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("label");
b.Property<Guid>("SessionId")
.HasColumnType("uuid")
.HasColumnName("session_id");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_api_keys");
b.HasIndex("AccountId")
.HasDatabaseName("ix_api_keys_account_id");
b.HasIndex("SessionId")
.HasDatabaseName("ix_api_keys_session_id");
b.ToTable("api_keys", (string)null);
});
modelBuilder.Entity("DysonNetwork.Pass.Auth.AuthChallenge", b => modelBuilder.Entity("DysonNetwork.Pass.Auth.AuthChallenge", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -817,6 +870,10 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("jsonb") .HasColumnType("jsonb")
.HasColumnName("blacklist_factors"); .HasColumnName("blacklist_factors");
b.Property<Guid?>("ClientId")
.HasColumnType("uuid")
.HasColumnName("client_id");
b.Property<Instant>("CreatedAt") b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("created_at"); .HasColumnName("created_at");
@@ -825,11 +882,6 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at"); .HasColumnName("deleted_at");
b.Property<string>("DeviceId")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("device_id");
b.Property<Instant?>("ExpiredAt") b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("expired_at"); .HasColumnName("expired_at");
@@ -852,10 +904,6 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("character varying(1024)") .HasColumnType("character varying(1024)")
.HasColumnName("nonce"); .HasColumnName("nonce");
b.Property<int>("Platform")
.HasColumnType("integer")
.HasColumnName("platform");
b.Property<List<string>>("Scopes") b.Property<List<string>>("Scopes")
.IsRequired() .IsRequired()
.HasColumnType("jsonb") .HasColumnType("jsonb")
@@ -888,9 +936,65 @@ namespace DysonNetwork.Pass.Migrations
b.HasIndex("AccountId") b.HasIndex("AccountId")
.HasDatabaseName("ix_auth_challenges_account_id"); .HasDatabaseName("ix_auth_challenges_account_id");
b.HasIndex("ClientId")
.HasDatabaseName("ix_auth_challenges_client_id");
b.ToTable("auth_challenges", (string)null); b.ToTable("auth_challenges", (string)null);
}); });
modelBuilder.Entity("DysonNetwork.Pass.Auth.AuthClient", 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>("DeviceId")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("device_id");
b.Property<string>("DeviceLabel")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("device_label");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("device_name");
b.Property<int>("Platform")
.HasColumnType("integer")
.HasColumnName("platform");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_auth_clients");
b.HasIndex("AccountId")
.HasDatabaseName("ix_auth_clients_account_id");
b.ToTable("auth_clients", (string)null);
});
modelBuilder.Entity("DysonNetwork.Pass.Auth.AuthSession", b => modelBuilder.Entity("DysonNetwork.Pass.Auth.AuthSession", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -906,7 +1010,7 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("app_id"); .HasColumnName("app_id");
b.Property<Guid>("ChallengeId") b.Property<Guid?>("ChallengeId")
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("challenge_id"); .HasColumnName("challenge_id");
@@ -922,11 +1026,6 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("expired_at"); .HasColumnName("expired_at");
b.Property<string>("Label")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("label");
b.Property<Instant?>("LastGrantedAt") b.Property<Instant?>("LastGrantedAt")
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("last_granted_at"); .HasColumnName("last_granted_at");
@@ -947,6 +1046,110 @@ namespace DysonNetwork.Pass.Migrations
b.ToTable("auth_sessions", (string)null); b.ToTable("auth_sessions", (string)null);
}); });
modelBuilder.Entity("DysonNetwork.Pass.Credit.SocialCreditRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<double>("Delta")
.HasColumnType("double precision")
.HasColumnName("delta");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("reason");
b.Property<string>("ReasonType")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("reason_type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_social_credit_records");
b.HasIndex("AccountId")
.HasDatabaseName("ix_social_credit_records_account_id");
b.ToTable("social_credit_records", (string)null);
});
modelBuilder.Entity("DysonNetwork.Pass.Leveling.ExperienceRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<double>("BonusMultiplier")
.HasColumnType("double precision")
.HasColumnName("bonus_multiplier");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<long>("Delta")
.HasColumnType("bigint")
.HasColumnName("delta");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("reason");
b.Property<string>("ReasonType")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("reason_type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_experience_records");
b.HasIndex("AccountId")
.HasDatabaseName("ix_experience_records_account_id");
b.ToTable("experience_records", (string)null);
});
modelBuilder.Entity("DysonNetwork.Pass.Permission.PermissionGroup", b => modelBuilder.Entity("DysonNetwork.Pass.Permission.PermissionGroup", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -1577,6 +1780,27 @@ namespace DysonNetwork.Pass.Migrations
b.Navigation("Account"); b.Navigation("Account");
}); });
modelBuilder.Entity("DysonNetwork.Pass.Auth.ApiKey", b =>
{
b.HasOne("DysonNetwork.Pass.Account.Account", "Account")
.WithMany()
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_api_keys_accounts_account_id");
b.HasOne("DysonNetwork.Pass.Auth.AuthSession", "Session")
.WithMany()
.HasForeignKey("SessionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_api_keys_auth_sessions_session_id");
b.Navigation("Account");
b.Navigation("Session");
});
modelBuilder.Entity("DysonNetwork.Pass.Auth.AuthChallenge", b => modelBuilder.Entity("DysonNetwork.Pass.Auth.AuthChallenge", b =>
{ {
b.HasOne("DysonNetwork.Pass.Account.Account", "Account") b.HasOne("DysonNetwork.Pass.Account.Account", "Account")
@@ -1586,6 +1810,25 @@ namespace DysonNetwork.Pass.Migrations
.IsRequired() .IsRequired()
.HasConstraintName("fk_auth_challenges_accounts_account_id"); .HasConstraintName("fk_auth_challenges_accounts_account_id");
b.HasOne("DysonNetwork.Pass.Auth.AuthClient", "Client")
.WithMany()
.HasForeignKey("ClientId")
.HasConstraintName("fk_auth_challenges_auth_clients_client_id");
b.Navigation("Account");
b.Navigation("Client");
});
modelBuilder.Entity("DysonNetwork.Pass.Auth.AuthClient", b =>
{
b.HasOne("DysonNetwork.Pass.Account.Account", "Account")
.WithMany()
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_auth_clients_accounts_account_id");
b.Navigation("Account"); b.Navigation("Account");
}); });
@@ -1601,8 +1844,6 @@ namespace DysonNetwork.Pass.Migrations
b.HasOne("DysonNetwork.Pass.Auth.AuthChallenge", "Challenge") b.HasOne("DysonNetwork.Pass.Auth.AuthChallenge", "Challenge")
.WithMany() .WithMany()
.HasForeignKey("ChallengeId") .HasForeignKey("ChallengeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_auth_sessions_auth_challenges_challenge_id"); .HasConstraintName("fk_auth_sessions_auth_challenges_challenge_id");
b.Navigation("Account"); b.Navigation("Account");
@@ -1610,6 +1851,30 @@ namespace DysonNetwork.Pass.Migrations
b.Navigation("Challenge"); b.Navigation("Challenge");
}); });
modelBuilder.Entity("DysonNetwork.Pass.Credit.SocialCreditRecord", b =>
{
b.HasOne("DysonNetwork.Pass.Account.Account", "Account")
.WithMany()
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_social_credit_records_accounts_account_id");
b.Navigation("Account");
});
modelBuilder.Entity("DysonNetwork.Pass.Leveling.ExperienceRecord", b =>
{
b.HasOne("DysonNetwork.Pass.Account.Account", "Account")
.WithMany()
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_experience_records_accounts_account_id");
b.Navigation("Account");
});
modelBuilder.Entity("DysonNetwork.Pass.Permission.PermissionGroupMember", b => modelBuilder.Entity("DysonNetwork.Pass.Permission.PermissionGroupMember", b =>
{ {
b.HasOne("DysonNetwork.Pass.Permission.PermissionGroup", "Group") b.HasOne("DysonNetwork.Pass.Permission.PermissionGroup", "Group")

View File

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

View File

@@ -183,4 +183,16 @@
<data name="FortuneTipNegativeContent_14" xml:space="preserve"> <data name="FortuneTipNegativeContent_14" xml:space="preserve">
<value>yummy screen meow</value> <value>yummy screen meow</value>
</data> </data>
<data name="FortuneTipNegativeTitle_15" xml:space="preserve">
<value>Washing film</value>
<comment/>
</data>
<data name="FortuneTipPositiveContent_15" xml:space="preserve">
<value>0 waterlogging</value>
<comment/>
</data>
<data name="FortuneTipNegativeContent_15" xml:space="preserve">
<value>“Why is there still something in the box“</value>
<comment/>
</data>
</root> </root>

View File

@@ -28,7 +28,7 @@ namespace DysonNetwork.Sphere.Resources.Localization {
internal static System.Resources.ResourceManager ResourceManager { internal static System.Resources.ResourceManager ResourceManager {
get { get {
if (object.Equals(null, resourceMan)) { if (object.Equals(null, resourceMan)) {
System.Resources.ResourceManager temp = new System.Resources.ResourceManager("DysonNetwork.Sphere.Resources.Localization.NotificationResource", typeof(NotificationResource).Assembly); System.Resources.ResourceManager temp = new System.Resources.ResourceManager("DysonNetwork.Pass.Resources.Localization.NotificationResource", typeof(NotificationResource).Assembly);
resourceMan = temp; resourceMan = temp;
} }
return resourceMan; return resourceMan;
@@ -158,5 +158,17 @@ namespace DysonNetwork.Sphere.Resources.Localization {
return ResourceManager.GetString("OrderPaidBody", resourceCulture); return ResourceManager.GetString("OrderPaidBody", resourceCulture);
} }
} }
internal static string NewLoginTitle {
get {
return ResourceManager.GetString("NewLoginTitle", resourceCulture);
}
}
internal static string NewLoginBody {
get {
return ResourceManager.GetString("NewLoginBody", resourceCulture);
}
}
} }
} }

View File

@@ -80,4 +80,16 @@
<data name="OrderPaidBody" xml:space="preserve"> <data name="OrderPaidBody" xml:space="preserve">
<value>{0} {1} was removed from your wallet to pay {2}</value> <value>{0} {1} was removed from your wallet to pay {2}</value>
</data> </data>
<data name="NewLoginTitle" xml:space="preserve">
<value>New login detected</value>
</data>
<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> </root>

View File

@@ -72,4 +72,16 @@
<data name="OrderPaidBody" xml:space="preserve"> <data name="OrderPaidBody" xml:space="preserve">
<value>{0} {1} 已从你的帐户中扣除来支付 {2}</value> <value>{0} {1} 已从你的帐户中扣除来支付 {2}</value>
</data> </data>
<data name="NewLoginTitle" xml:space="preserve">
<value>检测到新登陆</value>
</data>
<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> </root>

View File

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

View File

@@ -15,7 +15,9 @@ using System.Text.Json;
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
using DysonNetwork.Pass.Auth.OidcProvider.Options; using DysonNetwork.Pass.Auth.OidcProvider.Options;
using DysonNetwork.Pass.Auth.OidcProvider.Services; using DysonNetwork.Pass.Auth.OidcProvider.Services;
using DysonNetwork.Pass.Credit;
using DysonNetwork.Pass.Handlers; using DysonNetwork.Pass.Handlers;
using DysonNetwork.Pass.Leveling;
using DysonNetwork.Pass.Safety; using DysonNetwork.Pass.Safety;
using DysonNetwork.Pass.Wallet.PaymentHandlers; using DysonNetwork.Pass.Wallet.PaymentHandlers;
using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Cache;
@@ -196,12 +198,15 @@ public static class ServiceCollectionExtensions
services.AddScoped<RelationshipService>(); services.AddScoped<RelationshipService>();
services.AddScoped<MagicSpellService>(); services.AddScoped<MagicSpellService>();
services.AddScoped<AuthService>(); services.AddScoped<AuthService>();
services.AddScoped<TokenAuthService>();
services.AddScoped<AccountUsernameService>(); services.AddScoped<AccountUsernameService>();
services.AddScoped<WalletService>(); services.AddScoped<WalletService>();
services.AddScoped<SubscriptionService>(); services.AddScoped<SubscriptionService>();
services.AddScoped<PaymentService>(); services.AddScoped<PaymentService>();
services.AddScoped<AfdianPaymentHandler>(); services.AddScoped<AfdianPaymentHandler>();
services.AddScoped<SafetyService>(); services.AddScoped<SafetyService>();
services.AddScoped<SocialCreditService>();
services.AddScoped<ExperienceService>();
services.Configure<OidcProviderOptions>(configuration.GetSection("OidcProvider")); services.Configure<OidcProviderOptions>(configuration.GetSection("OidcProvider"));
services.AddScoped<OidcProviderService>(); services.AddScoped<OidcProviderService>();

View File

@@ -12,7 +12,8 @@
"ConnectionStrings": { "ConnectionStrings": {
"App": "Host=localhost;Port=5432;Database=dyson_pass;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60", "App": "Host=localhost;Port=5432;Database=dyson_pass;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60",
"FastRetrieve": "localhost:6379", "FastRetrieve": "localhost:6379",
"Etcd": "etcd.orb.local:2379" "Etcd": "etcd.orb.local:2379",
"Stream": "nats.orb.local:4222"
}, },
"Authentication": { "Authentication": {
"Schemes": { "Schemes": {

View File

@@ -16,7 +16,7 @@ public class AppDatabase(
) : DbContext(options) ) : DbContext(options)
{ {
public DbSet<Notification.Notification> Notifications { get; set; } = null!; public DbSet<Notification.Notification> Notifications { get; set; } = null!;
public DbSet<PushSubscription> PushSubscriptions { get; set; } public DbSet<PushSubscription> PushSubscriptions { get; set; } = null!;
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {

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