🔀 Merge branch 'refactor/seprate-auth'

# Conflicts:
#	DysonNetwork.Sphere/Chat/Realtime/LiveKitService.cs
#	DysonNetwork.Sphere/Chat/RealtimeCallController.cs
#	DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs
#	DysonNetwork.sln.DotSettings.user
This commit is contained in:
2025-07-20 02:28:42 +08:00
388 changed files with 26638 additions and 105969 deletions

View File

@@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Data;
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 Account Account { get; set; } = null!;
}

View File

@@ -0,0 +1,347 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Shared.Data;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using NodaTime.Serialization.Protobuf;
using OtpNet;
using VerificationMark = DysonNetwork.Shared.Data.VerificationMark;
namespace DysonNetwork.Pass.Account;
[Index(nameof(Name), IsUnique = true)]
public class Account : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(256)] public string Name { get; set; } = string.Empty;
[MaxLength(256)] public string Nick { get; set; } = string.Empty;
[MaxLength(32)] public string Language { get; set; } = string.Empty;
public Instant? ActivatedAt { get; set; }
public bool IsSuperuser { get; set; } = false;
public AccountProfile Profile { get; set; } = null!;
public ICollection<AccountContact> Contacts { get; set; } = new List<AccountContact>();
public ICollection<AccountBadge> Badges { get; set; } = new List<AccountBadge>();
[JsonIgnore] public ICollection<AccountAuthFactor> AuthFactors { get; set; } = new List<AccountAuthFactor>();
[JsonIgnore] public ICollection<AccountConnection> Connections { get; set; } = new List<AccountConnection>();
[JsonIgnore] public ICollection<Auth.AuthSession> Sessions { get; set; } = new List<Auth.AuthSession>();
[JsonIgnore] public ICollection<Auth.AuthChallenge> Challenges { get; set; } = new List<Auth.AuthChallenge>();
[JsonIgnore] public ICollection<Relationship> OutgoingRelationships { get; set; } = new List<Relationship>();
[JsonIgnore] public ICollection<Relationship> IncomingRelationships { get; set; } = new List<Relationship>();
public Shared.Proto.Account ToProtoValue()
{
var proto = new Shared.Proto.Account
{
Id = Id.ToString(),
Name = Name,
Nick = Nick,
Language = Language,
ActivatedAt = ActivatedAt?.ToTimestamp(),
IsSuperuser = IsSuperuser,
Profile = Profile.ToProtoValue(),
CreatedAt = CreatedAt.ToTimestamp(),
UpdatedAt = UpdatedAt.ToTimestamp()
};
// Add contacts
foreach (var contact in Contacts)
proto.Contacts.Add(contact.ToProtoValue());
// Add badges
foreach (var badge in Badges)
proto.Badges.Add(badge.ToProtoValue());
return proto;
}
public static Account FromProtoValue(Shared.Proto.Account proto)
{
var account = new Account
{
Id = Guid.Parse(proto.Id),
Name = proto.Name,
Nick = proto.Nick,
Language = proto.Language,
ActivatedAt = proto.ActivatedAt?.ToInstant(),
IsSuperuser = proto.IsSuperuser,
CreatedAt = proto.CreatedAt.ToInstant(),
UpdatedAt = proto.UpdatedAt.ToInstant(),
};
account.Profile = AccountProfile.FromProtoValue(proto.Profile);
foreach (var contactProto in proto.Contacts)
account.Contacts.Add(AccountContact.FromProtoValue(contactProto));
foreach (var badgeProto in proto.Badges)
account.Badges.Add(AccountBadge.FromProtoValue(badgeProto));
return account;
}
}
public abstract class Leveling
{
public static readonly List<int> ExperiencePerLevel =
[
0, // Level 0
100, // Level 1
250, // Level 2
500, // Level 3
1000, // Level 4
2000, // Level 5
4000, // Level 6
8000, // Level 7
16000, // Level 8
32000, // Level 9
64000, // Level 10
128000, // Level 11
256000, // Level 12
512000, // Level 13
1024000 // Level 14
];
}
public class AccountProfile : ModelBase, IIdentifiedResource
{
public Guid Id { get; set; }
[MaxLength(256)] public string? FirstName { get; set; }
[MaxLength(256)] public string? MiddleName { get; set; }
[MaxLength(256)] public string? LastName { get; set; }
[MaxLength(4096)] public string? Bio { 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; }
public Instant? Birthday { get; set; }
public Instant? LastSeenAt { get; set; }
[Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; }
[Column(TypeName = "jsonb")] public BadgeReferenceObject? ActiveBadge { get; set; }
public int Experience { get; set; } = 0;
[NotMapped] public int Level => Leveling.ExperiencePerLevel.Count(xp => Experience >= xp) - 1;
[NotMapped]
public double LevelingProgress => Level >= Leveling.ExperiencePerLevel.Count - 1
? 100
: (Experience - Leveling.ExperiencePerLevel[Level]) * 100.0 /
(Leveling.ExperiencePerLevel[Level + 1] - Leveling.ExperiencePerLevel[Level]);
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; }
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; }
public Guid AccountId { get; set; }
[JsonIgnore] public Account Account { get; set; } = null!;
public Shared.Proto.AccountProfile ToProtoValue()
{
var proto = new Shared.Proto.AccountProfile
{
Id = Id.ToString(),
FirstName = FirstName ?? string.Empty,
MiddleName = MiddleName ?? string.Empty,
LastName = LastName ?? string.Empty,
Bio = Bio ?? string.Empty,
Gender = Gender ?? string.Empty,
Pronouns = Pronouns ?? string.Empty,
TimeZone = TimeZone ?? string.Empty,
Location = Location ?? string.Empty,
Birthday = Birthday?.ToTimestamp(),
LastSeenAt = LastSeenAt?.ToTimestamp(),
Experience = Experience,
Level = Level,
LevelingProgress = LevelingProgress,
Picture = Picture?.ToProtoValue(),
Background = Background?.ToProtoValue(),
AccountId = AccountId.ToString(),
Verification = Verification?.ToProtoValue(),
ActiveBadge = ActiveBadge?.ToProtoValue(),
CreatedAt = CreatedAt.ToTimestamp(),
UpdatedAt = UpdatedAt.ToTimestamp()
};
return proto;
}
public static AccountProfile FromProtoValue(Shared.Proto.AccountProfile proto)
{
var profile = new AccountProfile
{
Id = Guid.Parse(proto.Id),
FirstName = proto.FirstName,
LastName = proto.LastName,
MiddleName = proto.MiddleName,
Bio = proto.Bio,
Gender = proto.Gender,
Pronouns = proto.Pronouns,
TimeZone = proto.TimeZone,
Location = proto.Location,
Birthday = proto.Birthday?.ToInstant(),
LastSeenAt = proto.LastSeenAt?.ToInstant(),
Verification = proto.Verification is null ? null : VerificationMark.FromProtoValue(proto.Verification),
ActiveBadge = proto.ActiveBadge is null ? null : BadgeReferenceObject.FromProtoValue(proto.ActiveBadge),
Experience = proto.Experience,
Picture = proto.Picture is null ? null : CloudFileReferenceObject.FromProtoValue(proto.Picture),
Background = proto.Background is null ? null : CloudFileReferenceObject.FromProtoValue(proto.Background),
AccountId = Guid.Parse(proto.AccountId),
CreatedAt = proto.CreatedAt.ToInstant(),
UpdatedAt = proto.UpdatedAt.ToInstant()
};
return profile;
}
public string ResourceIdentifier => $"account:profile:{Id}";
}
public class AccountContact : ModelBase
{
public Guid Id { get; set; }
public AccountContactType Type { get; set; }
public Instant? VerifiedAt { get; set; }
public bool IsPrimary { get; set; } = false;
[MaxLength(1024)] public string Content { get; set; } = string.Empty;
public Guid AccountId { get; set; }
[JsonIgnore] public Account Account { get; set; } = null!;
public Shared.Proto.AccountContact ToProtoValue()
{
var proto = new Shared.Proto.AccountContact
{
Id = Id.ToString(),
Type = Type switch
{
AccountContactType.Email => Shared.Proto.AccountContactType.Email,
AccountContactType.PhoneNumber => Shared.Proto.AccountContactType.PhoneNumber,
AccountContactType.Address => Shared.Proto.AccountContactType.Address,
_ => Shared.Proto.AccountContactType.Unspecified
},
Content = Content,
IsPrimary = IsPrimary,
VerifiedAt = VerifiedAt?.ToTimestamp(),
AccountId = AccountId.ToString(),
CreatedAt = CreatedAt.ToTimestamp(),
UpdatedAt = UpdatedAt.ToTimestamp()
};
return proto;
}
public static AccountContact FromProtoValue(Shared.Proto.AccountContact proto)
{
var contact = new AccountContact
{
Id = Guid.Parse(proto.Id),
AccountId = Guid.Parse(proto.AccountId),
Type = proto.Type switch
{
Shared.Proto.AccountContactType.Email => AccountContactType.Email,
Shared.Proto.AccountContactType.PhoneNumber => AccountContactType.PhoneNumber,
Shared.Proto.AccountContactType.Address => AccountContactType.Address,
_ => AccountContactType.Email
},
Content = proto.Content,
IsPrimary = proto.IsPrimary,
VerifiedAt = proto.VerifiedAt?.ToInstant(),
CreatedAt = proto.CreatedAt.ToInstant(),
UpdatedAt = proto.UpdatedAt.ToInstant()
};
return contact;
}
}
public enum AccountContactType
{
Email,
PhoneNumber,
Address
}
public class AccountAuthFactor : ModelBase
{
public Guid Id { get; set; }
public AccountAuthFactorType Type { get; set; }
[JsonIgnore] [MaxLength(8196)] public string? Secret { get; set; }
[JsonIgnore]
[Column(TypeName = "jsonb")]
public Dictionary<string, object>? Config { get; set; } = new();
/// <summary>
/// The trustworthy stands for how safe is this auth factor.
/// Basically, it affects how many steps it can complete in authentication.
/// Besides, users may need to use some high-trustworthy level auth factors when confirming some dangerous operations.
/// </summary>
public int Trustworthy { get; set; } = 1;
public Instant? EnabledAt { get; set; }
public Instant? ExpiredAt { get; set; }
public Guid AccountId { get; set; }
[JsonIgnore] public Account Account { get; set; } = null!;
public AccountAuthFactor HashSecret(int cost = 12)
{
if (Secret == null) return this;
Secret = BCrypt.Net.BCrypt.HashPassword(Secret, workFactor: cost);
return this;
}
public bool VerifyPassword(string password)
{
if (Secret == null)
throw new InvalidOperationException("Auth factor with no secret cannot be verified with password.");
switch (Type)
{
case AccountAuthFactorType.Password:
case AccountAuthFactorType.PinCode:
return BCrypt.Net.BCrypt.Verify(password, Secret);
case AccountAuthFactorType.TimedCode:
var otp = new Totp(Base32Encoding.ToBytes(Secret));
return otp.VerifyTotp(DateTime.UtcNow, password, out _, new VerificationWindow(previous: 5, future: 5));
case AccountAuthFactorType.EmailCode:
case AccountAuthFactorType.InAppCode:
default:
throw new InvalidOperationException("Unsupported verification type, use CheckDeliveredCode instead.");
}
}
/// <summary>
/// This dictionary will be returned to the client and should only be set when it just created.
/// Useful for passing the client some data to finishing setup and recovery code.
/// </summary>
[NotMapped]
public Dictionary<string, object>? CreatedResponse { get; set; }
}
public enum AccountAuthFactorType
{
Password,
EmailCode,
InAppCode,
TimedCode,
PinCode,
}
public class AccountConnection : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(4096)] public string Provider { get; set; } = null!;
[MaxLength(8192)] public string ProvidedIdentifier { get; set; } = null!;
[Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { get; set; } = new();
[JsonIgnore] [MaxLength(4096)] public string? AccessToken { get; set; }
[JsonIgnore] [MaxLength(4096)] public string? RefreshToken { get; set; }
public Instant? LastUsedAt { get; set; }
public Guid AccountId { get; set; }
public Account Account { get; set; } = null!;
}

View File

@@ -0,0 +1,173 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Pass.Auth;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Pass.Account;
[ApiController]
[Route("/api/accounts")]
public class AccountController(
AppDatabase db,
AuthService auth,
AccountService accounts,
AccountEventService events
) : ControllerBase
{
[HttpGet("{name}")]
[ProducesResponseType<Account>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<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<AccountBadge>>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<List<AccountBadge>>> 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<Account>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<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<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();
}
}

View File

@@ -0,0 +1,685 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Pass.Permission;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using AuthService = DysonNetwork.Pass.Auth.AuthService;
using AuthSession = DysonNetwork.Pass.Auth.AuthSession;
using ChallengePlatform = DysonNetwork.Pass.Auth.ChallengePlatform;
namespace DysonNetwork.Pass.Account;
[Authorize]
[ApiController]
[Route("/api/accounts/me")]
public class AccountCurrentController(
AppDatabase db,
AccountService accounts,
AccountEventService events,
AuthService auth,
FileService.FileServiceClient files,
FileReferenceService.FileReferenceServiceClient fileRefs
) : ControllerBase
{
[HttpGet]
[ProducesResponseType<Account>(StatusCodes.Status200OK)]
public async Task<ActionResult<Account>> GetCurrentIdentity()
{
if (HttpContext.Items["CurrentUser"] is not 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<Account>> UpdateBasicInfo([FromBody] BasicInfoRequest request)
{
if (HttpContext.Items["CurrentUser"] is not 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<AccountProfile>> UpdateProfile([FromBody] ProfileRequest request)
{
if (HttpContext.Items["CurrentUser"] is not 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 file = await files.GetFileAsync(new GetFileRequest { Id = request.PictureId });
if (profile.Picture is not null)
await fileRefs.DeleteResourceReferencesAsync(
new DeleteResourceReferencesRequest { ResourceId = profile.ResourceIdentifier }
);
await fileRefs.CreateReferenceAsync(
new CreateReferenceRequest
{
ResourceId = profile.ResourceIdentifier,
FileId = request.PictureId,
Usage = "profile.picture"
}
);
profile.Picture = CloudFileReferenceObject.FromProtoValue(file);
}
if (request.BackgroundId is not null)
{
var file = await files.GetFileAsync(new GetFileRequest { Id = request.BackgroundId });
if (profile.Background is not null)
await fileRefs.DeleteResourceReferencesAsync(
new DeleteResourceReferencesRequest { ResourceId = profile.ResourceIdentifier }
);
await fileRefs.CreateReferenceAsync(
new CreateReferenceRequest
{
ResourceId = profile.ResourceIdentifier,
FileId = request.BackgroundId,
Usage = "profile.background"
}
);
profile.Background = CloudFileReferenceObject.FromProtoValue(file);
}
db.Update(profile);
await db.SaveChangesAsync();
await accounts.PurgeAccountCache(currentUser);
return profile;
}
[HttpDelete]
public async Task<ActionResult> RequestDeleteAccount()
{
if (HttpContext.Items["CurrentUser"] is not 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 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 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 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 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 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 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 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 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 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 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 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 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 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<AuthSession> Sessions { get; set; } = [];
}
[HttpGet("devices")]
[Authorize]
public async Task<ActionResult<List<AuthorizedDevice>>> GetDevices()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
HttpContext.Items["CurrentSession"] is not AuthSession 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<AuthSession>>> GetSessions(
[FromQuery] int take = 20,
[FromQuery] int offset = 0
)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
HttpContext.Items["CurrentSession"] is not AuthSession 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<AuthSession>> DeleteSession(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not 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<AuthSession>> DeleteCurrentSession()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
HttpContext.Items["CurrentSession"] is not AuthSession 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<AuthSession>> UpdateSessionLabel(Guid id, [FromBody] string label)
{
if (HttpContext.Items["CurrentUser"] is not 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<AuthSession>> UpdateCurrentSessionLabel([FromBody] string label)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
HttpContext.Items["CurrentSession"] is not AuthSession 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 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 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 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 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 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<AccountBadge>>(StatusCodes.Status200OK)]
[Authorize]
public async Task<ActionResult<List<AccountBadge>>> GetBadges()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var badges = await db.Badges
.Where(b => b.AccountId == currentUser.Id)
.ToListAsync();
return Ok(badges);
}
[HttpPost("badges/{id:guid}/active")]
[Authorize]
public async Task<ActionResult<AccountBadge>> ActivateBadge(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
try
{
await accounts.ActiveBadge(currentUser, id);
return Ok();
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
}

View File

@@ -0,0 +1,345 @@
using System.Globalization;
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Proto;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
using NodaTime;
namespace DysonNetwork.Pass.Account;
public class AccountEventService(
AppDatabase db,
PaymentService payment,
ICacheService cache,
IStringLocalizer<Localization.AccountEventResource> localizer,
PusherService.PusherServiceClient pusher
)
{
private static readonly Random Random = new();
private const string StatusCacheKey = "account:status:";
private async Task<bool> GetAccountIsConnected(Guid userId)
{
var resp = await pusher.GetWebsocketConnectionStatusAsync(
new GetWebsocketConnectionStatusRequest { UserId = userId.ToString() }
);
return resp.IsConnected;
}
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 && await 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 = await GetAccountIsConnected(userId);
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 && await GetAccountIsConnected(userId);
results[userId] = cachedStatus;
}
else
{
cacheMissUserIds.Add(userId);
}
}
if (cacheMissUserIds.Count != 0)
{
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 = await GetAccountIsConnected(status.AccountId);
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 = await GetAccountIsConnected(userId);
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(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(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(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(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(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}"
);
}
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(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();
}
}

View File

@@ -0,0 +1,657 @@
using System.Globalization;
using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Auth.OpenId;
using DysonNetwork.Pass.Email;
using DysonNetwork.Pass.Localization;
using DysonNetwork.Pass.Permission;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Proto;
using EFCore.BulkExtensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
using NodaTime;
using OtpNet;
using AuthSession = DysonNetwork.Pass.Auth.AuthSession;
namespace DysonNetwork.Pass.Account;
public class AccountService(
AppDatabase db,
MagicSpellService spells,
AccountUsernameService uname,
EmailService mailer,
PusherService.PusherServiceClient pusher,
IStringLocalizer<NotificationResource> localizer,
ICacheService cache,
ILogger<AccountService> logger
)
{
public static void SetCultureInfo(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(Account account)
{
await cache.RemoveGroupAsync($"{AccountCachePrefix}{account.Id}");
}
public async Task<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<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<Account> CreateAccount(
string name,
string nick,
string email,
string? password,
string language = "en-US",
bool isEmailVerified = false,
bool isActivated = false
)
{
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 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 AccountProfile()
};
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
});
}
}
db.Accounts.Add(account);
await db.SaveChangesAsync();
if (isActivated) return account;
var spell = await spells.CreateMagicSpell(
account,
MagicSpellType.AccountActivation,
new Dictionary<string, object>
{
{ "contact_method", account.Contacts.First().Content }
}
);
await spells.NotifyMagicSpell(spell, true);
return account;
}
public async Task<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);
return await CreateAccount(
username,
displayName,
userInfo.Email,
null,
"en-US",
userInfo.EmailVerified,
userInfo.EmailVerified
);
}
public async Task RequestAccountDeletion(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(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(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(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(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 pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest
{
UserId = account.Id.ToString(),
Notification = new PushNotification
{
Topic = "auth.verification",
Title = localizer["AuthCodeTitle"],
Body = localizer["AuthCodeBody", code],
IsSavable = false
}
}
);
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<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<AuthSession> UpdateSessionLabel(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(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 pusher.UnsubscribePushNotificationsAsync(new UnsubscribePushNotificationsRequest()
{
DeviceId = 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(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(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(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(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<AccountBadge> GrantBadge(Account account, AccountBadge badge)
{
badge.AccountId = account.Id;
db.Badges.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(Account account, Guid badgeId)
{
var badge = await db.Badges
.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(Account account, Guid badgeId)
{
await using var transaction = await db.Database.BeginTransactionAsync();
try
{
var badge = await db.Badges
.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.Badges
.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 AccountProfile { Id = Guid.NewGuid(), AccountId = id })
.ToList();
await db.BulkInsertAsync(newProfiles);
}
}
}

View File

@@ -0,0 +1,153 @@
using DysonNetwork.Shared.Proto;
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using NodaTime.Serialization.Protobuf;
namespace DysonNetwork.Pass.Account;
public class AccountServiceGrpc(
AppDatabase db,
RelationshipService relationships,
IClock clock,
ILogger<AccountServiceGrpc> logger
)
: Shared.Proto.AccountService.AccountServiceBase
{
private readonly AppDatabase _db = db ?? throw new ArgumentNullException(nameof(db));
private readonly IClock _clock = clock ?? throw new ArgumentNullException(nameof(clock));
private readonly ILogger<AccountServiceGrpc>
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
public override async Task<Shared.Proto.Account> GetAccount(GetAccountRequest request, ServerCallContext context)
{
if (!Guid.TryParse(request.Id, out var accountId))
throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format"));
var account = await _db.Accounts
.AsNoTracking()
.Include(a => a.Profile)
.FirstOrDefaultAsync(a => a.Id == accountId);
if (account == null)
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, $"Account {request.Id} not found"));
return account.ToProtoValue();
}
public override async Task<GetAccountBatchResponse> GetAccountBatch(GetAccountBatchRequest request,
ServerCallContext context)
{
var accountIds = request.Id
.Select(id => Guid.TryParse(id, out var accountId) ? accountId : (Guid?)null)
.Where(id => id.HasValue)
.Select(id => id!.Value)
.ToList();
var accounts = await _db.Accounts
.AsNoTracking()
.Where(a => accountIds.Contains(a.Id))
.Include(a => a.Profile)
.ToListAsync();
var response = new GetAccountBatchResponse();
response.Accounts.AddRange(accounts.Select(a => a.ToProtoValue()));
return response;
}
public override async Task<ListAccountsResponse> ListAccounts(ListAccountsRequest request,
ServerCallContext context)
{
var query = _db.Accounts.AsNoTracking();
// Apply filters if provided
if (!string.IsNullOrEmpty(request.Filter))
{
// Implement filtering logic based on request.Filter
// This is a simplified example
query = query.Where(a => a.Name.Contains(request.Filter) || a.Nick.Contains(request.Filter));
}
// Apply ordering
query = request.OrderBy switch
{
"name" => query.OrderBy(a => a.Name),
"name_desc" => query.OrderByDescending(a => a.Name),
_ => query.OrderBy(a => a.Id)
};
// Get total count for pagination
var totalCount = await query.CountAsync();
// Apply pagination
var accounts = await query
.Skip(request.PageSize * (request.PageToken != null ? int.Parse(request.PageToken) : 0))
.Take(request.PageSize)
.Include(a => a.Profile)
.ToListAsync();
var response = new ListAccountsResponse
{
TotalSize = totalCount,
NextPageToken = (accounts.Count == request.PageSize)
? ((request.PageToken != null ? int.Parse(request.PageToken) : 0) + 1).ToString()
: ""
};
response.Accounts.AddRange(accounts.Select(x => x.ToProtoValue()));
return response;
}
public override async Task<ListRelationshipSimpleResponse> ListFriends(
ListRelationshipSimpleRequest request, ServerCallContext context)
{
var accountId = Guid.Parse(request.AccountId);
var relationship = await relationships.ListAccountFriends(accountId);
var resp = new ListRelationshipSimpleResponse();
resp.AccountsId.AddRange(relationship.Select(x => x.ToString()));
return resp;
}
public override async Task<ListRelationshipSimpleResponse> ListBlocked(
ListRelationshipSimpleRequest request, ServerCallContext context)
{
var accountId = Guid.Parse(request.AccountId);
var relationship = await relationships.ListAccountBlocked(accountId);
var resp = new ListRelationshipSimpleResponse();
resp.AccountsId.AddRange(relationship.Select(x => x.ToString()));
return resp;
}
public override async Task<GetRelationshipResponse> GetRelationship(GetRelationshipRequest request,
ServerCallContext context)
{
var relationship = await relationships.GetRelationship(
Guid.Parse(request.AccountId),
Guid.Parse(request.RelatedId),
status: (RelationshipStatus?)request.Status
);
return new GetRelationshipResponse
{
Relationship = relationship?.ToProtoValue()
};
}
public override async Task<BoolValue> HasRelationship(GetRelationshipRequest request, ServerCallContext context)
{
var hasRelationship = false;
if (!request.HasStatus)
hasRelationship = await relationships.HasExistingRelationship(
Guid.Parse(request.AccountId),
Guid.Parse(request.RelatedId)
);
else
hasRelationship = await relationships.HasRelationshipWithStatus(
Guid.Parse(request.AccountId),
Guid.Parse(request.RelatedId),
(RelationshipStatus)request.Status
);
return new BoolValue { Value = hasRelationship };
}
}

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

View File

@@ -0,0 +1,44 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Proto;
using NodaTime.Serialization.Protobuf;
using Point = NetTopologySuite.Geometries.Point;
namespace DysonNetwork.Pass.Account;
public class ActionLog : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(4096)] public string Action { get; set; } = null!;
[Column(TypeName = "jsonb")] public Dictionary<string, object> Meta { get; set; } = new();
[MaxLength(512)] public string? UserAgent { get; set; }
[MaxLength(128)] public string? IpAddress { get; set; }
public Point? Location { get; set; }
public Guid AccountId { get; set; }
public Account Account { get; set; } = null!;
public Guid? SessionId { get; set; }
public Shared.Proto.ActionLog ToProtoValue()
{
var protoLog = new Shared.Proto.ActionLog
{
Id = Id.ToString(),
Action = Action,
UserAgent = UserAgent ?? string.Empty,
IpAddress = IpAddress ?? string.Empty,
Location = Location?.ToString() ?? string.Empty,
AccountId = AccountId.ToString(),
CreatedAt = CreatedAt.ToTimestamp()
};
// Convert Meta dictionary to Struct
protoLog.Meta.Add(GrpcTypeHelper.ConvertToValueMap(Meta));
if (SessionId.HasValue)
protoLog.SessionId = SessionId.Value.ToString();
return protoLog;
}
}

View File

@@ -0,0 +1,44 @@
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.GeoIp;
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,
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 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 Auth.AuthSession currentSession)
log.SessionId = currentSession.Id;
fbs.Enqueue(log);
}
}

View File

@@ -0,0 +1,114 @@
using DysonNetwork.Shared.Proto;
using Grpc.Core;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Pass.Account;
public class ActionLogServiceGrpc : Shared.Proto.ActionLogService.ActionLogServiceBase
{
private readonly ActionLogService _actionLogService;
private readonly AppDatabase _db;
private readonly ILogger<ActionLogServiceGrpc> _logger;
public ActionLogServiceGrpc(
ActionLogService actionLogService,
AppDatabase db,
ILogger<ActionLogServiceGrpc> logger)
{
_actionLogService = actionLogService ?? throw new ArgumentNullException(nameof(actionLogService));
_db = db ?? throw new ArgumentNullException(nameof(db));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public override async Task<CreateActionLogResponse> CreateActionLog(CreateActionLogRequest request,
ServerCallContext context)
{
if (string.IsNullOrEmpty(request.AccountId) || !Guid.TryParse(request.AccountId, out var accountId))
{
throw new RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.InvalidArgument, "Invalid account ID"));
}
try
{
var meta = request.Meta
?.Select(x => new KeyValuePair<string, object?>(x.Key, GrpcTypeHelper.ConvertValueToObject(x.Value)))
.ToDictionary() ?? new Dictionary<string, object?>();
_actionLogService.CreateActionLog(
accountId,
request.Action,
meta
);
return new CreateActionLogResponse();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating action log");
throw new RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.Internal, "Failed to create action log"));
}
}
public override async Task<ListActionLogsResponse> ListActionLogs(ListActionLogsRequest request,
ServerCallContext context)
{
if (string.IsNullOrEmpty(request.AccountId) || !Guid.TryParse(request.AccountId, out var accountId))
{
throw new RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.InvalidArgument, "Invalid account ID"));
}
try
{
var query = _db.ActionLogs
.AsNoTracking()
.Where(log => log.AccountId == accountId);
if (!string.IsNullOrEmpty(request.Action))
{
query = query.Where(log => log.Action == request.Action);
}
// Apply ordering (default to newest first)
query = (request.OrderBy?.ToLower() ?? "createdat desc") switch
{
"createdat" => query.OrderBy(log => log.CreatedAt),
"createdat desc" => query.OrderByDescending(log => log.CreatedAt),
_ => query.OrderByDescending(log => log.CreatedAt)
};
// Apply pagination
var pageSize = request.PageSize == 0 ? 50 : Math.Min(request.PageSize, 1000);
var logs = await query
.Take(pageSize + 1) // Fetch one extra to determine if there are more pages
.ToListAsync();
var hasMore = logs.Count > pageSize;
if (hasMore)
{
logs.RemoveAt(logs.Count - 1);
}
var response = new ListActionLogsResponse
{
TotalSize = await query.CountAsync()
};
if (hasMore)
{
// In a real implementation, you'd generate a proper page token
response.NextPageToken = (logs.LastOrDefault()?.CreatedAt ?? SystemClock.Instance.GetCurrentInstant())
.ToString();
}
response.ActionLogs.AddRange(logs.Select(log => log.ToProtoValue()));
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error listing action logs");
throw new RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.Internal, "Failed to list action logs"));
}
}
}

View File

@@ -0,0 +1,123 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Proto;
using Google.Protobuf.WellKnownTypes;
using NodaTime;
using NodaTime.Serialization.Protobuf;
namespace DysonNetwork.Pass.Account;
public class AccountBadge : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(1024)] public string Type { get; set; } = null!;
[MaxLength(1024)] public string? Label { get; set; }
[MaxLength(4096)] public string? Caption { get; set; }
[Column(TypeName = "jsonb")] public Dictionary<string, object?> Meta { get; set; } = new();
public Instant? ActivatedAt { get; set; }
public Instant? ExpiredAt { get; set; }
public Guid AccountId { get; set; }
[JsonIgnore] public Account Account { get; set; } = null!;
public BadgeReferenceObject ToReference()
{
return new BadgeReferenceObject
{
Id = Id,
Type = Type,
Label = Label,
Caption = Caption,
Meta = Meta,
ActivatedAt = ActivatedAt,
ExpiredAt = ExpiredAt,
AccountId = AccountId,
};
}
public Shared.Proto.AccountBadge ToProtoValue()
{
var proto = new Shared.Proto.AccountBadge
{
Id = Id.ToString(),
Type = Type,
Label = Label ?? string.Empty,
Caption = Caption ?? string.Empty,
ActivatedAt = ActivatedAt?.ToTimestamp(),
ExpiredAt = ExpiredAt?.ToTimestamp(),
AccountId = AccountId.ToString(),
CreatedAt = CreatedAt.ToTimestamp(),
UpdatedAt = UpdatedAt.ToTimestamp()
};
proto.Meta.Add(GrpcTypeHelper.ConvertToValueMap(Meta));
return proto;
}
public static AccountBadge FromProtoValue(Shared.Proto.AccountBadge proto)
{
var badge = new AccountBadge
{
Id = Guid.Parse(proto.Id),
AccountId = Guid.Parse(proto.AccountId),
Type = proto.Type,
Label = proto.Label,
Caption = proto.Caption,
ActivatedAt = proto.ActivatedAt?.ToInstant(),
ExpiredAt = proto.ExpiredAt?.ToInstant(),
CreatedAt = proto.CreatedAt.ToInstant(),
UpdatedAt = proto.UpdatedAt.ToInstant()
};
return badge;
}
}
public class BadgeReferenceObject : ModelBase
{
public Guid Id { get; set; }
public string Type { get; set; } = null!;
public string? Label { get; set; }
public string? Caption { get; set; }
public Dictionary<string, object?> Meta { get; set; }
public Instant? ActivatedAt { get; set; }
public Instant? ExpiredAt { get; set; }
public Guid AccountId { get; set; }
public Shared.Proto.BadgeReferenceObject ToProtoValue()
{
var proto = new Shared.Proto.BadgeReferenceObject
{
Id = Id.ToString(),
Type = Type,
Label = Label ?? string.Empty,
Caption = Caption ?? string.Empty,
ActivatedAt = ActivatedAt?.ToTimestamp(),
ExpiredAt = ExpiredAt?.ToTimestamp(),
AccountId = AccountId.ToString()
};
proto.Meta.Add(GrpcTypeHelper.ConvertToValueMap(Meta!));
return proto;
}
public static BadgeReferenceObject FromProtoValue(Shared.Proto.BadgeReferenceObject proto)
{
var badge = new BadgeReferenceObject
{
Id = Guid.Parse(proto.Id),
Type = proto.Type,
Label = proto.Label,
Caption = proto.Caption,
Meta = GrpcTypeHelper.ConvertFromValueMap(proto.Meta).ToDictionary(),
ActivatedAt = proto.ActivatedAt?.ToInstant(),
ExpiredAt = proto.ExpiredAt?.ToInstant(),
AccountId = Guid.Parse(proto.AccountId)
};
return badge;
}
}

View File

@@ -0,0 +1,66 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using DysonNetwork.Shared.Data;
using NodaTime;
namespace DysonNetwork.Pass.Account;
public enum StatusAttitude
{
Positive,
Negative,
Neutral
}
public class Status : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
public StatusAttitude Attitude { get; set; }
[NotMapped] public bool IsOnline { get; set; }
[NotMapped] public bool IsCustomized { get; set; } = true;
public bool IsInvisible { get; set; }
public bool IsNotDisturb { get; set; }
[MaxLength(1024)] public string? Label { get; set; }
public Instant? ClearedAt { get; set; }
public Guid AccountId { get; set; }
public Account Account { get; set; } = null!;
}
public enum CheckInResultLevel
{
Worst,
Worse,
Normal,
Better,
Best
}
public class CheckInResult : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
public CheckInResultLevel Level { get; set; }
public decimal? RewardPoints { get; set; }
public int? RewardExperience { get; set; }
[Column(TypeName = "jsonb")] public ICollection<FortuneTip> Tips { get; set; } = new List<FortuneTip>();
public Guid AccountId { get; set; }
public Account Account { get; set; } = null!;
}
public class FortuneTip
{
public bool IsPositive { get; set; }
public string Title { get; set; } = null!;
public string Content { get; set; } = null!;
}
/// <summary>
/// This method should not be mapped. Used to generate the daily event calendar.
/// </summary>
public class DailyEventResponse
{
public Instant Date { get; set; }
public CheckInResult? CheckInResult { get; set; }
public ICollection<Status> Statuses { get; set; } = new List<Status>();
}

View File

@@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Shared.Data;
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 Account? Account { get; set; }
}

View File

@@ -0,0 +1,64 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Pass.Account;
[ApiController]
[Route("/api/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();
}
[HttpGet("{spellWord}")]
public async Task<ActionResult> GetMagicSpell(string spellWord)
{
var word = Uri.UnescapeDataString(spellWord);
var spell = await db.MagicSpells
.Where(x => x.Spell == word)
.Include(x => x.Account)
.ThenInclude(x => x.Profile)
.FirstOrDefaultAsync();
if (spell is null)
return NotFound();
return Ok(spell);
}
public record class MagicSpellApplyRequest
{
public string? NewPassword { get; set; }
}
[HttpPost("{spellWord}/apply")]
public async Task<ActionResult> ApplyMagicSpell([FromRoute] string spellWord, [FromBody] MagicSpellApplyRequest? request)
{
var word = Uri.UnescapeDataString(spellWord);
var spell = await db.MagicSpells
.Where(x => x.Spell == word)
.Include(x => x.Account)
.ThenInclude(x => x.Profile)
.FirstOrDefaultAsync();
if (spell is null)
return NotFound();
try
{
if (spell.Type == MagicSpellType.AuthPasswordReset && request?.NewPassword is not null)
await sp.ApplyPasswordReset(spell, request.NewPassword);
else
await sp.ApplyMagicSpell(spell);
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
return Ok();
}
}

View File

@@ -0,0 +1,251 @@
using System.Security.Cryptography;
using System.Text.Json;
using DysonNetwork.Pass.Email;
using DysonNetwork.Pass.Pages.Emails;
using DysonNetwork.Pass.Permission;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
using NodaTime;
using EmailResource = DysonNetwork.Pass.Localization.EmailResource;
namespace DysonNetwork.Pass.Account;
public class MagicSpellService(
AppDatabase db,
IConfiguration configuration,
ILogger<MagicSpellService> logger,
IStringLocalizer<EmailResource> localizer,
EmailService email
)
{
public async Task<MagicSpell> CreateMagicSpell(
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;
case MagicSpellType.AccountDeactivation:
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);
}
}

View File

@@ -0,0 +1,42 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Shared.Data;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Pass.Account;
public class Notification : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(1024)] public string Topic { get; set; } = null!;
[MaxLength(1024)] public string? Title { get; set; }
[MaxLength(2048)] public string? Subtitle { get; set; }
[MaxLength(4096)] public string? Content { get; set; }
[Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { get; set; }
public int Priority { get; set; } = 10;
public Instant? ViewedAt { get; set; }
public Guid AccountId { get; set; }
[JsonIgnore] public Account Account { get; set; } = null!;
}
public enum NotificationPushProvider
{
Apple,
Google
}
[Index(nameof(DeviceToken), nameof(DeviceId), nameof(AccountId), IsUnique = true)]
public class NotificationPushSubscription : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(4096)] public string DeviceId { get; set; } = null!;
[MaxLength(4096)] public string DeviceToken { get; set; } = null!;
public NotificationPushProvider Provider { get; set; }
public Instant? LastUsedAt { get; set; }
public Guid AccountId { get; set; }
[JsonIgnore] public Account Account { get; set; } = null!;
}

View File

@@ -0,0 +1,36 @@
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Proto;
using NodaTime;
using NodaTime.Serialization.Protobuf;
namespace DysonNetwork.Pass.Account;
public enum RelationshipStatus : short
{
Friends = 100,
Pending = 0,
Blocked = -100
}
public class Relationship : ModelBase
{
public Guid AccountId { get; set; }
public Account Account { get; set; } = null!;
public Guid RelatedId { get; set; }
public Account Related { get; set; } = null!;
public Instant? ExpiredAt { get; set; }
public RelationshipStatus Status { get; set; } = RelationshipStatus.Pending;
public Shared.Proto.Relationship ToProtoValue() => new()
{
AccountId = AccountId.ToString(),
RelatedId = RelatedId.ToString(),
Account = Account.ToProtoValue(),
Related = Related.ToProtoValue(),
Status = (int)Status,
CreatedAt = CreatedAt.ToTimestamp(),
UpdatedAt = UpdatedAt.ToTimestamp()
};
}

View File

@@ -0,0 +1,253 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Pass.Account;
[ApiController]
[Route("/api/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 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 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 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 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 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 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 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 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 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 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 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);
}
}
}

View File

@@ -0,0 +1,217 @@
using DysonNetwork.Shared.Cache;
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(Account sender, 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(Account sender, 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(Account sender, 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(Account sender, 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(Account account)
{
return await ListAccountFriends(account.Id);
}
public async Task<List<Guid>> ListAccountFriends(Guid accountId)
{
var cacheKey = $"{UserFriendsCacheKeyPrefix}{accountId}";
var friends = await cache.GetAsync<List<Guid>>(cacheKey);
if (friends == null)
{
friends = await db.AccountRelationships
.Where(r => r.RelatedId == accountId)
.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(Account account)
{
return await ListAccountBlocked(account.Id);
}
public async Task<List<Guid>> ListAccountBlocked(Guid accountId)
{
var cacheKey = $"{UserBlockedCacheKeyPrefix}{accountId}";
var blocked = await cache.GetAsync<List<Guid>>(cacheKey);
if (blocked == null)
{
blocked = await db.AccountRelationships
.Where(r => r.RelatedId == accountId)
.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}");
}
}