♻️ Basically completed the separate of account service

This commit is contained in:
2025-07-12 11:40:18 +08:00
parent e76c80eead
commit ba49d1c7a7
69 changed files with 4245 additions and 225 deletions

View File

@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Data;
using NodaTime;
namespace DysonNetwork.Pass.Account;

View File

@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Shared.Data;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using OtpNet;
@@ -19,12 +20,12 @@ public class Account : ModelBase
public Profile Profile { get; set; } = null!;
public ICollection<AccountContact> Contacts { get; set; } = new List<AccountContact>();
public ICollection<Badge> Badges { get; set; } = new List<Badge>();
public ICollection<AccountBadge> Badges { get; set; } = new List<AccountBadge>();
[JsonIgnore] public ICollection<AccountAuthFactor> AuthFactors { get; set; } = new List<AccountAuthFactor>();
[JsonIgnore] public ICollection<AccountConnection> Connections { get; set; } = new List<AccountConnection>();
[JsonIgnore] public ICollection<Auth.Session> Sessions { get; set; } = new List<Auth.Session>();
[JsonIgnore] public ICollection<Auth.Challenge> Challenges { get; set; } = new List<Auth.Challenge>();
[JsonIgnore] public ICollection<Auth.AuthSession> Sessions { get; set; } = new List<Auth.AuthSession>();
[JsonIgnore] public ICollection<Auth.AuthChallenge> Challenges { get; set; } = new List<Auth.AuthChallenge>();
[JsonIgnore] public ICollection<Relationship> OutgoingRelationships { get; set; } = new List<Relationship>();
[JsonIgnore] public ICollection<Relationship> IncomingRelationships { get; set; } = new List<Relationship>();

View File

@@ -34,9 +34,9 @@ public class AccountController(
}
[HttpGet("{name}/badges")]
[ProducesResponseType<List<Badge>>(StatusCodes.Status200OK)]
[ProducesResponseType<List<AccountBadge>>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<List<Badge>>> GetBadgesByName(string name)
public async Task<ActionResult<List<AccountBadge>>> GetBadgesByName(string name)
{
var account = await db.Accounts
.Include(e => e.Badges)

View File

@@ -1,12 +1,10 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass;
using DysonNetwork.Pass.Storage;
using DysonNetwork.Pass.Permission;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using Org.BouncyCastle.Utilities;
namespace DysonNetwork.Pass.Account;
@@ -16,7 +14,6 @@ namespace DysonNetwork.Pass.Account;
public class AccountCurrentController(
AppDatabase db,
AccountService accounts,
FileReferenceService fileRefService,
AccountEventService events,
AuthService auth
) : ControllerBase
@@ -97,58 +94,12 @@ public class AccountCurrentController(
if (request.PictureId is not null)
{
var picture = await db.Files.Where(f => f.Id == request.PictureId).FirstOrDefaultAsync();
if (picture is null) return BadRequest("Invalid picture id, unable to find the file on cloud.");
var profileResourceId = $"profile:{profile.Id}";
// Remove old references for the profile picture
if (profile.Picture is not null)
{
var oldPictureRefs =
await fileRefService.GetResourceReferencesAsync(profileResourceId, "profile.picture");
foreach (var oldRef in oldPictureRefs)
{
await fileRefService.DeleteReferenceAsync(oldRef.Id);
}
}
profile.Picture = picture.ToReferenceObject();
// Create new reference
await fileRefService.CreateReferenceAsync(
picture.Id,
"profile.picture",
profileResourceId
);
// TODO: Create reference, set profile picture
}
if (request.BackgroundId is not null)
{
var background = await db.Files.Where(f => f.Id == request.BackgroundId).FirstOrDefaultAsync();
if (background is null) return BadRequest("Invalid background id, unable to find the file on cloud.");
var profileResourceId = $"profile:{profile.Id}";
// Remove old references for the profile background
if (profile.Background is not null)
{
var oldBackgroundRefs =
await fileRefService.GetResourceReferencesAsync(profileResourceId, "profile.background");
foreach (var oldRef in oldBackgroundRefs)
{
await fileRefService.DeleteReferenceAsync(oldRef.Id);
}
}
profile.Background = background.ToReferenceObject();
// Create new reference
await fileRefService.CreateReferenceAsync(
background.Id,
"profile.background",
profileResourceId
);
// TODO: Create reference, set profile background
}
db.Update(profile);
@@ -438,7 +389,7 @@ public class AccountCurrentController(
public string UserAgent { get; set; } = null!;
public string DeviceId { get; set; } = null!;
public ChallengePlatform Platform { get; set; }
public List<Session> Sessions { get; set; } = [];
public List<AuthSession> Sessions { get; set; } = [];
}
[HttpGet("devices")]
@@ -446,7 +397,7 @@ public class AccountCurrentController(
public async Task<ActionResult<List<AuthorizedDevice>>> GetDevices()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized();
HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
Response.Headers.Append("X-Auth-Session", currentSession.Id.ToString());
@@ -475,13 +426,13 @@ public class AccountCurrentController(
[HttpGet("sessions")]
[Authorize]
public async Task<ActionResult<List<Session>>> GetSessions(
public async Task<ActionResult<List<AuthSession>>> GetSessions(
[FromQuery] int take = 20,
[FromQuery] int offset = 0
)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized();
HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
var query = db.AuthSessions
.Include(session => session.Account)
@@ -503,7 +454,7 @@ public class AccountCurrentController(
[HttpDelete("sessions/{id:guid}")]
[Authorize]
public async Task<ActionResult<Session>> DeleteSession(Guid id)
public async Task<ActionResult<AuthSession>> DeleteSession(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
@@ -520,10 +471,10 @@ public class AccountCurrentController(
[HttpDelete("sessions/current")]
[Authorize]
public async Task<ActionResult<Session>> DeleteCurrentSession()
public async Task<ActionResult<AuthSession>> DeleteCurrentSession()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized();
HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
try
{
@@ -537,7 +488,7 @@ public class AccountCurrentController(
}
[HttpPatch("sessions/{id:guid}/label")]
public async Task<ActionResult<Session>> UpdateSessionLabel(Guid id, [FromBody] string label)
public async Task<ActionResult<AuthSession>> UpdateSessionLabel(Guid id, [FromBody] string label)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
@@ -553,10 +504,10 @@ public class AccountCurrentController(
}
[HttpPatch("sessions/current/label")]
public async Task<ActionResult<Session>> UpdateCurrentSessionLabel([FromBody] string label)
public async Task<ActionResult<AuthSession>> UpdateCurrentSessionLabel([FromBody] string label)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized();
HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
try
{
@@ -672,9 +623,9 @@ public class AccountCurrentController(
}
[HttpGet("badges")]
[ProducesResponseType<List<Badge>>(StatusCodes.Status200OK)]
[ProducesResponseType<List<AccountBadge>>(StatusCodes.Status200OK)]
[Authorize]
public async Task<ActionResult<List<Badge>>> GetBadges()
public async Task<ActionResult<List<AccountBadge>>> GetBadges()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
@@ -686,7 +637,7 @@ public class AccountCurrentController(
[HttpPost("badges/{id:guid}/active")]
[Authorize]
public async Task<ActionResult<Badge>> ActivateBadge(Guid id)
public async Task<ActionResult<AccountBadge>> ActivateBadge(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();

View File

@@ -1,21 +1,16 @@
using System.Globalization;
using DysonNetwork.Pass;
using DysonNetwork.Pass.Connection;
using DysonNetwork.Pass.Storage;
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Cache;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Localization;
using NodaTime;
using Org.BouncyCastle.Asn1.X509;
namespace DysonNetwork.Pass.Account;
public class AccountEventService(
AppDatabase db,
WebSocketService ws,
ICacheService cache,
PaymentService payment,
ICacheService cache,
IStringLocalizer<Localization.AccountEventResource> localizer
)
{
@@ -34,7 +29,7 @@ public class AccountEventService(
var cachedStatus = await cache.GetAsync<Status>(cacheKey);
if (cachedStatus is not null)
{
cachedStatus!.IsOnline = !cachedStatus.IsInvisible && ws.GetAccountIsConnected(userId);
cachedStatus!.IsOnline = !cachedStatus.IsInvisible; // && ws.GetAccountIsConnected(userId);
return cachedStatus;
}
@@ -44,7 +39,7 @@ public class AccountEventService(
.Where(e => e.ClearedAt == null || e.ClearedAt > now)
.OrderByDescending(e => e.CreatedAt)
.FirstOrDefaultAsync();
var isOnline = ws.GetAccountIsConnected(userId);
var isOnline = false; // TODO: Get connection status
if (status is not null)
{
status.IsOnline = !status.IsInvisible && isOnline;
@@ -65,7 +60,7 @@ public class AccountEventService(
};
}
return new Status
return new Status
{
Attitude = StatusAttitude.Neutral,
IsOnline = false,
@@ -86,7 +81,7 @@ public class AccountEventService(
var cachedStatus = await cache.GetAsync<Status>(cacheKey);
if (cachedStatus != null)
{
cachedStatus.IsOnline = !cachedStatus.IsInvisible && ws.GetAccountIsConnected(userId);
cachedStatus.IsOnline = !cachedStatus.IsInvisible /* && ws.GetAccountIsConnected(userId) */;
results[userId] = cachedStatus;
}
else
@@ -109,7 +104,7 @@ public class AccountEventService(
foreach (var status in statusesFromDb)
{
var isOnline = ws.GetAccountIsConnected(status.AccountId);
var isOnline = false; // ws.GetAccountIsConnected(status.AccountId);
status.IsOnline = !status.IsInvisible && isOnline;
results[status.AccountId] = status;
var cacheKey = $"{StatusCacheKey}{status.AccountId}";
@@ -122,7 +117,7 @@ public class AccountEventService(
{
foreach (var userId in usersWithoutStatus)
{
var isOnline = ws.GetAccountIsConnected(userId);
var isOnline = false; // ws.GetAccountIsConnected(userId);
var defaultStatus = new Status
{
Attitude = StatusAttitude.Neutral,
@@ -198,11 +193,11 @@ public class AccountEventService(
public async Task<CheckInResult> CheckInDaily(Account user)
{
var lockKey = $"{CheckInLockKey}{user.Id}";
try
{
var lk = await cache.AcquireLockAsync(lockKey, TimeSpan.FromMinutes(1), TimeSpan.FromMilliseconds(100));
if (lk != null)
await lk.ReleaseAsync();
}
@@ -210,9 +205,10 @@ public class AccountEventService(
{
// Ignore errors from this pre-check
}
// Now try to acquire the lock properly
await using var lockObj = await cache.AcquireLockAsync(lockKey, TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(5));
await using var lockObj =
await cache.AcquireLockAsync(lockKey, TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(5));
if (lockObj is null) throw new InvalidOperationException("Check-in was in progress.");
var cultureInfo = new CultureInfo(user.Language, false);
@@ -274,7 +270,7 @@ public class AccountEventService(
s.SetProperty(b => b.Experience, b => b.Experience + result.RewardExperience)
);
db.AccountCheckInResults.Add(result);
await db.SaveChangesAsync(); // Don't forget to save changes to the database
await db.SaveChangesAsync(); // Don't forget to save changes to the database
// The lock will be automatically released by the await using statement
return result;

View File

@@ -1,16 +1,14 @@
using System.Globalization;
using DysonNetwork.Pass;
using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Auth.OpenId;
using DysonNetwork.Pass.Email;
using DysonNetwork.Pass.Localization;
using DysonNetwork.Pass.Permission;
using DysonNetwork.Pass.Storage;
using DysonNetwork.Shared.Cache;
using EFCore.BulkExtensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
using NodaTime;
using Org.BouncyCastle.Utilities;
using OtpNet;
namespace DysonNetwork.Pass.Account;
@@ -454,7 +452,7 @@ public class AccountService(
);
}
public async Task<Session> UpdateSessionLabel(Account account, Guid sessionId, string label)
public async Task<AuthSession> UpdateSessionLabel(Account account, Guid sessionId, string label)
{
var session = await db.AuthSessions
.Include(s => s.Challenge)
@@ -574,7 +572,7 @@ public class AccountService(
/// This method will grant a badge to the account.
/// Shouldn't be exposed to normal user and the user itself.
/// </summary>
public async Task<Badge> GrantBadge(Account account, Badge badge)
public async Task<AccountBadge> GrantBadge(Account account, AccountBadge badge)
{
badge.AccountId = account.Id;
db.Badges.Add(badge);

View File

@@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using DysonNetwork.Shared.Data;
using Point = NetTopologySuite.Geometries.Point;
namespace DysonNetwork.Pass.Account;

View File

@@ -1,7 +1,5 @@
using Quartz;
using DysonNetwork.Pass;
using DysonNetwork.Pass.Storage;
using DysonNetwork.Pass.Storage.Handlers;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Geo;
namespace DysonNetwork.Pass.Account;
@@ -38,7 +36,7 @@ public class ActionLogService(GeoIpService geo, FlushBufferService fbs)
else
throw new ArgumentException("No user context was found");
if (request.HttpContext.Items["CurrentSession"] is Auth.Session currentSession)
if (request.HttpContext.Items["CurrentSession"] is Auth.AuthSession currentSession)
log.SessionId = currentSession.Id;
fbs.Enqueue(log);

View File

@@ -1,11 +1,12 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Shared.Data;
using NodaTime;
namespace DysonNetwork.Pass.Account;
public class Badge : ModelBase
public class AccountBadge : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(1024)] public string Type { get; set; } = null!;

View File

@@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using DysonNetwork.Shared.Data;
using NodaTime;
namespace DysonNetwork.Pass.Account;

View File

@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Shared.Data;
using Microsoft.EntityFrameworkCore;
using NodaTime;

View File

@@ -1,23 +1,18 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text.Json;
using DysonNetwork.Pass;
using DysonNetwork.Pass.Pages.Emails;
using DysonNetwork.Pass.Permission;
using DysonNetwork.Pass.Resources.Localization;
using DysonNetwork.Pass.Resources.Pages.Emails;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
using NodaTime;
using EmailResource = DysonNetwork.Pass.Localization.EmailResource;
namespace DysonNetwork.Pass.Account;
public class MagicSpellService(
AppDatabase db,
EmailService email,
IConfiguration configuration,
ILogger<MagicSpellService> logger,
IStringLocalizer<Localization.EmailResource> localizer
IStringLocalizer<EmailResource> localizer
)
{
public async Task<MagicSpell> CreateMagicSpell(
@@ -84,61 +79,61 @@ public class MagicSpellService(
try
{
switch (spell.Type)
{
case MagicSpellType.AccountActivation:
await email.SendTemplatedEmailAsync<LandingEmail, LandingEmailModel>(
contact.Account.Nick,
contact.Content,
localizer["EmailLandingTitle"],
new LandingEmailModel
{
Name = contact.Account.Name,
Link = link
}
);
break;
case MagicSpellType.AccountRemoval:
await email.SendTemplatedEmailAsync<AccountDeletionEmail, AccountDeletionEmailModel>(
contact.Account.Nick,
contact.Content,
localizer["EmailAccountDeletionTitle"],
new AccountDeletionEmailModel
{
Name = contact.Account.Name,
Link = link
}
);
break;
case MagicSpellType.AuthPasswordReset:
await email.SendTemplatedEmailAsync<PasswordResetEmail, PasswordResetEmailModel>(
contact.Account.Nick,
contact.Content,
localizer["EmailAccountDeletionTitle"],
new PasswordResetEmailModel
{
Name = contact.Account.Name,
Link = link
}
);
break;
case MagicSpellType.ContactVerification:
if (spell.Meta["contact_method"] is not string contactMethod)
throw new InvalidOperationException("Contact method is not found.");
await email.SendTemplatedEmailAsync<ContactVerificationEmail, ContactVerificationEmailModel>(
contact.Account.Nick,
contactMethod!,
localizer["EmailContactVerificationTitle"],
new ContactVerificationEmailModel
{
Name = contact.Account.Name,
Link = link
}
);
break;
default:
throw new ArgumentOutOfRangeException();
}
// switch (spell.Type)
// {
// case MagicSpellType.AccountActivation:
// await email.SendTemplatedEmailAsync<LandingEmail, LandingEmailModel>(
// contact.Account.Nick,
// contact.Content,
// localizer["EmailLandingTitle"],
// new LandingEmailModel
// {
// Name = contact.Account.Name,
// Link = link
// }
// );
// break;
// case MagicSpellType.AccountRemoval:
// await email.SendTemplatedEmailAsync<AccountDeletionEmail, AccountDeletionEmailModel>(
// contact.Account.Nick,
// contact.Content,
// localizer["EmailAccountDeletionTitle"],
// new AccountDeletionEmailModel
// {
// Name = contact.Account.Name,
// Link = link
// }
// );
// break;
// case MagicSpellType.AuthPasswordReset:
// await email.SendTemplatedEmailAsync<PasswordResetEmail, PasswordResetEmailModel>(
// contact.Account.Nick,
// contact.Content,
// localizer["EmailAccountDeletionTitle"],
// new PasswordResetEmailModel
// {
// Name = contact.Account.Name,
// Link = link
// }
// );
// break;
// case MagicSpellType.ContactVerification:
// if (spell.Meta["contact_method"] is not string contactMethod)
// throw new InvalidOperationException("Contact method is not found.");
// await email.SendTemplatedEmailAsync<ContactVerificationEmail, ContactVerificationEmailModel>(
// contact.Account.Nick,
// contactMethod!,
// localizer["EmailContactVerificationTitle"],
// new ContactVerificationEmailModel
// {
// Name = contact.Account.Name,
// Link = link
// }
// );
// break;
// default:
// throw new ArgumentOutOfRangeException();
// }
}
catch (Exception err)
{

View File

@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Shared.Data;
using Microsoft.EntityFrameworkCore;
using NodaTime;

View File

@@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Pass;
using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Permission;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -69,7 +70,7 @@ public class NotificationController(AppDatabase db, NotificationService nty) : C
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
var currentUser = currentUserValue as Account;
if (currentUser == null) return Unauthorized();
var currentSession = currentSessionValue as Session;
var currentSession = currentSessionValue as AuthSession;
if (currentSession == null) return Unauthorized();
var result =
@@ -87,7 +88,7 @@ public class NotificationController(AppDatabase db, NotificationService nty) : C
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
var currentUser = currentUserValue as Account;
if (currentUser == null) return Unauthorized();
var currentSession = currentSessionValue as Session;
var currentSession = currentSessionValue as AuthSession;
if (currentSession == null) return Unauthorized();
var affectedRows = await db.NotificationPushSubscriptions

View File

@@ -9,9 +9,9 @@ namespace DysonNetwork.Pass.Account;
public class NotificationService(
AppDatabase db,
WebSocketService ws,
IHttpClientFactory httpFactory,
IConfiguration config)
IConfiguration config
)
{
private readonly string _notifyTopic = config["Notifications:Topic"]!;
private readonly Uri _notifyEndpoint = new(config["Notifications:Endpoint"]!);
@@ -31,7 +31,7 @@ public class NotificationService(
)
{
var now = SystemClock.Instance.GetCurrentInstant();
// First check if a matching subscription exists
var existingSubscription = await db.NotificationPushSubscriptions
.Where(s => s.AccountId == account.Id)
@@ -110,12 +110,6 @@ public class NotificationService(
public async Task DeliveryNotification(Notification notification)
{
ws.SendPacketToAccount(notification.AccountId, new WebSocketPacket
{
Type = "notifications.new",
Data = notification
});
// Pushing the notification
var subscribers = await db.NotificationPushSubscriptions
.Where(s => s.AccountId == notification.AccountId)
@@ -164,11 +158,6 @@ public class NotificationService(
{
notification.Account = account;
notification.AccountId = account.Id;
ws.SendPacketToAccount(account.Id, new WebSocketPacket
{
Type = "notifications.new",
Data = notification
});
}
var subscribers = await db.NotificationPushSubscriptions
@@ -202,11 +191,6 @@ public class NotificationService(
{
notification.Account = account;
notification.AccountId = account.Id;
ws.SendPacketToAccount(account.Id, new WebSocketPacket
{
Type = "notifications.new",
Data = notification
});
}
var accountsId = accounts.Select(x => x.Id).ToList();

View File

@@ -1,3 +1,4 @@
using DysonNetwork.Shared.Data;
using NodaTime;
namespace DysonNetwork.Pass.Account;