using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json.Serialization; using Microsoft.EntityFrameworkCore; using NodaTime; using OtpNet; namespace DysonNetwork.Common.Models; public enum AccountStatus { PendingActivation, Active, Suspended, Banned, Deleted } [Index(nameof(Name), IsUnique = true)] public class Account : ModelBase { public Guid Id { get; set; } [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 Profile Profile { get; set; } = null!; public ICollection Contacts { get; set; } = new List(); public ICollection Badges { get; set; } = new List(); [JsonIgnore] public ICollection AuthFactors { get; set; } = new List(); [JsonIgnore] public ICollection Connections { get; set; } = new List(); [JsonIgnore] public ICollection Sessions { get; set; } = new List(); [JsonIgnore] public ICollection Challenges { get; set; } = new List(); [JsonIgnore] public ICollection OutgoingRelationships { get; set; } = new List(); [JsonIgnore] public ICollection IncomingRelationships { get; set; } = new List(); [JsonIgnore] public ICollection Subscriptions { get; set; } = new List(); public AccountStatus Status { get; set; } = AccountStatus.PendingActivation; [NotMapped] public string? Email => GetPrimaryEmail(); public string? GetPrimaryEmail() { return Contacts .FirstOrDefault(c => c.Type == AccountContactType.Email && c.IsPrimary) ?.Content; } public void SetPrimaryEmail(string email) { // Remove primary flag from existing primary email if any foreach (var contact in Contacts.Where(c => c.Type == AccountContactType.Email && c.IsPrimary)) { contact.IsPrimary = false; } // Find or create the email contact var emailContact = Contacts.FirstOrDefault(c => c.Type == AccountContactType.Email && string.Equals(c.Content, email, StringComparison.OrdinalIgnoreCase)); if (emailContact == null) { emailContact = new AccountContact { Type = AccountContactType.Email, Content = email, IsPrimary = true }; Contacts.Add(emailContact); } else { emailContact.IsPrimary = true; } } } public abstract class Leveling { public static readonly List 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 Profile : ModelBase { 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; } [Column(TypeName = "jsonb")] public SubscriptionReferenceObject? StellarMembership { 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]); // Outdated fields, for backward compability [MaxLength(32)] public string? PictureId { get; set; } [MaxLength(32)] public string? BackgroundId { get; set; } [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 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 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? Config { get; set; } = new(); /// /// 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. /// public int Trustworthy { get; set; } = 1; [MaxLength(100)] public string Name { get; set; } = string.Empty; [MaxLength(500)] public string? Description { get; set; } public bool IsDefault { get; set; } public bool IsBackup { get; set; } public Instant? LastUsedAt { get; set; } public Instant? EnabledAt { get; set; } public Instant? ExpiredAt { get; set; } public Instant? DisabledAt { get; set; } [Column(TypeName = "jsonb")] public Dictionary? Metadata { get; set; } public Guid AccountId { get; set; } [JsonIgnore] public Account Account { get; set; } = null!; // Navigation property for related AuthSessions [JsonIgnore] public virtual ICollection? Sessions { get; set; } 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."); } } /// /// 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. /// [NotMapped] public Dictionary? CreatedResponse { get; set; } } public enum AccountAuthFactorType { Password, EmailCode, InAppCode, TimedCode, PinCode }