♻️ Moved services & controllers to Pass
This commit is contained in:
30
DysonNetwork.Pass/Account/AbuseReport.cs
Normal file
30
DysonNetwork.Pass/Account/AbuseReport.cs
Normal file
@ -0,0 +1,30 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Pass.Account;
|
||||
|
||||
public enum AbuseReportType
|
||||
{
|
||||
Copyright,
|
||||
Harassment,
|
||||
Impersonation,
|
||||
OffensiveContent,
|
||||
Spam,
|
||||
PrivacyViolation,
|
||||
IllegalContent,
|
||||
Other
|
||||
}
|
||||
|
||||
public class AbuseReport : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
[MaxLength(4096)] public string ResourceIdentifier { get; set; } = null!;
|
||||
public AbuseReportType Type { get; set; }
|
||||
[MaxLength(8192)] public string Reason { get; set; } = null!;
|
||||
|
||||
public Instant? ResolvedAt { get; set; }
|
||||
[MaxLength(8192)] public string? Resolution { get; set; }
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
public Shared.Models.Account Account { get; set; } = null!;
|
||||
}
|
175
DysonNetwork.Pass/Account/AccountController.cs
Normal file
175
DysonNetwork.Pass/Account/AccountController.cs
Normal file
@ -0,0 +1,175 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using DysonNetwork.Pass.Auth;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace DysonNetwork.Pass.Account;
|
||||
|
||||
[ApiController]
|
||||
[Route("/accounts")]
|
||||
public class AccountController(
|
||||
AppDatabase db,
|
||||
AuthService auth,
|
||||
AccountService accounts,
|
||||
AccountEventService events
|
||||
) : ControllerBase
|
||||
{
|
||||
[HttpGet("{name}")]
|
||||
[ProducesResponseType<Shared.Models.Account>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<Shared.Models.Account?>> GetByName(string name)
|
||||
{
|
||||
var account = await db.Accounts
|
||||
.Include(e => e.Badges)
|
||||
.Include(e => e.Profile)
|
||||
.Where(a => a.Name == name)
|
||||
.FirstOrDefaultAsync();
|
||||
return account is null ? new NotFoundResult() : account;
|
||||
}
|
||||
|
||||
[HttpGet("{name}/badges")]
|
||||
[ProducesResponseType<List<Badge>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<List<Badge>>> GetBadgesByName(string name)
|
||||
{
|
||||
var account = await db.Accounts
|
||||
.Include(e => e.Badges)
|
||||
.Where(a => a.Name == name)
|
||||
.FirstOrDefaultAsync();
|
||||
return account is null ? NotFound() : account.Badges.ToList();
|
||||
}
|
||||
|
||||
public class AccountCreateRequest
|
||||
{
|
||||
[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;
|
||||
|
||||
[EmailAddress]
|
||||
[RegularExpression(@"^[^+]+@[^@]+\.[^@]+$", ErrorMessage = "Email address cannot contain '+' symbol.")]
|
||||
[Required]
|
||||
[MaxLength(1024)]
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[MinLength(4)]
|
||||
[MaxLength(128)]
|
||||
public string Password { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(128)] public string Language { get; set; } = "en-us";
|
||||
|
||||
[Required] public string CaptchaToken { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType<Shared.Models.Account>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<Shared.Models.Account>> CreateAccount([FromBody] AccountCreateRequest request)
|
||||
{
|
||||
if (!await auth.ValidateCaptcha(request.CaptchaToken)) return BadRequest("Invalid captcha token.");
|
||||
|
||||
try
|
||||
{
|
||||
var account = await accounts.CreateAccount(
|
||||
request.Name,
|
||||
request.Nick,
|
||||
request.Email,
|
||||
request.Password,
|
||||
request.Language
|
||||
);
|
||||
return Ok(account);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public class RecoveryPasswordRequest
|
||||
{
|
||||
[Required] public string Account { get; set; } = null!;
|
||||
[Required] public string CaptchaToken { get; set; } = null!;
|
||||
}
|
||||
|
||||
[HttpPost("recovery/password")]
|
||||
public async Task<ActionResult> RequestResetPassword([FromBody] RecoveryPasswordRequest request)
|
||||
{
|
||||
if (!await auth.ValidateCaptcha(request.CaptchaToken)) return BadRequest("Invalid captcha token.");
|
||||
|
||||
var account = await accounts.LookupAccount(request.Account);
|
||||
if (account is null) return BadRequest("Unable to find the account.");
|
||||
|
||||
try
|
||||
{
|
||||
await accounts.RequestPasswordReset(account);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return BadRequest("You already requested password reset within 24 hours.");
|
||||
}
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
public class StatusRequest
|
||||
{
|
||||
public StatusAttitude Attitude { get; set; }
|
||||
public bool IsInvisible { get; set; }
|
||||
public bool IsNotDisturb { get; set; }
|
||||
[MaxLength(1024)] public string? Label { get; set; }
|
||||
public Instant? ClearedAt { get; set; }
|
||||
}
|
||||
|
||||
[HttpGet("{name}/statuses")]
|
||||
public async Task<ActionResult<Status>> GetOtherStatus(string name)
|
||||
{
|
||||
var account = await db.Accounts.FirstOrDefaultAsync(a => a.Name == name);
|
||||
if (account is null) return BadRequest();
|
||||
var status = await events.GetStatus(account.Id);
|
||||
status.IsInvisible = false; // Keep the invisible field not available for other users
|
||||
return Ok(status);
|
||||
}
|
||||
|
||||
[HttpGet("{name}/calendar")]
|
||||
public async Task<ActionResult<List<DailyEventResponse>>> GetOtherEventCalendar(
|
||||
string name,
|
||||
[FromQuery] int? month,
|
||||
[FromQuery] int? year
|
||||
)
|
||||
{
|
||||
var currentDate = SystemClock.Instance.GetCurrentInstant().InUtc().Date;
|
||||
month ??= currentDate.Month;
|
||||
year ??= currentDate.Year;
|
||||
|
||||
if (month is < 1 or > 12) return BadRequest("Invalid month.");
|
||||
if (year < 1) return BadRequest("Invalid year.");
|
||||
|
||||
var account = await db.Accounts.FirstOrDefaultAsync(a => a.Name == name);
|
||||
if (account is null) return BadRequest();
|
||||
|
||||
var calendar = await events.GetEventCalendar(account, month.Value, year.Value, replaceInvisible: true);
|
||||
return Ok(calendar);
|
||||
}
|
||||
|
||||
[HttpGet("search")]
|
||||
public async Task<List<Shared.Models.Account>> Search([FromQuery] string query, [FromQuery] int take = 20)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
return [];
|
||||
return await db.Accounts
|
||||
.Include(e => e.Profile)
|
||||
.Where(a => EF.Functions.ILike(a.Name, $"%{query}%") ||
|
||||
EF.Functions.ILike(a.Nick, $"%{query}%"))
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
703
DysonNetwork.Pass/Account/AccountCurrentController.cs
Normal file
703
DysonNetwork.Pass/Account/AccountCurrentController.cs
Normal file
@ -0,0 +1,703 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Pass.Auth;
|
||||
using DysonNetwork.Pass.Permission;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
|
||||
|
||||
namespace DysonNetwork.Pass.Account;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("/accounts/me")]
|
||||
public class AccountCurrentController(
|
||||
AppDatabase db,
|
||||
AccountService accounts,
|
||||
AccountEventService events,
|
||||
AuthService auth
|
||||
) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[ProducesResponseType<Shared.Models.Account>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<Shared.Models.Account>> GetCurrentIdentity()
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
|
||||
var userId = currentUser.Id;
|
||||
|
||||
var account = await db.Accounts
|
||||
.Include(e => e.Badges)
|
||||
.Include(e => e.Profile)
|
||||
.Where(e => e.Id == userId)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
return Ok(account);
|
||||
}
|
||||
|
||||
public class BasicInfoRequest
|
||||
{
|
||||
[MaxLength(256)] public string? Nick { get; set; }
|
||||
[MaxLength(32)] public string? Language { get; set; }
|
||||
}
|
||||
|
||||
[HttpPatch]
|
||||
public async Task<ActionResult<Shared.Models.Account>> UpdateBasicInfo([FromBody] BasicInfoRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
|
||||
|
||||
var account = await db.Accounts.FirstAsync(a => a.Id == currentUser.Id);
|
||||
|
||||
if (request.Nick is not null) account.Nick = request.Nick;
|
||||
if (request.Language is not null) account.Language = request.Language;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
await accounts.PurgeAccountCache(currentUser);
|
||||
return currentUser;
|
||||
}
|
||||
|
||||
public class ProfileRequest
|
||||
{
|
||||
[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; }
|
||||
}
|
||||
|
||||
[HttpPatch("profile")]
|
||||
public async Task<ActionResult<Profile>> UpdateProfile([FromBody] ProfileRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
|
||||
var userId = currentUser.Id;
|
||||
|
||||
var profile = await db.AccountProfiles
|
||||
.Where(p => p.Account.Id == userId)
|
||||
.FirstOrDefaultAsync();
|
||||
if (profile is null) return BadRequest("Unable to get your account.");
|
||||
|
||||
if (request.FirstName is not null) profile.FirstName = request.FirstName;
|
||||
if (request.MiddleName is not null) profile.MiddleName = request.MiddleName;
|
||||
if (request.LastName is not null) profile.LastName = request.LastName;
|
||||
if (request.Bio is not null) profile.Bio = request.Bio;
|
||||
if (request.Gender is not null) profile.Gender = request.Gender;
|
||||
if (request.Pronouns is not null) profile.Pronouns = request.Pronouns;
|
||||
if (request.Birthday is not null) profile.Birthday = request.Birthday;
|
||||
if (request.Location is not null) profile.Location = request.Location;
|
||||
if (request.TimeZone is not null) profile.TimeZone = request.TimeZone;
|
||||
|
||||
// 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
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// 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
|
||||
// );
|
||||
// }
|
||||
|
||||
db.Update(profile);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await accounts.PurgeAccountCache(currentUser);
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
[HttpDelete]
|
||||
public async Task<ActionResult> RequestDeleteAccount()
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
|
||||
|
||||
try
|
||||
{
|
||||
await accounts.RequestAccountDeletion(currentUser);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return BadRequest("You already requested account deletion within 24 hours.");
|
||||
}
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpGet("statuses")]
|
||||
public async Task<ActionResult<Status>> GetCurrentStatus()
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
|
||||
var status = await events.GetStatus(currentUser.Id);
|
||||
return Ok(status);
|
||||
}
|
||||
|
||||
[HttpPatch("statuses")]
|
||||
[RequiredPermission("global", "accounts.statuses.update")]
|
||||
public async Task<ActionResult<Status>> UpdateStatus([FromBody] AccountController.StatusRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var status = await db.AccountStatuses
|
||||
.Where(e => e.AccountId == currentUser.Id)
|
||||
.Where(e => e.ClearedAt == null || e.ClearedAt > now)
|
||||
.OrderByDescending(e => e.CreatedAt)
|
||||
.FirstOrDefaultAsync();
|
||||
if (status is null) return NotFound();
|
||||
|
||||
status.Attitude = request.Attitude;
|
||||
status.IsInvisible = request.IsInvisible;
|
||||
status.IsNotDisturb = request.IsNotDisturb;
|
||||
status.Label = request.Label;
|
||||
status.ClearedAt = request.ClearedAt;
|
||||
|
||||
db.Update(status);
|
||||
await db.SaveChangesAsync();
|
||||
events.PurgeStatusCache(currentUser.Id);
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
[HttpPost("statuses")]
|
||||
[RequiredPermission("global", "accounts.statuses.create")]
|
||||
public async Task<ActionResult<Status>> CreateStatus([FromBody] AccountController.StatusRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
|
||||
|
||||
var status = new Status
|
||||
{
|
||||
AccountId = currentUser.Id,
|
||||
Attitude = request.Attitude,
|
||||
IsInvisible = request.IsInvisible,
|
||||
IsNotDisturb = request.IsNotDisturb,
|
||||
Label = request.Label,
|
||||
ClearedAt = request.ClearedAt
|
||||
};
|
||||
|
||||
return await events.CreateStatus(currentUser, status);
|
||||
}
|
||||
|
||||
[HttpDelete("me/statuses")]
|
||||
public async Task<ActionResult> DeleteStatus()
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var status = await db.AccountStatuses
|
||||
.Where(s => s.AccountId == currentUser.Id)
|
||||
.Where(s => s.ClearedAt == null || s.ClearedAt > now)
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.FirstOrDefaultAsync();
|
||||
if (status is null) return NotFound();
|
||||
|
||||
await events.ClearStatus(currentUser, status);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("check-in")]
|
||||
public async Task<ActionResult<CheckInResult>> GetCheckInResult()
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
|
||||
var userId = currentUser.Id;
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var today = now.InUtc().Date;
|
||||
var startOfDay = today.AtStartOfDayInZone(DateTimeZone.Utc).ToInstant();
|
||||
var endOfDay = today.PlusDays(1).AtStartOfDayInZone(DateTimeZone.Utc).ToInstant();
|
||||
|
||||
var result = await db.AccountCheckInResults
|
||||
.Where(x => x.AccountId == userId)
|
||||
.Where(x => x.CreatedAt >= startOfDay && x.CreatedAt < endOfDay)
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("check-in")]
|
||||
public async Task<ActionResult<CheckInResult>> DoCheckIn([FromBody] string? captchaToken)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
|
||||
|
||||
var isAvailable = await events.CheckInDailyIsAvailable(currentUser);
|
||||
if (!isAvailable)
|
||||
return BadRequest("Check-in is not available for today.");
|
||||
|
||||
try
|
||||
{
|
||||
var needsCaptcha = await events.CheckInDailyDoAskCaptcha(currentUser);
|
||||
return needsCaptcha switch
|
||||
{
|
||||
true when string.IsNullOrWhiteSpace(captchaToken) => StatusCode(423,
|
||||
"Captcha is required for this check-in."),
|
||||
true when !await auth.ValidateCaptcha(captchaToken!) => BadRequest("Invalid captcha token."),
|
||||
_ => await events.CheckInDaily(currentUser)
|
||||
};
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("calendar")]
|
||||
public async Task<ActionResult<List<DailyEventResponse>>> GetEventCalendar([FromQuery] int? month,
|
||||
[FromQuery] int? year)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
|
||||
|
||||
var currentDate = SystemClock.Instance.GetCurrentInstant().InUtc().Date;
|
||||
month ??= currentDate.Month;
|
||||
year ??= currentDate.Year;
|
||||
|
||||
if (month is < 1 or > 12) return BadRequest("Invalid month.");
|
||||
if (year < 1) return BadRequest("Invalid year.");
|
||||
|
||||
var calendar = await events.GetEventCalendar(currentUser, month.Value, year.Value);
|
||||
return Ok(calendar);
|
||||
}
|
||||
|
||||
[HttpGet("actions")]
|
||||
[ProducesResponseType<List<ActionLog>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ActionResult<List<ActionLog>>> GetActionLogs(
|
||||
[FromQuery] int take = 20,
|
||||
[FromQuery] int offset = 0
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
|
||||
|
||||
var query = db.ActionLogs
|
||||
.Where(log => log.AccountId == currentUser.Id)
|
||||
.OrderByDescending(log => log.CreatedAt);
|
||||
|
||||
var total = await query.CountAsync();
|
||||
Response.Headers.Append("X-Total", total.ToString());
|
||||
|
||||
var logs = await query
|
||||
.Skip(offset)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(logs);
|
||||
}
|
||||
|
||||
[HttpGet("factors")]
|
||||
public async Task<ActionResult<List<AccountAuthFactor>>> GetAuthFactors()
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
|
||||
|
||||
var factors = await db.AccountAuthFactors
|
||||
.Include(f => f.Account)
|
||||
.Where(f => f.Account.Id == currentUser.Id)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(factors);
|
||||
}
|
||||
|
||||
public class AuthFactorRequest
|
||||
{
|
||||
public AccountAuthFactorType Type { get; set; }
|
||||
public string? Secret { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("factors")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<AccountAuthFactor>> CreateAuthFactor([FromBody] AuthFactorRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
|
||||
if (await accounts.CheckAuthFactorExists(currentUser, request.Type))
|
||||
return BadRequest($"Auth factor with type {request.Type} is already exists.");
|
||||
|
||||
var factor = await accounts.CreateAuthFactor(currentUser, request.Type, request.Secret);
|
||||
return Ok(factor);
|
||||
}
|
||||
|
||||
[HttpPost("factors/{id:guid}/enable")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<AccountAuthFactor>> EnableAuthFactor(Guid id, [FromBody] string? code)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
|
||||
|
||||
var factor = await db.AccountAuthFactors
|
||||
.Where(f => f.AccountId == currentUser.Id && f.Id == id)
|
||||
.FirstOrDefaultAsync();
|
||||
if (factor is null) return NotFound();
|
||||
|
||||
try
|
||||
{
|
||||
factor = await accounts.EnableAuthFactor(factor, code);
|
||||
return Ok(factor);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("factors/{id:guid}/disable")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<AccountAuthFactor>> DisableAuthFactor(Guid id)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
|
||||
|
||||
var factor = await db.AccountAuthFactors
|
||||
.Where(f => f.AccountId == currentUser.Id && f.Id == id)
|
||||
.FirstOrDefaultAsync();
|
||||
if (factor is null) return NotFound();
|
||||
|
||||
try
|
||||
{
|
||||
factor = await accounts.DisableAuthFactor(factor);
|
||||
return Ok(factor);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("factors/{id:guid}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<AccountAuthFactor>> DeleteAuthFactor(Guid id)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
|
||||
|
||||
var factor = await db.AccountAuthFactors
|
||||
.Where(f => f.AccountId == currentUser.Id && f.Id == id)
|
||||
.FirstOrDefaultAsync();
|
||||
if (factor is null) return NotFound();
|
||||
|
||||
try
|
||||
{
|
||||
await accounts.DeleteAuthFactor(factor);
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
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<Session> Sessions { get; set; } = [];
|
||||
}
|
||||
|
||||
[HttpGet("devices")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<List<AuthorizedDevice>>> GetDevices()
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser ||
|
||||
HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized();
|
||||
|
||||
Response.Headers.Append("X-Auth-Session", currentSession.Id.ToString());
|
||||
|
||||
// Group sessions by the related DeviceId, then create an AuthorizedDevice for each group.
|
||||
var deviceGroups = await db.AuthSessions
|
||||
.Where(s => s.Account.Id == currentUser.Id)
|
||||
.Include(s => s.Challenge)
|
||||
.GroupBy(s => s.Challenge.DeviceId!)
|
||||
.Select(g => new AuthorizedDevice
|
||||
{
|
||||
DeviceId = g.Key!,
|
||||
UserAgent = g.First(x => x.Challenge.UserAgent != null).Challenge.UserAgent!,
|
||||
Platform = g.First().Challenge.Platform!,
|
||||
Label = g.Where(x => !string.IsNullOrWhiteSpace(x.Label)).Select(x => x.Label).FirstOrDefault(),
|
||||
Sessions = g
|
||||
.OrderByDescending(x => x.LastGrantedAt)
|
||||
.ToList()
|
||||
})
|
||||
.ToListAsync();
|
||||
deviceGroups = deviceGroups
|
||||
.OrderByDescending(s => s.Sessions.First().LastGrantedAt)
|
||||
.ToList();
|
||||
|
||||
return Ok(deviceGroups);
|
||||
}
|
||||
|
||||
[HttpGet("sessions")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<List<Session>>> GetSessions(
|
||||
[FromQuery] int take = 20,
|
||||
[FromQuery] int offset = 0
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser ||
|
||||
HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized();
|
||||
|
||||
var query = db.AuthSessions
|
||||
.Include(session => session.Account)
|
||||
.Include(session => session.Challenge)
|
||||
.Where(session => session.Account.Id == currentUser.Id);
|
||||
|
||||
var total = await query.CountAsync();
|
||||
Response.Headers.Append("X-Total", total.ToString());
|
||||
Response.Headers.Append("X-Auth-Session", currentSession.Id.ToString());
|
||||
|
||||
var sessions = await query
|
||||
.OrderByDescending(x => x.LastGrantedAt)
|
||||
.Skip(offset)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(sessions);
|
||||
}
|
||||
|
||||
[HttpDelete("sessions/{id:guid}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<Session>> DeleteSession(Guid id)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
|
||||
|
||||
try
|
||||
{
|
||||
await accounts.DeleteSession(currentUser, id);
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("sessions/current")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<Session>> DeleteCurrentSession()
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser ||
|
||||
HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized();
|
||||
|
||||
try
|
||||
{
|
||||
await accounts.DeleteSession(currentUser, currentSession.Id);
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPatch("sessions/{id:guid}/label")]
|
||||
public async Task<ActionResult<Session>> UpdateSessionLabel(Guid id, [FromBody] string label)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
|
||||
|
||||
try
|
||||
{
|
||||
await accounts.UpdateSessionLabel(currentUser, id, label);
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPatch("sessions/current/label")]
|
||||
public async Task<ActionResult<Session>> UpdateCurrentSessionLabel([FromBody] string label)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser ||
|
||||
HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized();
|
||||
|
||||
try
|
||||
{
|
||||
await accounts.UpdateSessionLabel(currentUser, currentSession.Id, label);
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("contacts")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<List<AccountContact>>> GetContacts()
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
|
||||
|
||||
var contacts = await db.AccountContacts
|
||||
.Where(c => c.AccountId == currentUser.Id)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(contacts);
|
||||
}
|
||||
|
||||
public class AccountContactRequest
|
||||
{
|
||||
[Required] public AccountContactType Type { get; set; }
|
||||
[Required] public string Content { get; set; } = null!;
|
||||
}
|
||||
|
||||
[HttpPost("contacts")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<AccountContact>> CreateContact([FromBody] AccountContactRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
|
||||
|
||||
try
|
||||
{
|
||||
var contact = await accounts.CreateContactMethod(currentUser, request.Type, request.Content);
|
||||
return Ok(contact);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("contacts/{id:guid}/verify")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<AccountContact>> VerifyContact(Guid id)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
|
||||
|
||||
var contact = await db.AccountContacts
|
||||
.Where(c => c.AccountId == currentUser.Id && c.Id == id)
|
||||
.FirstOrDefaultAsync();
|
||||
if (contact is null) return NotFound();
|
||||
|
||||
try
|
||||
{
|
||||
await accounts.VerifyContactMethod(currentUser, contact);
|
||||
return Ok(contact);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("contacts/{id:guid}/primary")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<AccountContact>> SetPrimaryContact(Guid id)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
|
||||
|
||||
var contact = await db.AccountContacts
|
||||
.Where(c => c.AccountId == currentUser.Id && c.Id == id)
|
||||
.FirstOrDefaultAsync();
|
||||
if (contact is null) return NotFound();
|
||||
|
||||
try
|
||||
{
|
||||
contact = await accounts.SetContactMethodPrimary(currentUser, contact);
|
||||
return Ok(contact);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("contacts/{id:guid}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<AccountContact>> DeleteContact(Guid id)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
|
||||
|
||||
var contact = await db.AccountContacts
|
||||
.Where(c => c.AccountId == currentUser.Id && c.Id == id)
|
||||
.FirstOrDefaultAsync();
|
||||
if (contact is null) return NotFound();
|
||||
|
||||
try
|
||||
{
|
||||
await accounts.DeleteContactMethod(currentUser, contact);
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("badges")]
|
||||
[ProducesResponseType<List<Badge>>(StatusCodes.Status200OK)]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<List<Badge>>> GetBadges()
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
|
||||
|
||||
var badges = await db.AccountBadges
|
||||
.Where(b => b.AccountId == currentUser.Id)
|
||||
.ToListAsync();
|
||||
return Ok(badges);
|
||||
}
|
||||
|
||||
[HttpPost("badges/{id:guid}/active")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<Badge>> ActivateBadge(Guid id)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
|
||||
|
||||
try
|
||||
{
|
||||
await accounts.ActiveBadge(currentUser, id);
|
||||
return Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
339
DysonNetwork.Pass/Account/AccountEventService.cs
Normal file
339
DysonNetwork.Pass/Account/AccountEventService.cs
Normal file
@ -0,0 +1,339 @@
|
||||
using System.Globalization;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Pass.Account;
|
||||
|
||||
public class AccountEventService(
|
||||
AppDatabase db,
|
||||
// WebSocketService ws,
|
||||
// ICacheService cache,
|
||||
// PaymentService payment,
|
||||
IStringLocalizer<Localization.AccountEventResource> localizer
|
||||
)
|
||||
{
|
||||
private static readonly Random Random = new();
|
||||
private const string StatusCacheKey = "AccountStatus_";
|
||||
|
||||
public void PurgeStatusCache(Guid userId)
|
||||
{
|
||||
var cacheKey = $"{StatusCacheKey}{userId}";
|
||||
// cache.RemoveAsync(cacheKey);
|
||||
}
|
||||
|
||||
public async Task<Status> GetStatus(Guid userId)
|
||||
{
|
||||
var cacheKey = $"{StatusCacheKey}{userId}";
|
||||
// var cachedStatus = await cache.GetAsync<Status>(cacheKey);
|
||||
// if (cachedStatus is not null)
|
||||
// {
|
||||
// cachedStatus!.IsOnline = !cachedStatus.IsInvisible && ws.GetAccountIsConnected(userId);
|
||||
// return cachedStatus;
|
||||
// }
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var status = await db.AccountStatuses
|
||||
.Where(e => e.AccountId == userId)
|
||||
.Where(e => e.ClearedAt == null || e.ClearedAt > now)
|
||||
.OrderByDescending(e => e.CreatedAt)
|
||||
.FirstOrDefaultAsync();
|
||||
// var isOnline = ws.GetAccountIsConnected(userId);
|
||||
var isOnline = false; // Placeholder
|
||||
if (status is not null)
|
||||
{
|
||||
status.IsOnline = !status.IsInvisible && isOnline;
|
||||
// await cache.SetWithGroupsAsync(cacheKey, status, [$"{AccountService.AccountCachePrefix}{status.AccountId}"],
|
||||
// TimeSpan.FromMinutes(5));
|
||||
return status;
|
||||
}
|
||||
|
||||
if (isOnline)
|
||||
{
|
||||
return new Status
|
||||
{
|
||||
Attitude = StatusAttitude.Neutral,
|
||||
IsOnline = true,
|
||||
IsCustomized = false,
|
||||
Label = "Online",
|
||||
AccountId = userId,
|
||||
};
|
||||
}
|
||||
|
||||
return new Status
|
||||
{
|
||||
Attitude = StatusAttitude.Neutral,
|
||||
IsOnline = false,
|
||||
IsCustomized = false,
|
||||
Label = "Offline",
|
||||
AccountId = userId,
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<Dictionary<Guid, Status>> GetStatuses(List<Guid> userIds)
|
||||
{
|
||||
var results = new Dictionary<Guid, Status>();
|
||||
var cacheMissUserIds = new List<Guid>();
|
||||
|
||||
foreach (var userId in userIds)
|
||||
{
|
||||
var cacheKey = $"{StatusCacheKey}{userId}";
|
||||
// var cachedStatus = await cache.GetAsync<Status>(cacheKey);
|
||||
// if (cachedStatus != null)
|
||||
// {
|
||||
// cachedStatus.IsOnline = !cachedStatus.IsInvisible && ws.GetAccountIsConnected(userId);
|
||||
// results[userId] = cachedStatus;
|
||||
// }
|
||||
// else
|
||||
// {
|
||||
cacheMissUserIds.Add(userId);
|
||||
// }
|
||||
}
|
||||
|
||||
if (cacheMissUserIds.Any())
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var statusesFromDb = await db.AccountStatuses
|
||||
.Where(e => cacheMissUserIds.Contains(e.AccountId))
|
||||
.Where(e => e.ClearedAt == null || e.ClearedAt > now)
|
||||
.GroupBy(e => e.AccountId)
|
||||
.Select(g => g.OrderByDescending(e => e.CreatedAt).First())
|
||||
.ToListAsync();
|
||||
|
||||
var foundUserIds = new HashSet<Guid>();
|
||||
|
||||
foreach (var status in statusesFromDb)
|
||||
{
|
||||
// var isOnline = ws.GetAccountIsConnected(status.AccountId);
|
||||
var isOnline = false; // Placeholder
|
||||
status.IsOnline = !status.IsInvisible && isOnline;
|
||||
results[status.AccountId] = status;
|
||||
var cacheKey = $"{StatusCacheKey}{status.AccountId}";
|
||||
// await cache.SetAsync(cacheKey, status, TimeSpan.FromMinutes(5));
|
||||
foundUserIds.Add(status.AccountId);
|
||||
}
|
||||
|
||||
var usersWithoutStatus = cacheMissUserIds.Except(foundUserIds).ToList();
|
||||
if (usersWithoutStatus.Any())
|
||||
{
|
||||
foreach (var userId in usersWithoutStatus)
|
||||
{
|
||||
// var isOnline = ws.GetAccountIsConnected(userId);
|
||||
var isOnline = false; // Placeholder
|
||||
var defaultStatus = new Status
|
||||
{
|
||||
Attitude = StatusAttitude.Neutral,
|
||||
IsOnline = isOnline,
|
||||
IsCustomized = false,
|
||||
Label = isOnline ? "Online" : "Offline",
|
||||
AccountId = userId,
|
||||
};
|
||||
results[userId] = defaultStatus;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<Status> CreateStatus(Shared.Models.Account user, Status status)
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
await db.AccountStatuses
|
||||
.Where(x => x.AccountId == user.Id && (x.ClearedAt == null || x.ClearedAt > now))
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.ClearedAt, now));
|
||||
|
||||
db.AccountStatuses.Add(status);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
public async Task ClearStatus(Shared.Models.Account user, Status status)
|
||||
{
|
||||
status.ClearedAt = SystemClock.Instance.GetCurrentInstant();
|
||||
db.Update(status);
|
||||
await db.SaveChangesAsync();
|
||||
PurgeStatusCache(user.Id);
|
||||
}
|
||||
|
||||
private const int FortuneTipCount = 7; // This will be the max index for each type (positive/negative)
|
||||
private const string CaptchaCacheKey = "CheckInCaptcha_";
|
||||
private const int CaptchaProbabilityPercent = 20;
|
||||
|
||||
public async Task<bool> CheckInDailyDoAskCaptcha(Shared.Models.Account user)
|
||||
{
|
||||
var cacheKey = $"{CaptchaCacheKey}{user.Id}";
|
||||
// var needsCaptcha = await cache.GetAsync<bool?>(cacheKey);
|
||||
// if (needsCaptcha is not null)
|
||||
// return needsCaptcha!.Value;
|
||||
|
||||
var result = Random.Next(100) < CaptchaProbabilityPercent;
|
||||
// await cache.SetAsync(cacheKey, result, TimeSpan.FromHours(24));
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<bool> CheckInDailyIsAvailable(Shared.Models.Account user)
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var lastCheckIn = await db.AccountCheckInResults
|
||||
.Where(x => x.AccountId == user.Id)
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (lastCheckIn == null)
|
||||
return true;
|
||||
|
||||
var lastDate = lastCheckIn.CreatedAt.InUtc().Date;
|
||||
var currentDate = now.InUtc().Date;
|
||||
|
||||
return lastDate < currentDate;
|
||||
}
|
||||
|
||||
public const string CheckInLockKey = "CheckInLock_";
|
||||
|
||||
public async Task<CheckInResult> CheckInDaily(Shared.Models.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();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 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));
|
||||
// if (lockObj is null) throw new InvalidOperationException("Check-in was in progress.");
|
||||
|
||||
var cultureInfo = new CultureInfo(user.Language, false);
|
||||
CultureInfo.CurrentCulture = cultureInfo;
|
||||
CultureInfo.CurrentUICulture = cultureInfo;
|
||||
|
||||
// Generate 2 positive tips
|
||||
var positiveIndices = Enumerable.Range(1, FortuneTipCount)
|
||||
.OrderBy(_ => Random.Next())
|
||||
.Take(2)
|
||||
.ToList();
|
||||
var tips = positiveIndices.Select(index => new FortuneTip
|
||||
{
|
||||
IsPositive = true, Title = localizer[$"FortuneTipPositiveTitle_{index}"].Value,
|
||||
Content = localizer[$"FortuneTipPositiveContent_{index}"].Value
|
||||
}).ToList();
|
||||
|
||||
// Generate 2 negative tips
|
||||
var negativeIndices = Enumerable.Range(1, FortuneTipCount)
|
||||
.Except(positiveIndices)
|
||||
.OrderBy(_ => Random.Next())
|
||||
.Take(2)
|
||||
.ToList();
|
||||
tips.AddRange(negativeIndices.Select(index => new FortuneTip
|
||||
{
|
||||
IsPositive = false, Title = localizer[$"FortuneTipNegativeTitle_{index}"].Value,
|
||||
Content = localizer[$"FortuneTipNegativeContent_{index}"].Value
|
||||
}));
|
||||
|
||||
var result = new CheckInResult
|
||||
{
|
||||
Tips = tips,
|
||||
Level = (CheckInResultLevel)Random.Next(Enum.GetValues<CheckInResultLevel>().Length),
|
||||
AccountId = user.Id,
|
||||
RewardExperience = 100,
|
||||
RewardPoints = 10,
|
||||
};
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant().InUtc().Date;
|
||||
try
|
||||
{
|
||||
if (result.RewardPoints.HasValue)
|
||||
// await payment.CreateTransactionWithAccountAsync(
|
||||
// null,
|
||||
// user.Id,
|
||||
// WalletCurrency.SourcePoint,
|
||||
// result.RewardPoints.Value,
|
||||
// $"Check-in reward on {now:yyyy/MM/dd}"
|
||||
// );
|
||||
Console.WriteLine($"Simulating transaction for {result.RewardPoints.Value} points");
|
||||
}
|
||||
catch
|
||||
{
|
||||
result.RewardPoints = null;
|
||||
}
|
||||
|
||||
await db.AccountProfiles
|
||||
.Where(p => p.AccountId == user.Id)
|
||||
.ExecuteUpdateAsync(s =>
|
||||
s.SetProperty(b => b.Experience, b => b.Experience + result.RewardExperience)
|
||||
);
|
||||
db.AccountCheckInResults.Add(result);
|
||||
await db.SaveChangesAsync(); // Don't forget to save changes to the database
|
||||
|
||||
// The lock will be automatically released by the await using statement
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<List<DailyEventResponse>> GetEventCalendar(Shared.Models.Account user, int month, int year = 0,
|
||||
bool replaceInvisible = false)
|
||||
{
|
||||
if (year == 0)
|
||||
year = SystemClock.Instance.GetCurrentInstant().InUtc().Date.Year;
|
||||
|
||||
// Create start and end dates for the specified month
|
||||
var startOfMonth = new LocalDate(year, month, 1).AtStartOfDayInZone(DateTimeZone.Utc).ToInstant();
|
||||
var endOfMonth = startOfMonth.Plus(Duration.FromDays(DateTime.DaysInMonth(year, month)));
|
||||
|
||||
var statuses = await db.AccountStatuses
|
||||
.AsNoTracking()
|
||||
.TagWith("GetEventCalendar_Statuses")
|
||||
.Where(x => x.AccountId == user.Id && x.CreatedAt >= startOfMonth && x.CreatedAt < endOfMonth)
|
||||
.Select(x => new Status
|
||||
{
|
||||
Id = x.Id,
|
||||
Attitude = x.Attitude,
|
||||
IsInvisible = !replaceInvisible && x.IsInvisible,
|
||||
IsNotDisturb = x.IsNotDisturb,
|
||||
Label = x.Label,
|
||||
ClearedAt = x.ClearedAt,
|
||||
AccountId = x.AccountId,
|
||||
CreatedAt = x.CreatedAt
|
||||
})
|
||||
.OrderBy(x => x.CreatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
var checkIn = await db.AccountCheckInResults
|
||||
.AsNoTracking()
|
||||
.TagWith("GetEventCalendar_CheckIn")
|
||||
.Where(x => x.AccountId == user.Id && x.CreatedAt >= startOfMonth && x.CreatedAt < endOfMonth)
|
||||
.ToListAsync();
|
||||
|
||||
var dates = Enumerable.Range(1, DateTime.DaysInMonth(year, month))
|
||||
.Select(day => new LocalDate(year, month, day).AtStartOfDayInZone(DateTimeZone.Utc).ToInstant())
|
||||
.ToList();
|
||||
|
||||
var statusesByDate = statuses
|
||||
.GroupBy(s => s.CreatedAt.InUtc().Date)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
var checkInByDate = checkIn
|
||||
.ToDictionary(c => c.CreatedAt.InUtc().Date);
|
||||
|
||||
return dates.Select(date =>
|
||||
{
|
||||
var utcDate = date.InUtc().Date;
|
||||
return new DailyEventResponse
|
||||
{
|
||||
Date = date,
|
||||
CheckInResult = checkInByDate.GetValueOrDefault(utcDate),
|
||||
Statuses = statusesByDate.GetValueOrDefault(utcDate, new List<Status>())
|
||||
};
|
||||
}).ToList();
|
||||
}
|
||||
}
|
61
DysonNetwork.Pass/Account/AccountGrpcService.cs
Normal file
61
DysonNetwork.Pass/Account/AccountGrpcService.cs
Normal file
@ -0,0 +1,61 @@
|
||||
using DysonNetwork.Shared.Models;
|
||||
using DysonNetwork.Shared.Protos.Account;
|
||||
using Grpc.Core;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DysonNetwork.Pass.Auth;
|
||||
|
||||
namespace DysonNetwork.Pass.Account;
|
||||
|
||||
public class AccountGrpcService(AppDatabase db, AuthService auth)
|
||||
: DysonNetwork.Shared.Protos.Account.AccountService.AccountServiceBase
|
||||
{
|
||||
public override async Task<AccountResponse> GetAccount(Empty request, ServerCallContext context)
|
||||
{
|
||||
var account = await GetAccountFromContext(context);
|
||||
return ToAccountResponse(account);
|
||||
}
|
||||
|
||||
public override async Task<AccountResponse> UpdateAccount(UpdateAccountRequest request, ServerCallContext context)
|
||||
{
|
||||
var account = await GetAccountFromContext(context);
|
||||
|
||||
// TODO: implement
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return ToAccountResponse(account);
|
||||
}
|
||||
|
||||
private async Task<Shared.Models.Account> GetAccountFromContext(ServerCallContext context)
|
||||
{
|
||||
var authorizationHeader = context.RequestHeaders.FirstOrDefault(h => h.Key == "authorization");
|
||||
if (authorizationHeader == null)
|
||||
{
|
||||
throw new RpcException(new Grpc.Core.Status(StatusCode.Unauthenticated, "Missing authorization header."));
|
||||
}
|
||||
|
||||
var token = authorizationHeader.Value.Replace("Bearer ", "");
|
||||
if (!auth.ValidateToken(token, out var sessionId))
|
||||
{
|
||||
throw new RpcException(new Grpc.Core.Status(StatusCode.Unauthenticated, "Invalid token."));
|
||||
}
|
||||
|
||||
var session = await db.AuthSessions.Include(s => s.Account).ThenInclude(a => a.Contacts)
|
||||
.FirstOrDefaultAsync(s => s.Id == sessionId);
|
||||
if (session == null)
|
||||
{
|
||||
throw new RpcException(new Grpc.Core.Status(StatusCode.Unauthenticated, "Session not found."));
|
||||
}
|
||||
|
||||
return session.Account;
|
||||
}
|
||||
|
||||
private AccountResponse ToAccountResponse(Shared.Models.Account account)
|
||||
{
|
||||
// TODO: implement
|
||||
return new AccountResponse
|
||||
{
|
||||
};
|
||||
}
|
||||
}
|
655
DysonNetwork.Pass/Account/AccountService.cs
Normal file
655
DysonNetwork.Pass/Account/AccountService.cs
Normal file
@ -0,0 +1,655 @@
|
||||
using System.Globalization;
|
||||
using DysonNetwork.Pass.Auth;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using DysonNetwork.Sphere.Auth.OpenId;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using NodaTime;
|
||||
using OtpNet;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using EFCore.BulkExtensions;
|
||||
|
||||
namespace DysonNetwork.Pass.Account;
|
||||
|
||||
public class AccountService(
|
||||
AppDatabase db,
|
||||
// MagicSpellService spells,
|
||||
// AccountUsernameService uname,
|
||||
// NotificationService nty,
|
||||
// EmailService mailer,
|
||||
// IStringLocalizer<NotificationResource> localizer,
|
||||
ICacheService cache,
|
||||
ILogger<AccountService> logger
|
||||
)
|
||||
{
|
||||
public static void SetCultureInfo(Shared.Models.Account account)
|
||||
{
|
||||
SetCultureInfo(account.Language);
|
||||
}
|
||||
|
||||
public static void SetCultureInfo(string? languageCode)
|
||||
{
|
||||
var info = new CultureInfo(languageCode ?? "en-us", false);
|
||||
CultureInfo.CurrentCulture = info;
|
||||
CultureInfo.CurrentUICulture = info;
|
||||
}
|
||||
|
||||
public const string AccountCachePrefix = "account:";
|
||||
|
||||
public async Task PurgeAccountCache(Shared.Models.Account account)
|
||||
{
|
||||
await cache.RemoveGroupAsync($"{AccountCachePrefix}{account.Id}");
|
||||
}
|
||||
|
||||
public async Task<Shared.Models.Account?> LookupAccount(string probe)
|
||||
{
|
||||
var account = await db.Accounts.Where(a => a.Name == probe).FirstOrDefaultAsync();
|
||||
if (account is not null) return account;
|
||||
|
||||
var contact = await db.AccountContacts
|
||||
.Where(c => c.Content == probe)
|
||||
.Include(c => c.Account)
|
||||
.FirstOrDefaultAsync();
|
||||
return contact?.Account;
|
||||
}
|
||||
|
||||
public async Task<Shared.Models.Account?> LookupAccountByConnection(string identifier, string provider)
|
||||
{
|
||||
var connection = await db.AccountConnections
|
||||
.Where(c => c.ProvidedIdentifier == identifier && c.Provider == provider)
|
||||
.Include(c => c.Account)
|
||||
.FirstOrDefaultAsync();
|
||||
return connection?.Account;
|
||||
}
|
||||
|
||||
public async Task<int?> GetAccountLevel(Guid accountId)
|
||||
{
|
||||
var profile = await db.AccountProfiles
|
||||
.Where(a => a.AccountId == accountId)
|
||||
.FirstOrDefaultAsync();
|
||||
return profile?.Level;
|
||||
}
|
||||
|
||||
public async Task<Shared.Models.Account> CreateAccount(
|
||||
string name,
|
||||
string nick,
|
||||
string email,
|
||||
string? password,
|
||||
string language = "en-US",
|
||||
bool isEmailVerified = false,
|
||||
bool isActivated = false
|
||||
)
|
||||
{
|
||||
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
var dupeNameCount = await db.Accounts.Where(a => a.Name == name).CountAsync();
|
||||
if (dupeNameCount > 0)
|
||||
throw new InvalidOperationException("Account name has already been taken.");
|
||||
|
||||
var account = new Shared.Models.Account
|
||||
{
|
||||
Name = name,
|
||||
Nick = nick,
|
||||
Language = language,
|
||||
Contacts = new List<AccountContact>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Type = AccountContactType.Email,
|
||||
Content = email,
|
||||
VerifiedAt = isEmailVerified ? SystemClock.Instance.GetCurrentInstant() : null,
|
||||
IsPrimary = true
|
||||
}
|
||||
},
|
||||
AuthFactors = password is not null
|
||||
? new List<AccountAuthFactor>
|
||||
{
|
||||
new AccountAuthFactor
|
||||
{
|
||||
Type = AccountAuthFactorType.Password,
|
||||
Secret = password,
|
||||
EnabledAt = SystemClock.Instance.GetCurrentInstant()
|
||||
}.HashSecret()
|
||||
}
|
||||
: [],
|
||||
Profile = new Profile()
|
||||
};
|
||||
|
||||
if (isActivated)
|
||||
{
|
||||
account.ActivatedAt = SystemClock.Instance.GetCurrentInstant();
|
||||
var defaultGroup = await db.PermissionGroups.FirstOrDefaultAsync(g => g.Key == "default");
|
||||
if (defaultGroup is not null)
|
||||
{
|
||||
db.PermissionGroupMembers.Add(new PermissionGroupMember
|
||||
{
|
||||
Actor = $"user:{account.Id}",
|
||||
Group = defaultGroup
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// var spell = await spells.CreateMagicSpell(
|
||||
// account,
|
||||
// MagicSpellType.AccountActivation,
|
||||
// new Dictionary<string, object>
|
||||
// {
|
||||
// { "contact_method", account.Contacts.First().Content }
|
||||
// }
|
||||
// );
|
||||
// await spells.NotifyMagicSpell(spell, true);
|
||||
}
|
||||
|
||||
db.Accounts.Add(account);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await transaction.CommitAsync();
|
||||
return account;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Shared.Models.Account> CreateAccount(OidcUserInfo userInfo)
|
||||
{
|
||||
if (string.IsNullOrEmpty(userInfo.Email))
|
||||
throw new ArgumentException("Email is required for account creation");
|
||||
|
||||
var displayName = !string.IsNullOrEmpty(userInfo.DisplayName)
|
||||
? userInfo.DisplayName
|
||||
: $"{userInfo.FirstName} {userInfo.LastName}".Trim();
|
||||
|
||||
// Generate username from email
|
||||
// var username = await uname.GenerateUsernameFromEmailAsync(userInfo.Email);
|
||||
var username = userInfo.Email.Split('@')[0]; // Placeholder
|
||||
|
||||
return await CreateAccount(
|
||||
username,
|
||||
displayName,
|
||||
userInfo.Email,
|
||||
null,
|
||||
"en-US",
|
||||
userInfo.EmailVerified,
|
||||
userInfo.EmailVerified
|
||||
);
|
||||
}
|
||||
|
||||
public async Task RequestAccountDeletion(Shared.Models.Account account)
|
||||
{
|
||||
// var spell = await spells.CreateMagicSpell(
|
||||
// account,
|
||||
// MagicSpellType.AccountRemoval,
|
||||
// new Dictionary<string, object>(),
|
||||
// SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)),
|
||||
// preventRepeat: true
|
||||
// );
|
||||
// await spells.NotifyMagicSpell(spell);
|
||||
}
|
||||
|
||||
public async Task RequestPasswordReset(Shared.Models.Account account)
|
||||
{
|
||||
// var spell = await spells.CreateMagicSpell(
|
||||
// account,
|
||||
// MagicSpellType.AuthPasswordReset,
|
||||
// new Dictionary<string, object>(),
|
||||
// SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)),
|
||||
// preventRepeat: true
|
||||
// );
|
||||
// await spells.NotifyMagicSpell(spell);
|
||||
}
|
||||
|
||||
public async Task<bool> CheckAuthFactorExists(Shared.Models.Account account, AccountAuthFactorType type)
|
||||
{
|
||||
var isExists = await db.AccountAuthFactors
|
||||
.Where(x => x.AccountId == account.Id && x.Type == type)
|
||||
.AnyAsync();
|
||||
return isExists;
|
||||
}
|
||||
|
||||
public async Task<AccountAuthFactor?> CreateAuthFactor(Shared.Models.Account account, AccountAuthFactorType type, string? secret)
|
||||
{
|
||||
AccountAuthFactor? factor = null;
|
||||
switch (type)
|
||||
{
|
||||
case AccountAuthFactorType.Password:
|
||||
if (string.IsNullOrWhiteSpace(secret)) throw new ArgumentNullException(nameof(secret));
|
||||
factor = new AccountAuthFactor
|
||||
{
|
||||
Type = AccountAuthFactorType.Password,
|
||||
Trustworthy = 1,
|
||||
AccountId = account.Id,
|
||||
Secret = secret,
|
||||
EnabledAt = SystemClock.Instance.GetCurrentInstant(),
|
||||
}.HashSecret();
|
||||
break;
|
||||
case AccountAuthFactorType.EmailCode:
|
||||
factor = new AccountAuthFactor
|
||||
{
|
||||
Type = AccountAuthFactorType.EmailCode,
|
||||
Trustworthy = 2,
|
||||
EnabledAt = SystemClock.Instance.GetCurrentInstant(),
|
||||
};
|
||||
break;
|
||||
case AccountAuthFactorType.InAppCode:
|
||||
factor = new AccountAuthFactor
|
||||
{
|
||||
Type = AccountAuthFactorType.InAppCode,
|
||||
Trustworthy = 1,
|
||||
EnabledAt = SystemClock.Instance.GetCurrentInstant()
|
||||
};
|
||||
break;
|
||||
case AccountAuthFactorType.TimedCode:
|
||||
var skOtp = KeyGeneration.GenerateRandomKey(20);
|
||||
var skOtp32 = Base32Encoding.ToString(skOtp);
|
||||
factor = new AccountAuthFactor
|
||||
{
|
||||
Secret = skOtp32,
|
||||
Type = AccountAuthFactorType.TimedCode,
|
||||
Trustworthy = 2,
|
||||
EnabledAt = null, // It needs to be tired once to enable
|
||||
CreatedResponse = new Dictionary<string, object>
|
||||
{
|
||||
["uri"] = new OtpUri(
|
||||
OtpType.Totp,
|
||||
skOtp32,
|
||||
account.Id.ToString(),
|
||||
"Solar Network"
|
||||
).ToString(),
|
||||
}
|
||||
};
|
||||
break;
|
||||
case AccountAuthFactorType.PinCode:
|
||||
if (string.IsNullOrWhiteSpace(secret)) throw new ArgumentNullException(nameof(secret));
|
||||
if (!secret.All(char.IsDigit) || secret.Length != 6)
|
||||
throw new ArgumentException("PIN code must be exactly 6 digits");
|
||||
factor = new AccountAuthFactor
|
||||
{
|
||||
Type = AccountAuthFactorType.PinCode,
|
||||
Trustworthy = 0, // Only for confirming, can't be used for login
|
||||
Secret = secret,
|
||||
EnabledAt = SystemClock.Instance.GetCurrentInstant(),
|
||||
}.HashSecret();
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(type), type, null);
|
||||
}
|
||||
|
||||
if (factor is null) throw new InvalidOperationException("Unable to create auth factor.");
|
||||
factor.AccountId = account.Id;
|
||||
db.AccountAuthFactors.Add(factor);
|
||||
await db.SaveChangesAsync();
|
||||
return factor;
|
||||
}
|
||||
|
||||
public async Task<AccountAuthFactor> EnableAuthFactor(AccountAuthFactor factor, string? code)
|
||||
{
|
||||
if (factor.EnabledAt is not null) throw new ArgumentException("The factor has been enabled.");
|
||||
if (factor.Type is AccountAuthFactorType.Password or AccountAuthFactorType.TimedCode)
|
||||
{
|
||||
if (code is null || !factor.VerifyPassword(code))
|
||||
throw new InvalidOperationException(
|
||||
"Invalid code, you need to enter the correct code to enable the factor."
|
||||
);
|
||||
}
|
||||
|
||||
factor.EnabledAt = SystemClock.Instance.GetCurrentInstant();
|
||||
db.Update(factor);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return factor;
|
||||
}
|
||||
|
||||
public async Task<AccountAuthFactor> DisableAuthFactor(AccountAuthFactor factor)
|
||||
{
|
||||
if (factor.EnabledAt is null) throw new ArgumentException("The factor has been disabled.");
|
||||
|
||||
var count = await db.AccountAuthFactors
|
||||
.Where(f => f.AccountId == factor.AccountId && f.EnabledAt != null)
|
||||
.CountAsync();
|
||||
if (count <= 1)
|
||||
throw new InvalidOperationException(
|
||||
"Disabling this auth factor will cause you have no active auth factors.");
|
||||
|
||||
factor.EnabledAt = null;
|
||||
db.Update(factor);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return factor;
|
||||
}
|
||||
|
||||
public async Task DeleteAuthFactor(AccountAuthFactor factor)
|
||||
{
|
||||
var count = await db.AccountAuthFactors
|
||||
.Where(f => f.AccountId == factor.AccountId)
|
||||
// .If(factor.EnabledAt is not null, q => q.Where(f => f.EnabledAt != null))
|
||||
.CountAsync();
|
||||
if (count <= 1)
|
||||
throw new InvalidOperationException("Deleting this auth factor will cause you have no auth factor.");
|
||||
|
||||
db.AccountAuthFactors.Remove(factor);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
/// <param name="account">The owner of the auth factor</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(Shared.Models.Account account, AccountAuthFactor factor, string? hint = null)
|
||||
{
|
||||
var code = new Random().Next(100000, 999999).ToString("000000");
|
||||
|
||||
switch (factor.Type)
|
||||
{
|
||||
case AccountAuthFactorType.InAppCode:
|
||||
if (await _GetFactorCode(factor) is not null)
|
||||
throw new InvalidOperationException("A factor code has been sent and in active duration.");
|
||||
|
||||
// await nty.SendNotification(
|
||||
// account,
|
||||
// "auth.verification",
|
||||
// localizer["AuthCodeTitle"],
|
||||
// null,
|
||||
// localizer["AuthCodeBody", code],
|
||||
// save: true
|
||||
// );
|
||||
await _SetFactorCode(factor, code, TimeSpan.FromMinutes(5));
|
||||
break;
|
||||
case AccountAuthFactorType.EmailCode:
|
||||
if (await _GetFactorCode(factor) is not null)
|
||||
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
|
||||
.Where(c => c.Type == AccountContactType.Email)
|
||||
.Where(c => c.VerifiedAt != null)
|
||||
.Where(c => EF.Functions.ILike(c.Content, $"%{hint}%"))
|
||||
.Include(c => c.Account)
|
||||
.FirstOrDefaultAsync();
|
||||
if (contact is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Unable to send factor code to #{FactorId} with hint {Hint}, due to no contact method found according to hint...",
|
||||
factor.Id,
|
||||
hint
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// await mailer.SendTemplatedEmailAsync<DysonNetwork.Sphere.Pages.Emails.VerificationEmail, VerificationEmailModel>(
|
||||
// account.Nick,
|
||||
// contact.Content,
|
||||
// localizer["VerificationEmail"],
|
||||
// new VerificationEmailModel
|
||||
// {
|
||||
// Name = account.Name,
|
||||
// Code = code
|
||||
// }
|
||||
// );
|
||||
|
||||
await _SetFactorCode(factor, code, TimeSpan.FromMinutes(30));
|
||||
break;
|
||||
case AccountAuthFactorType.Password:
|
||||
case AccountAuthFactorType.TimedCode:
|
||||
default:
|
||||
// No need to send, such as password etc...
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> VerifyFactorCode(AccountAuthFactor factor, string code)
|
||||
{
|
||||
switch (factor.Type)
|
||||
{
|
||||
case AccountAuthFactorType.EmailCode:
|
||||
case AccountAuthFactorType.InAppCode:
|
||||
var correctCode = await _GetFactorCode(factor);
|
||||
var isCorrect = correctCode is not null &&
|
||||
string.Equals(correctCode, code, StringComparison.OrdinalIgnoreCase);
|
||||
await cache.RemoveAsync($"{AuthFactorCachePrefix}{factor.Id}:code");
|
||||
return isCorrect;
|
||||
case AccountAuthFactorType.Password:
|
||||
case AccountAuthFactorType.TimedCode:
|
||||
default:
|
||||
return factor.VerifyPassword(code);
|
||||
}
|
||||
}
|
||||
|
||||
private const string AuthFactorCachePrefix = "authfactor:";
|
||||
|
||||
private async Task _SetFactorCode(AccountAuthFactor factor, string code, TimeSpan expires)
|
||||
{
|
||||
await cache.SetAsync(
|
||||
$"{AuthFactorCachePrefix}{factor.Id}:code",
|
||||
code,
|
||||
expires
|
||||
);
|
||||
}
|
||||
|
||||
private async Task<string?> _GetFactorCode(AccountAuthFactor factor)
|
||||
{
|
||||
return await cache.GetAsync<string?>(
|
||||
$"{AuthFactorCachePrefix}{factor.Id}:code"
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<Session> UpdateSessionLabel(Shared.Models.Account account, Guid sessionId, string label)
|
||||
{
|
||||
var session = await db.AuthSessions
|
||||
.Include(s => s.Challenge)
|
||||
.Where(s => s.Id == sessionId && s.AccountId == account.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
if (session is null) throw new InvalidOperationException("Session was not found.");
|
||||
|
||||
await db.AuthSessions
|
||||
.Include(s => s.Challenge)
|
||||
.Where(s => s.Challenge.DeviceId == session.Challenge.DeviceId)
|
||||
.ExecuteUpdateAsync(p => p.SetProperty(s => s.Label, label));
|
||||
|
||||
var sessions = await db.AuthSessions
|
||||
.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(Shared.Models.Account account, Guid sessionId)
|
||||
{
|
||||
var session = await db.AuthSessions
|
||||
.Include(s => s.Challenge)
|
||||
.Where(s => s.Id == sessionId && s.AccountId == account.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
if (session is null) throw new InvalidOperationException("Session was not found.");
|
||||
|
||||
var sessions = await db.AuthSessions
|
||||
.Include(s => s.Challenge)
|
||||
.Where(s => s.AccountId == session.Id && s.Challenge.DeviceId == session.Challenge.DeviceId)
|
||||
.ToListAsync();
|
||||
|
||||
if (session.Challenge.DeviceId is not null)
|
||||
// await nty.UnsubscribePushNotifications(session.Challenge.DeviceId);
|
||||
|
||||
// The current session should be included in the sessions' list
|
||||
await db.AuthSessions
|
||||
.Include(s => s.Challenge)
|
||||
.Where(s => s.Challenge.DeviceId == session.Challenge.DeviceId)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
foreach (var item in sessions)
|
||||
await cache.RemoveAsync($"{DysonTokenAuthHandler.AuthCachePrefix}{item.Id}");
|
||||
}
|
||||
|
||||
public async Task<AccountContact> CreateContactMethod(Shared.Models.Account account, AccountContactType type, string content)
|
||||
{
|
||||
var contact = new AccountContact
|
||||
{
|
||||
Type = type,
|
||||
Content = content,
|
||||
AccountId = account.Id,
|
||||
};
|
||||
|
||||
db.AccountContacts.Add(contact);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return contact;
|
||||
}
|
||||
|
||||
public async Task VerifyContactMethod(Shared.Models.Account account, AccountContact contact)
|
||||
{
|
||||
// var spell = await spells.CreateMagicSpell(
|
||||
// account,
|
||||
// MagicSpellType.ContactVerification,
|
||||
// new Dictionary<string, object> { { "contact_method", contact.Content } },
|
||||
// expiredAt: SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)),
|
||||
// preventRepeat: true
|
||||
// );
|
||||
// await spells.NotifyMagicSpell(spell);
|
||||
}
|
||||
|
||||
public async Task<AccountContact> SetContactMethodPrimary(Shared.Models.Account account, AccountContact contact)
|
||||
{
|
||||
if (contact.AccountId != account.Id)
|
||||
throw new InvalidOperationException("Contact method does not belong to this account.");
|
||||
if (contact.VerifiedAt is null)
|
||||
throw new InvalidOperationException("Cannot set unverified contact method as primary.");
|
||||
|
||||
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||
|
||||
try
|
||||
{
|
||||
await db.AccountContacts
|
||||
.Where(c => c.AccountId == account.Id && c.Type == contact.Type)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.IsPrimary, false));
|
||||
|
||||
contact.IsPrimary = true;
|
||||
db.AccountContacts.Update(contact);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await transaction.CommitAsync();
|
||||
return contact;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteContactMethod(Shared.Models.Account account, AccountContact contact)
|
||||
{
|
||||
if (contact.AccountId != account.Id)
|
||||
throw new InvalidOperationException("Contact method does not belong to this account.");
|
||||
if (contact.IsPrimary)
|
||||
throw new InvalidOperationException("Cannot delete primary contact method.");
|
||||
|
||||
db.AccountContacts.Remove(contact);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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(Shared.Models.Account account, Badge badge)
|
||||
{
|
||||
badge.AccountId = account.Id;
|
||||
db.AccountBadges.Add(badge);
|
||||
await db.SaveChangesAsync();
|
||||
return badge;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This method will revoke a badge from the account.
|
||||
/// Shouldn't be exposed to normal user and the user itself.
|
||||
/// </summary>
|
||||
public async Task RevokeBadge(Shared.Models.Account account, Guid badgeId)
|
||||
{
|
||||
var badge = await db.AccountBadges
|
||||
.Where(b => b.AccountId == account.Id && b.Id == badgeId)
|
||||
.OrderByDescending(b => b.CreatedAt)
|
||||
.FirstOrDefaultAsync();
|
||||
if (badge is null) throw new InvalidOperationException("Badge was not found.");
|
||||
|
||||
var profile = await db.AccountProfiles
|
||||
.Where(p => p.AccountId == account.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
if (profile?.ActiveBadge is not null && profile.ActiveBadge.Id == badge.Id)
|
||||
profile.ActiveBadge = null;
|
||||
|
||||
db.Remove(badge);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task ActiveBadge(Shared.Models.Account account, Guid badgeId)
|
||||
{
|
||||
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var badge = await db.AccountBadges
|
||||
.Where(b => b.AccountId == account.Id && b.Id == badgeId)
|
||||
.OrderByDescending(b => b.CreatedAt)
|
||||
.FirstOrDefaultAsync();
|
||||
if (badge is null) throw new InvalidOperationException("Badge was not found.");
|
||||
|
||||
await db.AccountBadges
|
||||
.Where(b => b.AccountId == account.Id && b.Id != badgeId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(p => p.ActivatedAt, p => null));
|
||||
|
||||
badge.ActivatedAt = SystemClock.Instance.GetCurrentInstant();
|
||||
db.Update(badge);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await db.AccountProfiles
|
||||
.Where(p => p.AccountId == account.Id)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(p => p.ActiveBadge, badge.ToReference()));
|
||||
await PurgeAccountCache(account);
|
||||
|
||||
await transaction.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The maintenance method for server administrator.
|
||||
/// To check every user has an account profile and to create them if it isn't having one.
|
||||
/// </summary>
|
||||
public async Task EnsureAccountProfileCreated()
|
||||
{
|
||||
var accountsId = await db.Accounts.Select(a => a.Id).ToListAsync();
|
||||
var existingId = await db.AccountProfiles.Select(p => p.AccountId).ToListAsync();
|
||||
var missingId = accountsId.Except(existingId).ToList();
|
||||
|
||||
if (missingId.Count != 0)
|
||||
{
|
||||
var newProfiles = missingId.Select(id => new Profile { Id = Guid.NewGuid(), AccountId = id }).ToList();
|
||||
await db.BulkInsertAsync(newProfiles);
|
||||
}
|
||||
}
|
||||
}
|
105
DysonNetwork.Pass/Account/AccountUsernameService.cs
Normal file
105
DysonNetwork.Pass/Account/AccountUsernameService.cs
Normal file
@ -0,0 +1,105 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DysonNetwork.Pass.Account;
|
||||
|
||||
/// <summary>
|
||||
/// Service for handling username generation and validation
|
||||
/// </summary>
|
||||
public class AccountUsernameService(AppDatabase db)
|
||||
{
|
||||
private readonly Random _random = new();
|
||||
|
||||
/// <summary>
|
||||
/// Generates a unique username based on the provided base name
|
||||
/// </summary>
|
||||
/// <param name="baseName">The preferred username</param>
|
||||
/// <returns>A unique username</returns>
|
||||
public async Task<string> GenerateUniqueUsernameAsync(string baseName)
|
||||
{
|
||||
// Sanitize the base name
|
||||
var sanitized = SanitizeUsername(baseName);
|
||||
|
||||
// If the base name is empty after sanitization, use a default
|
||||
if (string.IsNullOrEmpty(sanitized))
|
||||
{
|
||||
sanitized = "user";
|
||||
}
|
||||
|
||||
// Check if the sanitized name is available
|
||||
if (!await IsUsernameExistsAsync(sanitized))
|
||||
{
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
// Try up to 10 times with random numbers
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
var suffix = _random.Next(1000, 9999);
|
||||
var candidate = $"{sanitized}{suffix}";
|
||||
|
||||
if (!await IsUsernameExistsAsync(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
// If all attempts fail, use a timestamp
|
||||
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
return $"{sanitized}{timestamp}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sanitizes a username by removing invalid characters and converting to lowercase
|
||||
/// </summary>
|
||||
public string SanitizeUsername(string username)
|
||||
{
|
||||
if (string.IsNullOrEmpty(username))
|
||||
return string.Empty;
|
||||
|
||||
// Replace spaces and special characters with underscores
|
||||
var sanitized = Regex.Replace(username, @"[^a-zA-Z0-9_\-]", "");
|
||||
|
||||
// Convert to lowercase
|
||||
sanitized = sanitized.ToLowerInvariant();
|
||||
|
||||
// Ensure it starts with a letter
|
||||
if (sanitized.Length > 0 && !char.IsLetter(sanitized[0]))
|
||||
{
|
||||
sanitized = "u" + sanitized;
|
||||
}
|
||||
|
||||
// Truncate if too long
|
||||
if (sanitized.Length > 30)
|
||||
{
|
||||
sanitized = sanitized[..30];
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a username already exists
|
||||
/// </summary>
|
||||
public async Task<bool> IsUsernameExistsAsync(string username)
|
||||
{
|
||||
return await db.Accounts.AnyAsync(a => a.Name == username);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a username from an email address
|
||||
/// </summary>
|
||||
/// <param name="email">The email address to generate a username from</param>
|
||||
/// <returns>A unique username derived from the email</returns>
|
||||
public async Task<string> GenerateUsernameFromEmailAsync(string email)
|
||||
{
|
||||
if (string.IsNullOrEmpty(email))
|
||||
return await GenerateUniqueUsernameAsync("user");
|
||||
|
||||
// Extract the local part of the email (before the @)
|
||||
var localPart = email.Split('@')[0];
|
||||
|
||||
// Use the local part as the base for username generation
|
||||
return await GenerateUniqueUsernameAsync(localPart);
|
||||
}
|
||||
}
|
47
DysonNetwork.Pass/Account/ActionLogService.cs
Normal file
47
DysonNetwork.Pass/Account/ActionLogService.cs
Normal file
@ -0,0 +1,47 @@
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace DysonNetwork.Pass.Account;
|
||||
|
||||
public class ActionLogService(
|
||||
// GeoIpService geo,
|
||||
// FlushBufferService fbs
|
||||
)
|
||||
{
|
||||
public void CreateActionLog(Guid accountId, string action, Dictionary<string, object> meta)
|
||||
{
|
||||
var log = new ActionLog
|
||||
{
|
||||
Action = action,
|
||||
AccountId = accountId,
|
||||
Meta = meta,
|
||||
};
|
||||
|
||||
// fbs.Enqueue(log);
|
||||
}
|
||||
|
||||
public void CreateActionLogFromRequest(string action, Dictionary<string, object> meta, HttpRequest request,
|
||||
Shared.Models.Account? account = null)
|
||||
{
|
||||
var log = new ActionLog
|
||||
{
|
||||
Action = action,
|
||||
Meta = meta,
|
||||
UserAgent = request.Headers.UserAgent,
|
||||
IpAddress = request.HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||
// Location = geo.GetPointFromIp(request.HttpContext.Connection.RemoteIpAddress?.ToString())
|
||||
};
|
||||
|
||||
if (request.HttpContext.Items["CurrentUser"] is Shared.Models.Account currentUser)
|
||||
log.AccountId = currentUser.Id;
|
||||
else if (account != null)
|
||||
log.AccountId = account.Id;
|
||||
else
|
||||
throw new ArgumentException("No user context was found");
|
||||
|
||||
if (request.HttpContext.Items["CurrentSession"] is Session currentSession)
|
||||
log.SessionId = currentSession.Id;
|
||||
|
||||
// fbs.Enqueue(log);
|
||||
}
|
||||
}
|
30
DysonNetwork.Pass/Account/MagicSpell.cs
Normal file
30
DysonNetwork.Pass/Account/MagicSpell.cs
Normal file
@ -0,0 +1,30 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Pass.Account;
|
||||
|
||||
public enum MagicSpellType
|
||||
{
|
||||
AccountActivation,
|
||||
AccountDeactivation,
|
||||
AccountRemoval,
|
||||
AuthPasswordReset,
|
||||
ContactVerification,
|
||||
}
|
||||
|
||||
[Index(nameof(Spell), IsUnique = true)]
|
||||
public class MagicSpell : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
[JsonIgnore] [MaxLength(1024)] public string Spell { get; set; } = null!;
|
||||
public MagicSpellType Type { get; set; }
|
||||
public Instant? ExpiresAt { get; set; }
|
||||
public Instant? AffectedAt { get; set; }
|
||||
[Column(TypeName = "jsonb")] public Dictionary<string, object> Meta { get; set; } = new();
|
||||
|
||||
public Guid? AccountId { get; set; }
|
||||
public Shared.Models.Account? Account { get; set; }
|
||||
}
|
19
DysonNetwork.Pass/Account/MagicSpellController.cs
Normal file
19
DysonNetwork.Pass/Account/MagicSpellController.cs
Normal file
@ -0,0 +1,19 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DysonNetwork.Pass.Account;
|
||||
|
||||
[ApiController]
|
||||
[Route("/spells")]
|
||||
public class MagicSpellController(AppDatabase db, MagicSpellService sp) : ControllerBase
|
||||
{
|
||||
[HttpPost("{spellId:guid}/resend")]
|
||||
public async Task<ActionResult> ResendMagicSpell(Guid spellId)
|
||||
{
|
||||
var spell = db.MagicSpells.FirstOrDefault(x => x.Id == spellId);
|
||||
if (spell == null)
|
||||
return NotFound();
|
||||
|
||||
await sp.NotifyMagicSpell(spell, true);
|
||||
return Ok();
|
||||
}
|
||||
}
|
250
DysonNetwork.Pass/Account/MagicSpellService.cs
Normal file
250
DysonNetwork.Pass/Account/MagicSpellService.cs
Normal file
@ -0,0 +1,250 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using NodaTime;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DysonNetwork.Pass.Account;
|
||||
|
||||
public class MagicSpellService(
|
||||
AppDatabase db,
|
||||
// EmailService email,
|
||||
IConfiguration configuration,
|
||||
ILogger<MagicSpellService> logger
|
||||
// IStringLocalizer<Localization.EmailResource> localizer
|
||||
)
|
||||
{
|
||||
public async Task<MagicSpell> CreateMagicSpell(
|
||||
Shared.Models.Account account,
|
||||
MagicSpellType type,
|
||||
Dictionary<string, object> meta,
|
||||
Instant? expiredAt = null,
|
||||
Instant? affectedAt = null,
|
||||
bool preventRepeat = false
|
||||
)
|
||||
{
|
||||
if (preventRepeat)
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var existingSpell = await db.MagicSpells
|
||||
.Where(s => s.AccountId == account.Id)
|
||||
.Where(s => s.Type == type)
|
||||
.Where(s => s.ExpiresAt == null || s.ExpiresAt > now)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (existingSpell != null)
|
||||
{
|
||||
throw new InvalidOperationException($"Account already has an active magic spell of type {type}");
|
||||
}
|
||||
}
|
||||
|
||||
var spellWord = _GenerateRandomString(128);
|
||||
var spell = new MagicSpell
|
||||
{
|
||||
Spell = spellWord,
|
||||
Type = type,
|
||||
ExpiresAt = expiredAt,
|
||||
AffectedAt = affectedAt,
|
||||
AccountId = account.Id,
|
||||
Meta = meta
|
||||
};
|
||||
|
||||
db.MagicSpells.Add(spell);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return spell;
|
||||
}
|
||||
|
||||
public async Task NotifyMagicSpell(MagicSpell spell, bool bypassVerify = false)
|
||||
{
|
||||
var contact = await db.AccountContacts
|
||||
.Where(c => c.Account.Id == spell.AccountId)
|
||||
.Where(c => c.Type == AccountContactType.Email)
|
||||
.Where(c => c.VerifiedAt != null || bypassVerify)
|
||||
.OrderByDescending(c => c.IsPrimary)
|
||||
.Include(c => c.Account)
|
||||
.FirstOrDefaultAsync();
|
||||
if (contact is null) throw new ArgumentException("Account has no contact method that can use");
|
||||
|
||||
var link = $"{configuration.GetValue<string>("BaseUrl")}/spells/{Uri.EscapeDataString(spell.Spell)}";
|
||||
|
||||
logger.LogInformation("Sending magic spell... {Link}", link);
|
||||
|
||||
var accountLanguage = await db.Accounts
|
||||
.Where(a => a.Id == spell.AccountId)
|
||||
.Select(a => a.Language)
|
||||
.FirstOrDefaultAsync();
|
||||
AccountService.SetCultureInfo(accountLanguage);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
catch (Exception err)
|
||||
{
|
||||
logger.LogError($"Error sending magic spell (${spell.Spell})... {err}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ApplyMagicSpell(MagicSpell spell)
|
||||
{
|
||||
switch (spell.Type)
|
||||
{
|
||||
case MagicSpellType.AuthPasswordReset:
|
||||
throw new ArgumentException(
|
||||
"For password reset spell, please use the ApplyPasswordReset method instead."
|
||||
);
|
||||
case MagicSpellType.AccountRemoval:
|
||||
var account = await db.Accounts.FirstOrDefaultAsync(c => c.Id == spell.AccountId);
|
||||
if (account is null) break;
|
||||
db.Accounts.Remove(account);
|
||||
break;
|
||||
case MagicSpellType.AccountActivation:
|
||||
var contactMethod = (spell.Meta["contact_method"] as JsonElement? ?? default).ToString();
|
||||
var contact = await
|
||||
db.AccountContacts.FirstOrDefaultAsync(c =>
|
||||
c.Content == contactMethod
|
||||
);
|
||||
if (contact is not null)
|
||||
{
|
||||
contact.VerifiedAt = SystemClock.Instance.GetCurrentInstant();
|
||||
db.Update(contact);
|
||||
}
|
||||
|
||||
account = await db.Accounts.FirstOrDefaultAsync(c => c.Id == spell.AccountId);
|
||||
if (account is not null)
|
||||
{
|
||||
account.ActivatedAt = SystemClock.Instance.GetCurrentInstant();
|
||||
db.Update(account);
|
||||
}
|
||||
|
||||
var defaultGroup = await db.PermissionGroups.FirstOrDefaultAsync(g => g.Key == "default");
|
||||
if (defaultGroup is not null && account is not null)
|
||||
{
|
||||
db.PermissionGroupMembers.Add(new PermissionGroupMember
|
||||
{
|
||||
Actor = $"user:{account.Id}",
|
||||
Group = defaultGroup
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
case MagicSpellType.ContactVerification:
|
||||
var verifyContactMethod = (spell.Meta["contact_method"] as JsonElement? ?? default).ToString();
|
||||
var verifyContact = await db.AccountContacts
|
||||
.FirstOrDefaultAsync(c => c.Content == verifyContactMethod);
|
||||
if (verifyContact is not null)
|
||||
{
|
||||
verifyContact.VerifiedAt = SystemClock.Instance.GetCurrentInstant();
|
||||
db.Update(verifyContact);
|
||||
}
|
||||
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException();
|
||||
}
|
||||
|
||||
db.Remove(spell);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task ApplyPasswordReset(MagicSpell spell, string newPassword)
|
||||
{
|
||||
if (spell.Type != MagicSpellType.AuthPasswordReset)
|
||||
throw new ArgumentException("This spell is not a password reset spell.");
|
||||
|
||||
var passwordFactor = await db.AccountAuthFactors
|
||||
.Include(f => f.Account)
|
||||
.Where(f => f.Type == AccountAuthFactorType.Password && f.Account.Id == spell.AccountId)
|
||||
.FirstOrDefaultAsync();
|
||||
if (passwordFactor is null)
|
||||
{
|
||||
var account = await db.Accounts.FirstOrDefaultAsync(c => c.Id == spell.AccountId);
|
||||
if (account is null) throw new InvalidOperationException("Both account and auth factor was not found.");
|
||||
passwordFactor = new AccountAuthFactor
|
||||
{
|
||||
Type = AccountAuthFactorType.Password,
|
||||
Account = account,
|
||||
Secret = newPassword
|
||||
}.HashSecret();
|
||||
db.AccountAuthFactors.Add(passwordFactor);
|
||||
}
|
||||
else
|
||||
{
|
||||
passwordFactor.Secret = newPassword;
|
||||
passwordFactor.HashSecret();
|
||||
db.Update(passwordFactor);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static string _GenerateRandomString(int length)
|
||||
{
|
||||
using var rng = RandomNumberGenerator.Create();
|
||||
var randomBytes = new byte[length];
|
||||
rng.GetBytes(randomBytes);
|
||||
|
||||
var base64String = Convert.ToBase64String(randomBytes);
|
||||
|
||||
return base64String.Substring(0, length);
|
||||
}
|
||||
}
|
167
DysonNetwork.Pass/Account/NotificationController.cs
Normal file
167
DysonNetwork.Pass/Account/NotificationController.cs
Normal file
@ -0,0 +1,167 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using DysonNetwork.Pass.Auth;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace DysonNetwork.Pass.Account;
|
||||
|
||||
[ApiController]
|
||||
[Route("/notifications")]
|
||||
public class NotificationController(AppDatabase db, NotificationService nty) : ControllerBase
|
||||
{
|
||||
[HttpGet("count")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<int>> CountUnreadNotifications()
|
||||
{
|
||||
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
|
||||
if (currentUserValue is not Shared.Models.Account currentUser) return Unauthorized();
|
||||
|
||||
var count = await db.Notifications
|
||||
.Where(s => s.AccountId == currentUser.Id && s.ViewedAt == null)
|
||||
.CountAsync();
|
||||
return Ok(count);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<List<Notification>>> ListNotifications(
|
||||
[FromQuery] int offset = 0,
|
||||
// The page size set to 5 is to avoid the client pulled the notification
|
||||
// but didn't render it in the screen-viewable region.
|
||||
[FromQuery] int take = 5
|
||||
)
|
||||
{
|
||||
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
|
||||
if (currentUserValue is not Shared.Models.Account currentUser) return Unauthorized();
|
||||
|
||||
var totalCount = await db.Notifications
|
||||
.Where(s => s.AccountId == currentUser.Id)
|
||||
.CountAsync();
|
||||
var notifications = await db.Notifications
|
||||
.Where(s => s.AccountId == currentUser.Id)
|
||||
.OrderByDescending(e => e.CreatedAt)
|
||||
.Skip(offset)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
|
||||
Response.Headers["X-Total"] = totalCount.ToString();
|
||||
// await nty.MarkNotificationsViewed(notifications);
|
||||
|
||||
return Ok(notifications);
|
||||
}
|
||||
|
||||
public class PushNotificationSubscribeRequest
|
||||
{
|
||||
[MaxLength(4096)] public string DeviceToken { get; set; } = null!;
|
||||
public NotificationPushProvider Provider { get; set; }
|
||||
}
|
||||
|
||||
[HttpPut("subscription")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<NotificationPushSubscription>> SubscribeToPushNotification(
|
||||
[FromBody] PushNotificationSubscribeRequest request
|
||||
)
|
||||
{
|
||||
HttpContext.Items.TryGetValue("CurrentSession", out var currentSessionValue);
|
||||
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
|
||||
var currentUser = currentUserValue as Shared.Models.Account;
|
||||
if (currentUser == null) return Unauthorized();
|
||||
var currentSession = currentSessionValue as Session;
|
||||
if (currentSession == null) return Unauthorized();
|
||||
|
||||
// var result =
|
||||
// await nty.SubscribePushNotification(currentUser, request.Provider, currentSession.Challenge.DeviceId!,
|
||||
// request.DeviceToken);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpDelete("subscription")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<int>> UnsubscribeFromPushNotification()
|
||||
{
|
||||
HttpContext.Items.TryGetValue("CurrentSession", out var currentSessionValue);
|
||||
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
|
||||
var currentUser = currentUserValue as Shared.Models.Account;
|
||||
if (currentUser == null) return Unauthorized();
|
||||
var currentSession = currentSessionValue as Session;
|
||||
if (currentSession == null) return Unauthorized();
|
||||
|
||||
var affectedRows = await db.NotificationPushSubscriptions
|
||||
.Where(s =>
|
||||
s.AccountId == currentUser.Id &&
|
||||
s.DeviceId == currentSession.Challenge.DeviceId
|
||||
).ExecuteDeleteAsync();
|
||||
return Ok(affectedRows);
|
||||
}
|
||||
|
||||
public class NotificationRequest
|
||||
{
|
||||
[Required] [MaxLength(1024)] public string Topic { get; set; } = null!;
|
||||
[Required] [MaxLength(1024)] public string Title { get; set; } = null!;
|
||||
[MaxLength(2048)] public string? Subtitle { get; set; }
|
||||
[Required] [MaxLength(4096)] public string Content { get; set; } = null!;
|
||||
public Dictionary<string, object>? Meta { get; set; }
|
||||
public int Priority { get; set; } = 10;
|
||||
}
|
||||
|
||||
[HttpPost("broadcast")]
|
||||
[Authorize]
|
||||
// [RequiredPermission("global", "notifications.broadcast")]
|
||||
public async Task<ActionResult> BroadcastNotification(
|
||||
[FromBody] NotificationRequest request,
|
||||
[FromQuery] bool save = false
|
||||
)
|
||||
{
|
||||
// await nty.BroadcastNotification(
|
||||
// new Notification
|
||||
// {
|
||||
// CreatedAt = SystemClock.Instance.GetCurrentInstant(),
|
||||
// UpdatedAt = SystemClock.Instance.GetCurrentInstant(),
|
||||
// Topic = request.Topic,
|
||||
// Title = request.Title,
|
||||
// Subtitle = request.Subtitle,
|
||||
// Content = request.Content,
|
||||
// Meta = request.Meta,
|
||||
// Priority = request.Priority,
|
||||
// },
|
||||
// save
|
||||
// );
|
||||
return Ok();
|
||||
}
|
||||
|
||||
public class NotificationWithAimRequest : NotificationRequest
|
||||
{
|
||||
[Required] public List<Guid> AccountId { get; set; } = null!;
|
||||
}
|
||||
|
||||
[HttpPost("send")]
|
||||
[Authorize]
|
||||
// [RequiredPermission("global", "notifications.send")]
|
||||
public async Task<ActionResult> SendNotification(
|
||||
[FromBody] NotificationWithAimRequest request,
|
||||
[FromQuery] bool save = false
|
||||
)
|
||||
{
|
||||
var accounts = await db.Accounts.Where(a => request.AccountId.Contains(a.Id)).ToListAsync();
|
||||
// await nty.SendNotificationBatch(
|
||||
// new Notification
|
||||
// {
|
||||
// CreatedAt = SystemClock.Instance.GetCurrentInstant(),
|
||||
// UpdatedAt = SystemClock.Instance.GetCurrentInstant(),
|
||||
// Topic = request.Topic,
|
||||
// Title = request.Title,
|
||||
// Subtitle = request.Subtitle,
|
||||
// Content = request.Content,
|
||||
// Meta = request.Meta,
|
||||
// },
|
||||
// accounts,
|
||||
// save
|
||||
// );
|
||||
return Ok();
|
||||
}
|
||||
}
|
311
DysonNetwork.Pass/Account/NotificationService.cs
Normal file
311
DysonNetwork.Pass/Account/NotificationService.cs
Normal file
@ -0,0 +1,311 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using EFCore.BulkExtensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace DysonNetwork.Pass.Account;
|
||||
|
||||
public class NotificationService(
|
||||
AppDatabase db
|
||||
// WebSocketService ws,
|
||||
// IHttpClientFactory httpFactory,
|
||||
// IConfiguration config
|
||||
)
|
||||
{
|
||||
// private readonly string _notifyTopic = config["Notifications:Topic"]!;
|
||||
// private readonly Uri _notifyEndpoint = new(config["Notifications:Endpoint"]!);
|
||||
|
||||
public async Task UnsubscribePushNotifications(string deviceId)
|
||||
{
|
||||
// await db.NotificationPushSubscriptions
|
||||
// .Where(s => s.DeviceId == deviceId)
|
||||
// .ExecuteDeleteAsync();
|
||||
}
|
||||
|
||||
public async Task<NotificationPushSubscription> SubscribePushNotification(
|
||||
Shared.Models.Account account,
|
||||
NotificationPushProvider provider,
|
||||
string deviceId,
|
||||
string deviceToken
|
||||
)
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
// First check if a matching subscription exists
|
||||
// var existingSubscription = await db.NotificationPushSubscriptions
|
||||
// .Where(s => s.AccountId == account.Id)
|
||||
// .Where(s => s.DeviceId == deviceId || s.DeviceToken == deviceToken)
|
||||
// .FirstOrDefaultAsync();
|
||||
|
||||
// if (existingSubscription is not null)
|
||||
// {
|
||||
// // Update the existing subscription directly in the database
|
||||
// await db.NotificationPushSubscriptions
|
||||
// .Where(s => s.Id == existingSubscription.Id)
|
||||
// .ExecuteUpdateAsync(setters => setters
|
||||
// .SetProperty(s => s.DeviceId, deviceId)
|
||||
// .SetProperty(s => s.DeviceToken, deviceToken)
|
||||
// .SetProperty(s => s.UpdatedAt, now));
|
||||
|
||||
// // Return the updated subscription
|
||||
// existingSubscription.DeviceId = deviceId;
|
||||
// existingSubscription.DeviceToken = deviceToken;
|
||||
// existingSubscription.UpdatedAt = now;
|
||||
// return existingSubscription;
|
||||
// }
|
||||
|
||||
var subscription = new NotificationPushSubscription
|
||||
{
|
||||
DeviceId = deviceId,
|
||||
DeviceToken = deviceToken,
|
||||
Provider = provider,
|
||||
AccountId = account.Id,
|
||||
};
|
||||
|
||||
// db.NotificationPushSubscriptions.Add(subscription);
|
||||
// await db.SaveChangesAsync();
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
public async Task<Notification> SendNotification(
|
||||
Shared.Models.Account account,
|
||||
string topic,
|
||||
string? title = null,
|
||||
string? subtitle = null,
|
||||
string? content = null,
|
||||
Dictionary<string, object>? meta = null,
|
||||
string? actionUri = null,
|
||||
bool isSilent = false,
|
||||
bool save = true
|
||||
)
|
||||
{
|
||||
if (title is null && subtitle is null && content is null)
|
||||
throw new ArgumentException("Unable to send notification that completely empty.");
|
||||
|
||||
meta ??= new Dictionary<string, object>();
|
||||
if (actionUri is not null) meta["action_uri"] = actionUri;
|
||||
|
||||
var notification = new Notification
|
||||
{
|
||||
Topic = topic,
|
||||
Title = title,
|
||||
Subtitle = subtitle,
|
||||
Content = content,
|
||||
Meta = meta,
|
||||
AccountId = account.Id,
|
||||
};
|
||||
|
||||
if (save)
|
||||
{
|
||||
// db.Add(notification);
|
||||
// await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
if (!isSilent) Console.WriteLine("Simulating notification delivery."); // _ = DeliveryNotification(notification);
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
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)
|
||||
// .ToListAsync();
|
||||
|
||||
// await _PushNotification(notification, subscribers);
|
||||
}
|
||||
|
||||
public async Task MarkNotificationsViewed(ICollection<Notification> notifications)
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var id = notifications.Where(n => n.ViewedAt == null).Select(n => n.Id).ToList();
|
||||
if (id.Count == 0) return;
|
||||
|
||||
// await db.Notifications
|
||||
// .Where(n => id.Contains(n.Id))
|
||||
// .ExecuteUpdateAsync(s => s.SetProperty(n => n.ViewedAt, now)
|
||||
// );
|
||||
}
|
||||
|
||||
public async Task BroadcastNotification(Notification notification, bool save = false)
|
||||
{
|
||||
var accounts = new List<Shared.Models.Account>(); // await db.Accounts.ToListAsync();
|
||||
|
||||
if (save)
|
||||
{
|
||||
var notifications = accounts.Select(x =>
|
||||
{
|
||||
var newNotification = new Notification
|
||||
{
|
||||
Topic = notification.Topic,
|
||||
Title = notification.Title,
|
||||
Subtitle = notification.Subtitle,
|
||||
Content = notification.Content,
|
||||
Meta = notification.Meta,
|
||||
Priority = notification.Priority,
|
||||
Account = x,
|
||||
AccountId = x.Id
|
||||
};
|
||||
return newNotification;
|
||||
}).ToList();
|
||||
// await db.BulkInsertAsync(notifications);
|
||||
}
|
||||
|
||||
foreach (var account in accounts)
|
||||
{
|
||||
notification.Account = account;
|
||||
notification.AccountId = account.Id;
|
||||
// ws.SendPacketToAccount(account.Id, new WebSocketPacket
|
||||
// {
|
||||
// Type = "notifications.new",
|
||||
// Data = notification
|
||||
// });
|
||||
}
|
||||
|
||||
// var subscribers = await db.NotificationPushSubscriptions
|
||||
// .ToListAsync();
|
||||
// await _PushNotification(notification, subscribers);
|
||||
}
|
||||
|
||||
public async Task SendNotificationBatch(Notification notification, List<Shared.Models.Account> accounts, bool save = false)
|
||||
{
|
||||
if (save)
|
||||
{
|
||||
var notifications = accounts.Select(x =>
|
||||
{
|
||||
var newNotification = new Notification
|
||||
{
|
||||
Topic = notification.Topic,
|
||||
Title = notification.Title,
|
||||
Subtitle = notification.Subtitle,
|
||||
Content = notification.Content,
|
||||
Meta = notification.Meta,
|
||||
Priority = notification.Priority,
|
||||
Account = x,
|
||||
AccountId = x.Id
|
||||
};
|
||||
return newNotification;
|
||||
}).ToList();
|
||||
// await db.BulkInsertAsync(notifications);
|
||||
}
|
||||
|
||||
foreach (var account in accounts)
|
||||
{
|
||||
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();
|
||||
// var subscribers = await db.NotificationPushSubscriptions
|
||||
// .Where(s => accountsId.Contains(s.AccountId))
|
||||
// .ToListAsync();
|
||||
// await _PushNotification(notification, subscribers);
|
||||
}
|
||||
|
||||
// private List<Dictionary<string, object>> _BuildNotificationPayload(Notification notification,
|
||||
// IEnumerable<NotificationPushSubscription> subscriptions)
|
||||
// {
|
||||
// var subDict = subscriptions
|
||||
// .GroupBy(x => x.Provider)
|
||||
// .ToDictionary(x => x.Key, x => x.ToList());
|
||||
|
||||
// var notifications = subDict.Select(value =>
|
||||
// {
|
||||
// var platformCode = value.Key switch
|
||||
// {
|
||||
// NotificationPushProvider.Apple => 1,
|
||||
// NotificationPushProvider.Google => 2,
|
||||
// _ => throw new InvalidOperationException($"Unknown push provider: {value.Key}")
|
||||
// };
|
||||
|
||||
// var tokens = value.Value.Select(x => x.DeviceToken).ToList();
|
||||
// return _BuildNotificationPayload(notification, platformCode, tokens);
|
||||
// }).ToList();
|
||||
|
||||
// return notifications.ToList();
|
||||
// }
|
||||
|
||||
// private Dictionary<string, object> _BuildNotificationPayload(Notification notification, int platformCode,
|
||||
// IEnumerable<string> deviceTokens)
|
||||
// {
|
||||
// var alertDict = new Dictionary<string, object>();
|
||||
// var dict = new Dictionary<string, object>
|
||||
// {
|
||||
// ["notif_id"] = notification.Id.ToString(),
|
||||
// ["apns_id"] = notification.Id.ToString(),
|
||||
// ["topic"] = _notifyTopic,
|
||||
// ["tokens"] = deviceTokens,
|
||||
// ["data"] = new Dictionary<string, object>
|
||||
// {
|
||||
// ["type"] = notification.Topic,
|
||||
// ["meta"] = notification.Meta ?? new Dictionary<string, object>(),
|
||||
// },
|
||||
// ["mutable_content"] = true,
|
||||
// ["priority"] = notification.Priority >= 5 ? "high" : "normal",
|
||||
// };
|
||||
|
||||
// if (!string.IsNullOrWhiteSpace(notification.Title))
|
||||
// {
|
||||
// dict["title"] = notification.Title;
|
||||
// alertDict["title"] = notification.Title;
|
||||
// }
|
||||
|
||||
// if (!string.IsNullOrWhiteSpace(notification.Content))
|
||||
// {
|
||||
// dict["message"] = notification.Content;
|
||||
// alertDict["body"] = notification.Content;
|
||||
// }
|
||||
|
||||
// if (!string.IsNullOrWhiteSpace(notification.Subtitle))
|
||||
// {
|
||||
// dict["message"] = $"{notification.Subtitle}\n{dict["message"]}";
|
||||
// alertDict["subtitle"] = notification.Subtitle;
|
||||
// }
|
||||
|
||||
// if (notification.Priority >= 5)
|
||||
// dict["name"] = "default";
|
||||
|
||||
// dict["platform"] = platformCode;
|
||||
// dict["alert"] = alertDict;
|
||||
|
||||
// return dict;
|
||||
// }
|
||||
|
||||
// private async Task _PushNotification(Notification notification,
|
||||
// IEnumerable<NotificationPushSubscription> subscriptions)
|
||||
// {
|
||||
// var subList = subscriptions.ToList();
|
||||
// if (subList.Count == 0) return;
|
||||
|
||||
// var requestDict = new Dictionary<string, object>
|
||||
// {
|
||||
// ["notifications"] = _BuildNotificationPayload(notification, subList)
|
||||
// };
|
||||
|
||||
// var client = httpFactory.CreateClient();
|
||||
// client.BaseAddress = _notifyEndpoint;
|
||||
// var request = await client.PostAsync("/push", new StringContent(
|
||||
// JsonSerializer.Serialize(requestDict),
|
||||
// Encoding.UTF8,
|
||||
// "application/json"
|
||||
// ));
|
||||
// request.EnsureSuccessStatusCode();
|
||||
// }
|
||||
}
|
255
DysonNetwork.Pass/Account/RelationshipController.cs
Normal file
255
DysonNetwork.Pass/Account/RelationshipController.cs
Normal file
@ -0,0 +1,255 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace DysonNetwork.Pass.Account;
|
||||
|
||||
[ApiController]
|
||||
[Route("/relationships")]
|
||||
public class RelationshipController(AppDatabase db, RelationshipService rels) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<List<Relationship>>> ListRelationships([FromQuery] int offset = 0,
|
||||
[FromQuery] int take = 20)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
|
||||
var userId = currentUser.Id;
|
||||
|
||||
var query = db.AccountRelationships.AsQueryable()
|
||||
.Where(r => r.RelatedId == userId);
|
||||
var totalCount = await query.CountAsync();
|
||||
var relationships = await query
|
||||
.Include(r => r.Related)
|
||||
.Include(r => r.Related.Profile)
|
||||
.Include(r => r.Account)
|
||||
.Include(r => r.Account.Profile)
|
||||
.Skip(offset)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
|
||||
var statuses = await db.AccountRelationships
|
||||
.Where(r => r.AccountId == userId)
|
||||
.ToDictionaryAsync(r => r.RelatedId);
|
||||
foreach (var relationship in relationships)
|
||||
if (statuses.TryGetValue(relationship.RelatedId, out var status))
|
||||
relationship.Status = status.Status;
|
||||
|
||||
Response.Headers["X-Total"] = totalCount.ToString();
|
||||
|
||||
return relationships;
|
||||
}
|
||||
|
||||
[HttpGet("requests")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<List<Relationship>>> ListSentRequests()
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
|
||||
|
||||
var relationships = await db.AccountRelationships
|
||||
.Where(r => r.AccountId == currentUser.Id && r.Status == RelationshipStatus.Pending)
|
||||
.Include(r => r.Related)
|
||||
.Include(r => r.Related.Profile)
|
||||
.Include(r => r.Account)
|
||||
.Include(r => r.Account.Profile)
|
||||
.ToListAsync();
|
||||
|
||||
return relationships;
|
||||
}
|
||||
|
||||
public class RelationshipRequest
|
||||
{
|
||||
[Required] public RelationshipStatus Status { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("{userId:guid}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<Relationship>> CreateRelationship(Guid userId,
|
||||
[FromBody] RelationshipRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
|
||||
|
||||
var relatedUser = await db.Accounts.FindAsync(userId);
|
||||
if (relatedUser is null) return NotFound("Account was not found.");
|
||||
|
||||
try
|
||||
{
|
||||
var relationship = await rels.CreateRelationship(
|
||||
currentUser, relatedUser, request.Status
|
||||
);
|
||||
return relationship;
|
||||
}
|
||||
catch (InvalidOperationException err)
|
||||
{
|
||||
return BadRequest(err.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPatch("{userId:guid}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<Relationship>> UpdateRelationship(Guid userId,
|
||||
[FromBody] RelationshipRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
|
||||
|
||||
try
|
||||
{
|
||||
var relationship = await rels.UpdateRelationship(currentUser.Id, userId, request.Status);
|
||||
return relationship;
|
||||
}
|
||||
catch (ArgumentException err)
|
||||
{
|
||||
return NotFound(err.Message);
|
||||
}
|
||||
catch (InvalidOperationException err)
|
||||
{
|
||||
return BadRequest(err.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("{userId:guid}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<Relationship>> GetRelationship(Guid userId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
|
||||
|
||||
var now = Instant.FromDateTimeUtc(DateTime.UtcNow);
|
||||
var queries = db.AccountRelationships.AsQueryable()
|
||||
.Where(r => r.AccountId == currentUser.Id && r.RelatedId == userId)
|
||||
.Where(r => r.ExpiredAt == null || r.ExpiredAt > now);
|
||||
var relationship = await queries
|
||||
.Include(r => r.Related)
|
||||
.Include(r => r.Related.Profile)
|
||||
.FirstOrDefaultAsync();
|
||||
if (relationship is null) return NotFound();
|
||||
|
||||
relationship.Account = currentUser;
|
||||
return Ok(relationship);
|
||||
}
|
||||
|
||||
[HttpPost("{userId:guid}/friends")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<Relationship>> SendFriendRequest(Guid userId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
|
||||
|
||||
var relatedUser = await db.Accounts.FindAsync(userId);
|
||||
if (relatedUser is null) return NotFound("Account was not found.");
|
||||
|
||||
var existing = await db.AccountRelationships.FirstOrDefaultAsync(r =>
|
||||
(r.AccountId == currentUser.Id && r.RelatedId == userId) ||
|
||||
(r.AccountId == userId && r.RelatedId == currentUser.Id));
|
||||
if (existing != null) return BadRequest("Relationship already exists.");
|
||||
|
||||
try
|
||||
{
|
||||
var relationship = await rels.SendFriendRequest(currentUser, relatedUser);
|
||||
return relationship;
|
||||
}
|
||||
catch (InvalidOperationException err)
|
||||
{
|
||||
return BadRequest(err.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{userId:guid}/friends")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> DeleteFriendRequest(Guid userId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
|
||||
|
||||
try
|
||||
{
|
||||
await rels.DeleteFriendRequest(currentUser.Id, userId);
|
||||
return NoContent();
|
||||
}
|
||||
catch (ArgumentException err)
|
||||
{
|
||||
return NotFound(err.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{userId:guid}/friends/accept")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<Relationship>> AcceptFriendRequest(Guid userId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
|
||||
|
||||
var relationship = await rels.GetRelationship(userId, currentUser.Id, RelationshipStatus.Pending);
|
||||
if (relationship is null) return NotFound("Friend request was not found.");
|
||||
|
||||
try
|
||||
{
|
||||
relationship = await rels.AcceptFriendRelationship(relationship);
|
||||
return relationship;
|
||||
}
|
||||
catch (InvalidOperationException err)
|
||||
{
|
||||
return BadRequest(err.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{userId:guid}/friends/decline")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<Relationship>> DeclineFriendRequest(Guid userId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
|
||||
|
||||
var relationship = await rels.GetRelationship(userId, currentUser.Id, RelationshipStatus.Pending);
|
||||
if (relationship is null) return NotFound("Friend request was not found.");
|
||||
|
||||
try
|
||||
{
|
||||
relationship = await rels.AcceptFriendRelationship(relationship, status: RelationshipStatus.Blocked);
|
||||
return relationship;
|
||||
}
|
||||
catch (InvalidOperationException err)
|
||||
{
|
||||
return BadRequest(err.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{userId:guid}/block")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<Relationship>> BlockUser(Guid userId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
|
||||
|
||||
var relatedUser = await db.Accounts.FindAsync(userId);
|
||||
if (relatedUser is null) return NotFound("Account was not found.");
|
||||
|
||||
try
|
||||
{
|
||||
var relationship = await rels.BlockAccount(currentUser, relatedUser);
|
||||
return relationship;
|
||||
}
|
||||
catch (InvalidOperationException err)
|
||||
{
|
||||
return BadRequest(err.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{userId:guid}/block")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<Relationship>> UnblockUser(Guid userId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized();
|
||||
|
||||
var relatedUser = await db.Accounts.FindAsync(userId);
|
||||
if (relatedUser is null) return NotFound("Account was not found.");
|
||||
|
||||
try
|
||||
{
|
||||
var relationship = await rels.UnblockAccount(currentUser, relatedUser);
|
||||
return relationship;
|
||||
}
|
||||
catch (InvalidOperationException err)
|
||||
{
|
||||
return BadRequest(err.Message);
|
||||
}
|
||||
}
|
||||
}
|
212
DysonNetwork.Pass/Account/RelationshipService.cs
Normal file
212
DysonNetwork.Pass/Account/RelationshipService.cs
Normal file
@ -0,0 +1,212 @@
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Pass.Account;
|
||||
|
||||
public class RelationshipService(
|
||||
AppDatabase db
|
||||
// ICacheService cache
|
||||
)
|
||||
{
|
||||
private const string UserFriendsCacheKeyPrefix = "accounts:friends:";
|
||||
private const string UserBlockedCacheKeyPrefix = "accounts:blocked:";
|
||||
|
||||
public async Task<bool> HasExistingRelationship(Guid accountId, Guid relatedId)
|
||||
{
|
||||
var count = await db.AccountRelationships
|
||||
.Where(r => (r.AccountId == accountId && r.RelatedId == relatedId) ||
|
||||
(r.AccountId == relatedId && r.AccountId == accountId))
|
||||
.CountAsync();
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
public async Task<Relationship?> GetRelationship(
|
||||
Guid accountId,
|
||||
Guid relatedId,
|
||||
RelationshipStatus? status = null,
|
||||
bool ignoreExpired = false
|
||||
)
|
||||
{
|
||||
var now = Instant.FromDateTimeUtc(DateTime.UtcNow);
|
||||
var queries = db.AccountRelationships.AsQueryable()
|
||||
.Where(r => r.AccountId == accountId && r.RelatedId == relatedId);
|
||||
if (!ignoreExpired) queries = queries.Where(r => r.ExpiredAt == null || r.ExpiredAt > now);
|
||||
if (status is not null) queries = queries.Where(r => r.Status == status);
|
||||
var relationship = await queries.FirstOrDefaultAsync();
|
||||
return relationship;
|
||||
}
|
||||
|
||||
public async Task<Relationship> CreateRelationship(Shared.Models.Account sender, Shared.Models.Account target, RelationshipStatus status)
|
||||
{
|
||||
if (status == RelationshipStatus.Pending)
|
||||
throw new InvalidOperationException(
|
||||
"Cannot create relationship with pending status, use SendFriendRequest instead.");
|
||||
if (await HasExistingRelationship(sender.Id, target.Id))
|
||||
throw new InvalidOperationException("Found existing relationship between you and target user.");
|
||||
|
||||
var relationship = new Relationship
|
||||
{
|
||||
AccountId = sender.Id,
|
||||
RelatedId = target.Id,
|
||||
Status = status
|
||||
};
|
||||
|
||||
db.AccountRelationships.Add(relationship);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// await PurgeRelationshipCache(sender.Id, target.Id);
|
||||
|
||||
return relationship;
|
||||
}
|
||||
|
||||
public async Task<Relationship> BlockAccount(Shared.Models.Account sender, Shared.Models.Account target)
|
||||
{
|
||||
if (await HasExistingRelationship(sender.Id, target.Id))
|
||||
return await UpdateRelationship(sender.Id, target.Id, RelationshipStatus.Blocked);
|
||||
return await CreateRelationship(sender, target, RelationshipStatus.Blocked);
|
||||
}
|
||||
|
||||
public async Task<Relationship> UnblockAccount(Shared.Models.Account sender, Shared.Models.Account target)
|
||||
{
|
||||
var relationship = await GetRelationship(sender.Id, target.Id, RelationshipStatus.Blocked);
|
||||
if (relationship is null) throw new ArgumentException("There is no relationship between you and the user.");
|
||||
db.Remove(relationship);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// await PurgeRelationshipCache(sender.Id, target.Id);
|
||||
|
||||
return relationship;
|
||||
}
|
||||
|
||||
public async Task<Relationship> SendFriendRequest(Shared.Models.Account sender, Shared.Models.Account target)
|
||||
{
|
||||
if (await HasExistingRelationship(sender.Id, target.Id))
|
||||
throw new InvalidOperationException("Found existing relationship between you and target user.");
|
||||
|
||||
var relationship = new Relationship
|
||||
{
|
||||
AccountId = sender.Id,
|
||||
RelatedId = target.Id,
|
||||
Status = RelationshipStatus.Pending,
|
||||
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(7))
|
||||
};
|
||||
|
||||
db.AccountRelationships.Add(relationship);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return relationship;
|
||||
}
|
||||
|
||||
public async Task DeleteFriendRequest(Guid accountId, Guid relatedId)
|
||||
{
|
||||
var relationship = await GetRelationship(accountId, relatedId, RelationshipStatus.Pending);
|
||||
if (relationship is null) throw new ArgumentException("Friend request was not found.");
|
||||
|
||||
await db.AccountRelationships
|
||||
.Where(r => r.AccountId == accountId && r.RelatedId == relatedId && r.Status == RelationshipStatus.Pending)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
// await PurgeRelationshipCache(relationship.AccountId, relationship.RelatedId);
|
||||
}
|
||||
|
||||
public async Task<Relationship> AcceptFriendRelationship(
|
||||
Relationship relationship,
|
||||
RelationshipStatus status = RelationshipStatus.Friends
|
||||
)
|
||||
{
|
||||
if (relationship.Status != RelationshipStatus.Pending)
|
||||
throw new ArgumentException("Cannot accept friend request that not in pending status.");
|
||||
if (status == RelationshipStatus.Pending)
|
||||
throw new ArgumentException("Cannot accept friend request by setting the new status to pending.");
|
||||
|
||||
// Whatever the receiver decides to apply which status to the relationship,
|
||||
// the sender should always see the user as a friend since the sender ask for it
|
||||
relationship.Status = RelationshipStatus.Friends;
|
||||
relationship.ExpiredAt = null;
|
||||
db.Update(relationship);
|
||||
|
||||
var relationshipBackward = new Relationship
|
||||
{
|
||||
AccountId = relationship.RelatedId,
|
||||
RelatedId = relationship.AccountId,
|
||||
Status = status
|
||||
};
|
||||
db.AccountRelationships.Add(relationshipBackward);
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// await PurgeRelationshipCache(relationship.AccountId, relationship.RelatedId);
|
||||
|
||||
return relationshipBackward;
|
||||
}
|
||||
|
||||
public async Task<Relationship> UpdateRelationship(Guid accountId, Guid relatedId, RelationshipStatus status)
|
||||
{
|
||||
var relationship = await GetRelationship(accountId, relatedId);
|
||||
if (relationship is null) throw new ArgumentException("There is no relationship between you and the user.");
|
||||
if (relationship.Status == status) return relationship;
|
||||
relationship.Status = status;
|
||||
db.Update(relationship);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// await PurgeRelationshipCache(accountId, relatedId);
|
||||
|
||||
return relationship;
|
||||
}
|
||||
|
||||
public async Task<List<Guid>> ListAccountFriends(Shared.Models.Account account)
|
||||
{
|
||||
var cacheKey = $"{UserFriendsCacheKeyPrefix}{account.Id}";
|
||||
// var friends = await cache.GetAsync<List<Guid>>(cacheKey);
|
||||
var friends = new List<Guid>(); // Placeholder
|
||||
|
||||
if (friends == null)
|
||||
{
|
||||
friends = await db.AccountRelationships
|
||||
.Where(r => r.RelatedId == account.Id)
|
||||
.Where(r => r.Status == RelationshipStatus.Friends)
|
||||
.Select(r => r.AccountId)
|
||||
.ToListAsync();
|
||||
|
||||
// await cache.SetAsync(cacheKey, friends, TimeSpan.FromHours(1));
|
||||
}
|
||||
|
||||
return friends ?? [];
|
||||
}
|
||||
|
||||
public async Task<List<Guid>> ListAccountBlocked(Shared.Models.Account account)
|
||||
{
|
||||
var cacheKey = $"{UserBlockedCacheKeyPrefix}{account.Id}";
|
||||
// var blocked = await cache.GetAsync<List<Guid>>(cacheKey);
|
||||
var blocked = new List<Guid>(); // Placeholder
|
||||
|
||||
if (blocked == null)
|
||||
{
|
||||
blocked = await db.AccountRelationships
|
||||
.Where(r => r.RelatedId == account.Id)
|
||||
.Where(r => r.Status == RelationshipStatus.Blocked)
|
||||
.Select(r => r.AccountId)
|
||||
.ToListAsync();
|
||||
|
||||
// await cache.SetAsync(cacheKey, blocked, TimeSpan.FromHours(1));
|
||||
}
|
||||
|
||||
return blocked ?? [];
|
||||
}
|
||||
|
||||
public async Task<bool> HasRelationshipWithStatus(Guid accountId, Guid relatedId,
|
||||
RelationshipStatus status = RelationshipStatus.Friends)
|
||||
{
|
||||
var relationship = await GetRelationship(accountId, relatedId, status);
|
||||
return relationship is not null;
|
||||
}
|
||||
|
||||
private async Task PurgeRelationshipCache(Guid accountId, Guid relatedId)
|
||||
{
|
||||
// await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{accountId}");
|
||||
// await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relatedId}");
|
||||
// await cache.RemoveAsync($"{UserBlockedCacheKeyPrefix}{accountId}");
|
||||
// await cache.RemoveAsync($"{UserBlockedCacheKeyPrefix}{relatedId}");
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user