using System.Globalization; using System.Security.Cryptography; using DysonNetwork.Sphere.Email; using DysonNetwork.Sphere.Pages.Emails; using DysonNetwork.Sphere.Permission; using DysonNetwork.Sphere.Resources.Localization; using DysonNetwork.Sphere.Resources.Pages.Emails; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Localization; using NodaTime; namespace DysonNetwork.Sphere.Account; public class MagicSpellService( AppDatabase db, EmailService email, IConfiguration configuration, ILogger logger, IStringLocalizer localizer ) { public async Task CreateMagicSpell( Account account, MagicSpellType type, Dictionary 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) .Include(c => c.Account) .FirstOrDefaultAsync(); if (contact is null) throw new ArgumentException("Account has no contact method that can use"); var link = $"{configuration.GetValue("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(); var cultureInfo = new CultureInfo(accountLanguage ?? "en-us", false); CultureInfo.CurrentCulture = cultureInfo; CultureInfo.CurrentUICulture = cultureInfo; try { switch (spell.Type) { case MagicSpellType.AccountActivation: await email.SendTemplatedEmailAsync( contact.Account.Name, contact.Content, localizer["EmailLandingTitle"], new LandingEmailModel { Name = contact.Account.Name, Link = link } ); break; case MagicSpellType.AccountRemoval: await email.SendTemplatedEmailAsync( contact.Account.Name, contact.Content, localizer["EmailAccountDeletionTitle"], new AccountDeletionEmailModel { Name = contact.Account.Name, Link = link } ); break; case MagicSpellType.AuthPasswordReset: await email.SendTemplatedEmailAsync( contact.Account.Name, contact.Content, localizer["EmailAccountDeletionTitle"], new PasswordResetEmailModel { Name = contact.Account.Name, Link = link } ); break; default: throw new ArgumentOutOfRangeException(); } } catch (Exception err) { logger.LogError($"Error sending magic spell (${spell.Spell})... {err}"); } } public async Task ApplyMagicSpell(MagicSpell spell) { switch (spell.Type) { case MagicSpellType.AuthPasswordReset: throw new ArgumentException( "For password reset spell, please use the ApplyPasswordReset method instead." ); case MagicSpellType.AccountRemoval: var account = await db.Accounts.FirstOrDefaultAsync(c => c.Id == spell.AccountId); if (account is null) break; db.Accounts.Remove(account); await db.SaveChangesAsync(); break; case MagicSpellType.AccountActivation: var contactMethod = spell.Meta["contact_method"] as string; 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 }); } db.Remove(spell); await db.SaveChangesAsync(); break; default: throw new ArgumentOutOfRangeException(); } } 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); } }