using System.Globalization; using DysonNetwork.Pass.Auth; using DysonNetwork.Pass.Auth.OpenId; using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Services; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Localization; using NodaTime; using OtpNet; using Microsoft.Extensions.Logging; using EFCore.BulkExtensions; using MagicOnion.Server; using Grpc.Core; using DysonNetwork.Pass.Account; using DysonNetwork.Pass.Auth.OidcProvider.Services; using DysonNetwork.Pass.Localization; using DysonNetwork.Shared.Localization; using DysonNetwork.Shared.Services; namespace DysonNetwork.Pass.Account; public class AccountService( AppDatabase db, MagicSpellService spells, AccountUsernameService uname, NotificationService nty, // EmailService mailer, // Commented out for now IStringLocalizer localizer, ICacheService cache, ILogger logger, AuthService authService, ActionLogService actionLogService, RelationshipService relationshipService ) : ServiceBase, IAccountService { public static void SetCultureInfo(Shared.Models.Account account) { SetCultureInfo(account.Language); } public static void SetCultureInfo(string? languageCode) { var info = new CultureInfo(languageCode ?? "en-us", false); CultureInfo.CurrentCulture = info; CultureInfo.CurrentUICulture = info; } public const string AccountCachePrefix = "account:"; public async Task PurgeAccountCache(Shared.Models.Account account) { await cache.RemoveGroupAsync($"{AccountCachePrefix}{account.Id}"); } public async Task 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 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 GetAccountLevel(Guid accountId) { var profile = await db.AccountProfiles .Where(a => a.AccountId == accountId) .FirstOrDefaultAsync(); return profile?.Level; } public async Task CreateAccount( string name, string nick, string email, string? password, string language = "en-US", bool isEmailVerified = false, bool isActivated = false ) { await using var transaction = await db.Database.BeginTransactionAsync(); try { var dupeNameCount = await db.Accounts.Where(a => a.Name == name).CountAsync(); if (dupeNameCount > 0) throw new InvalidOperationException("Account name has already been taken."); var account = new Shared.Models.Account { Name = name, Nick = nick, Language = language, Contacts = new List { new() { Type = AccountContactType.Email, Content = email, VerifiedAt = isEmailVerified ? SystemClock.Instance.GetCurrentInstant() : null, IsPrimary = true } }, AuthFactors = password is not null ? new List { new AccountAuthFactor { Type = AccountAuthFactorType.Password, Secret = password, EnabledAt = SystemClock.Instance.GetCurrentInstant() }.HashSecret() } : [], Profile = new Profile() }; if (isActivated) { account.ActivatedAt = SystemClock.Instance.GetCurrentInstant(); var defaultGroup = await db.PermissionGroups.FirstOrDefaultAsync(g => g.Key == "default"); if (defaultGroup is not null) { db.PermissionGroupMembers.Add(new PermissionGroupMember { Actor = $"user:{account.Id}", Group = defaultGroup }); } } else { var spell = await spells.CreateMagicSpell( account, MagicSpellType.AccountActivation, new Dictionary { { "contact_method", account.Contacts.First().Content } } ); await spells.NotifyMagicSpell(spell, true); } db.Accounts.Add(account); await db.SaveChangesAsync(); await transaction.CommitAsync(); return account; } catch { await transaction.RollbackAsync(); throw; } } public async Task 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(); 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(Shared.Models.Account account) { var spell = await spells.CreateMagicSpell( account, MagicSpellType.AccountRemoval, new Dictionary(), SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)), preventRepeat: true ); await spells.NotifyMagicSpell(spell); } public async Task RequestPasswordReset(Shared.Models.Account account) { var spell = await spells.CreateMagicSpell( account, MagicSpellType.AuthPasswordReset, new Dictionary(), SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)), preventRepeat: true ); await spells.NotifyMagicSpell(spell); } public async Task CheckAuthFactorExists(Shared.Models.Account account, AccountAuthFactorType type) { var isExists = await db.AccountAuthFactors .Where(x => x.AccountId == account.Id && x.Type == type) .AnyAsync(); return isExists; } public async Task CreateAuthFactor(Shared.Models.Account account, AccountAuthFactorType type, string? secret) { AccountAuthFactor? factor = null; switch (type) { case AccountAuthFactorType.Password: if (string.IsNullOrWhiteSpace(secret)) throw new ArgumentNullException(nameof(secret)); factor = new AccountAuthFactor { Type = AccountAuthFactorType.Password, Trustworthy = 1, AccountId = account.Id, Secret = secret, EnabledAt = SystemClock.Instance.GetCurrentInstant(), }.HashSecret(); break; case AccountAuthFactorType.EmailCode: factor = new AccountAuthFactor { Type = AccountAuthFactorType.EmailCode, Trustworthy = 2, EnabledAt = SystemClock.Instance.GetCurrentInstant(), }; break; case AccountAuthFactorType.InAppCode: factor = new AccountAuthFactor { Type = AccountAuthFactorType.InAppCode, Trustworthy = 1, EnabledAt = SystemClock.Instance.GetCurrentInstant() }; break; case AccountAuthFactorType.TimedCode: var skOtp = KeyGeneration.GenerateRandomKey(20); var skOtp32 = Base32Encoding.ToString(skOtp); factor = new AccountAuthFactor { Secret = skOtp32, Type = AccountAuthFactorType.TimedCode, Trustworthy = 2, EnabledAt = null, // It needs to be tired once to enable CreatedResponse = new Dictionary { ["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 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 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) .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(); } /// /// 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. /// /// The owner of the auth factor /// The auth factor needed to send code /// The part of the contact method for verification public async Task SendFactorCode(Shared.Models.Account account, AccountAuthFactor factor, string? hint = null) { var code = new Random().Next(100000, 999999).ToString("000000"); switch (factor.Type) { case AccountAuthFactorType.InAppCode: if (await _GetFactorCode(factor) is not null) throw new InvalidOperationException("A factor code has been sent and in active duration."); await nty.SendNotification( account, "auth.verification", localizer["AuthCodeTitle"], null, localizer["AuthCodeBody", code], save: true ); await _SetFactorCode(factor, code, TimeSpan.FromMinutes(5)); break; case AccountAuthFactorType.EmailCode: if (await _GetFactorCode(factor) is not null) throw new InvalidOperationException("A factor code has been sent and in active duration."); ArgumentNullException.ThrowIfNull(hint); hint = hint.Replace("@", "").Replace(".", "").Replace("+", "").Replace("%", ""); if (string.IsNullOrWhiteSpace(hint)) { logger.LogWarning( "Unable to send factor code to #{FactorId} with hint {Hint}, due to invalid hint...", factor.Id, hint ); return; } var contact = await db.AccountContacts .Where(c => c.Type == AccountContactType.Email) .Where(c => c.VerifiedAt != null) .Where(c => EF.Functions.ILike(c.Content, $"%{hint}%")) .Include(c => c.Account) .FirstOrDefaultAsync(); if (contact is null) { logger.LogWarning( "Unable to send factor code to #{FactorId} with hint {Hint}, due to no contact method found according to hint...", factor.Id, hint ); return; } // await mailer.SendTemplatedEmailAsync( // account.Nick, // contact.Content, // localizer["VerificationEmail"], // new DysonNetwork.Pass.Pages.Emails.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 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 _GetFactorCode(AccountAuthFactor factor) { return await cache.GetAsync( $"{AuthFactorCachePrefix}{factor.Id}:code" ); } public async Task UpdateSessionLabel(Shared.Models.Account account, Guid sessionId, string label) { var session = await db.AuthSessions .Include(s => s.Challenge) .Where(s => s.Id == sessionId && s.AccountId == account.Id) .FirstOrDefaultAsync(); if (session is null) throw new InvalidOperationException("Session was not found."); await db.AuthSessions .Include(s => s.Challenge) .Where(s => s.Challenge.DeviceId == session.Challenge.DeviceId) .ExecuteUpdateAsync(p => p.SetProperty(s => s.Label, label)); var sessions = await db.AuthSessions .Include(s => s.Challenge) .Where(s => s.AccountId == session.Id && s.Challenge.DeviceId == session.Challenge.DeviceId) .ToListAsync(); foreach (var item in sessions) await cache.RemoveAsync($"{DysonTokenAuthHandler.AuthCachePrefix}{item.Id}"); return session; } public async Task DeleteSession(Shared.Models.Account account, Guid sessionId) { var session = await db.AuthSessions .Include(s => s.Challenge) .Where(s => s.Id == sessionId && s.AccountId == account.Id) .FirstOrDefaultAsync(); if (session is null) throw new InvalidOperationException("Session was not found."); var sessions = await db.AuthSessions .Include(s => s.Challenge) .Where(s => s.AccountId == session.Id && s.Challenge.DeviceId == session.Challenge.DeviceId) .ToListAsync(); if (session.Challenge.DeviceId is not null) await nty.UnsubscribePushNotifications(session.Challenge.DeviceId); // The current session should be included in the sessions' list await db.AuthSessions .Include(s => s.Challenge) .Where(s => s.Challenge.DeviceId == session.Challenge.DeviceId) .ExecuteDeleteAsync(); foreach (var item in sessions) await cache.RemoveAsync($"{DysonTokenAuthHandler.AuthCachePrefix}{item.Id}"); } public async Task CreateContactMethod(Shared.Models.Account account, AccountContactType type, string content) { var contact = new AccountContact { Type = type, Content = content, AccountId = account.Id, }; db.AccountContacts.Add(contact); await db.SaveChangesAsync(); return contact; } public async Task VerifyContactMethod(Shared.Models.Account account, AccountContact contact) { var spell = await spells.CreateMagicSpell( account, MagicSpellType.ContactVerification, new Dictionary { { "contact_method", contact.Content } }, expiredAt: SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)), preventRepeat: true ); await spells.NotifyMagicSpell(spell); } public async Task SetContactMethodPrimary(Shared.Models.Account account, AccountContact contact) { if (contact.AccountId != account.Id) throw new InvalidOperationException("Contact method does not belong to this account."); if (contact.VerifiedAt is null) throw new InvalidOperationException("Cannot set unverified contact method as primary."); await using var transaction = await db.Database.BeginTransactionAsync(); try { await db.AccountContacts .Where(c => c.AccountId == account.Id && c.Type == contact.Type) .ExecuteUpdateAsync(s => s.SetProperty(x => x.IsPrimary, false)); contact.IsPrimary = true; db.AccountContacts.Update(contact); await db.SaveChangesAsync(); await transaction.CommitAsync(); return contact; } catch { await transaction.RollbackAsync(); throw; } } public async Task DeleteContactMethod(Shared.Models.Account account, AccountContact contact) { if (contact.AccountId != account.Id) throw new InvalidOperationException("Contact method does not belong to this account."); if (contact.IsPrimary) throw new InvalidOperationException("Cannot delete primary contact method."); db.AccountContacts.Remove(contact); await db.SaveChangesAsync(); } /// /// This method will grant a badge to the account. /// Shouldn't be exposed to normal user and the user itself. /// public async Task GrantBadge(Shared.Models.Account account, Badge badge) { badge.AccountId = account.Id; db.AccountBadges.Add(badge); await db.SaveChangesAsync(); return badge; } /// /// This method will revoke a badge from the account. /// Shouldn't be exposed to normal user and the user itself. /// public async Task RevokeBadge(Shared.Models.Account account, Guid badgeId) { var badge = await db.AccountBadges .Where(b => b.AccountId == account.Id && b.Id == badgeId) .OrderByDescending(b => b.CreatedAt) .FirstOrDefaultAsync(); if (badge is null) throw new InvalidOperationException("Badge was not found."); var profile = await db.AccountProfiles .Where(p => p.AccountId == account.Id) .FirstOrDefaultAsync(); if (profile?.ActiveBadge is not null && profile.ActiveBadge.Id == badge.Id) profile.ActiveBadge = null; db.Remove(badge); await db.SaveChangesAsync(); } public async Task ActiveBadge(Shared.Models.Account account, Guid badgeId) { await using var transaction = await db.Database.BeginTransactionAsync(); try { var badge = await db.AccountBadges .Where(b => b.AccountId == account.Id && b.Id != badgeId) .OrderByDescending(b => b.CreatedAt) .FirstOrDefaultAsync(); if (badge is null) throw new InvalidOperationException("Badge was not found."); await db.AccountBadges .Where(b => b.AccountId == account.Id && b.Id != badgeId) .ExecuteUpdateAsync(s => s.SetProperty(p => p.ActivatedAt, p => null)); badge.ActivatedAt = SystemClock.Instance.GetCurrentInstant(); db.Update(badge); await db.SaveChangesAsync(); await db.AccountProfiles .Where(p => p.AccountId == account.Id) .ExecuteUpdateAsync(s => s.SetProperty(p => p.ActiveBadge, badge.ToReference())); await PurgeAccountCache(account); await transaction.CommitAsync(); } catch { await transaction.RollbackAsync(); throw; } } /// /// The maintenance method for server administrator. /// To check every user has an account profile and to create them if it isn't having one. /// public async Task EnsureAccountProfileCreated() { var accountsId = await db.Accounts.Select(a => a.Id).ToListAsync(); var existingId = await db.AccountProfiles.Select(p => p.AccountId).ToListAsync(); var missingId = accountsId.Except(existingId).ToList(); if (missingId.Count != 0) { var newProfiles = missingId.Select(id => new Profile { Id = Guid.NewGuid(), AccountId = id }).ToList(); await db.BulkInsertAsync(newProfiles); } } public async Task GetAccountById(Guid accountId, bool withProfile = false) { return await db.Accounts .Where(a => a.Id == accountId) .If(withProfile, q => q.Include(a => a.Profile)) .FirstOrDefaultAsync(); } public async Task GetAccountProfile(Guid accountId) { return await db.AccountProfiles.FirstOrDefaultAsync(p => p.AccountId == accountId); } public async Task GetAuthChallenge(Guid challengeId) { return await db.AuthChallenges.FindAsync(challengeId); } public async Task GetAuthChallenge(Guid accountId, string? ipAddress, string? userAgent, Instant now) { return await db.AuthChallenges .Where(e => e.AccountId == accountId) .Where(e => e.IpAddress == ipAddress) .Where(e => e.UserAgent == userAgent) .Where(e => e.StepRemain > 0) .Where(e => e.ExpiredAt != null && now < e.ExpiredAt) .FirstOrDefaultAsync(); } public async Task CreateAuthChallenge(Challenge challenge) { db.AuthChallenges.Add(challenge); await db.SaveChangesAsync(); return challenge; } public async Task GetAccountAuthFactor(Guid factorId, Guid accountId) { return await db.AccountAuthFactors.FirstOrDefaultAsync(f => f.Id == factorId && f.AccountId == accountId); } public async Task> GetAccountAuthFactors(Guid accountId) { return await db.AccountAuthFactors .Where(e => e.AccountId == accountId) .Where(e => e.EnabledAt != null && e.Trustworthy >= 1) .ToListAsync(); } public async Task GetAuthSession(Guid sessionId) { return await db.AuthSessions.FindAsync(sessionId); } public async Task GetMagicSpell(Guid spellId) { return await db.MagicSpells.FindAsync(spellId); } public async Task GetAbuseReport(Guid reportId) { return await db.AbuseReports.FindAsync(reportId); } public async Task CreateAbuseReport(string resourceIdentifier, AbuseReportType type, string reason, Guid accountId) { var existingReport = await db.AbuseReports .Where(r => r.ResourceIdentifier == resourceIdentifier && r.AccountId == accountId && r.DeletedAt == null) .FirstOrDefaultAsync(); if (existingReport != null) { throw new InvalidOperationException("You have already reported this content."); } var report = new AbuseReport { ResourceIdentifier = resourceIdentifier, Type = type, Reason = reason, AccountId = accountId }; db.AbuseReports.Add(report); await db.SaveChangesAsync(); logger.LogInformation("New abuse report created: {ReportId} for resource {ResourceId}", report.Id, resourceIdentifier); return report; } public async Task CountAbuseReports(bool includeResolved = false) { return await db.AbuseReports .Where(r => includeResolved || r.ResolvedAt == null) .CountAsync(); } public async Task CountUserAbuseReports(Guid accountId, bool includeResolved = false) { return await db.AbuseReports .Where(r => r.AccountId == accountId) .Where(r => includeResolved || r.ResolvedAt == null) .CountAsync(); } public async Task> GetAbuseReports(int skip = 0, int take = 20, bool includeResolved = false) { return await db.AbuseReports .Where(r => includeResolved || r.ResolvedAt == null) .OrderByDescending(r => r.CreatedAt) .Skip(skip) .Take(take) .Include(r => r.Account) .ToListAsync(); } public async Task> GetUserAbuseReports(Guid accountId, int skip = 0, int take = 20, bool includeResolved = false) { return await db.AbuseReports .Where(r => r.AccountId == accountId) .Where(r => includeResolved || r.ResolvedAt == null) .OrderByDescending(r => r.CreatedAt) .Skip(skip) .Take(take) .ToListAsync(); } public async Task ResolveAbuseReport(Guid id, string resolution) { var report = await db.AbuseReports.FindAsync(id); if (report == null) { throw new KeyNotFoundException("Report not found"); } report.ResolvedAt = SystemClock.Instance.GetCurrentInstant(); report.Resolution = resolution; await db.SaveChangesAsync(); return report; } public async Task GetPendingAbuseReportsCount() { return await db.AbuseReports .Where(r => r.ResolvedAt == null) .CountAsync(); } public async Task HasRelationshipWithStatus(Guid accountId1, Guid accountId2, Shared.Models.RelationshipStatus status) { return await db.AccountRelationships.AnyAsync(r => (r.AccountId == accountId1 && r.RelatedId == accountId2 && r.Status == status) || (r.AccountId == accountId2 && r.RelatedId == accountId1 && r.Status == status) ); } public async Task> GetStatuses(List accountIds) { return await db.AccountStatuses .Where(s => accountIds.Contains(s.AccountId)) .GroupBy(s => s.AccountId) .ToDictionaryAsync(g => g.Key, g => g.OrderByDescending(s => s.CreatedAt).First()); } public async Task SendNotification(Shared.Models.Account account, string topic, string title, string? subtitle, string body, string? actionUri = null) { await nty.SendNotification(account, topic, title, subtitle, body, actionUri: actionUri); } public async Task> ListAccountFriends(Shared.Models.Account account) { return await relationshipService.ListAccountFriends(account); } public string CreateToken(Shared.Models.Session session) { return authService.CreateToken(session); } public string GetAuthCookieTokenName() { return AuthConstants.CookieTokenName; } public async Task CreateActionLogFromRequest(string type, Dictionary meta, string? ipAddress, string? userAgent, Shared.Models.Account? account = null) { return await actionLogService.CreateActionLogFromRequest(type, meta, ipAddress, userAgent, account); } public async Task UpdateAuthChallenge(Challenge challenge) { db.AuthChallenges.Update(challenge); await db.SaveChangesAsync(); return challenge; } public async Task CreateSession(Instant lastGrantedAt, Instant expiredAt, Shared.Models.Account account, Challenge challenge) { var session = new Session { LastGrantedAt = lastGrantedAt, ExpiredAt = expiredAt, Account = account, Challenge = challenge, }; db.AuthSessions.Add(session); await db.SaveChangesAsync(); return session; } public async Task UpdateSessionLastGrantedAt(Guid sessionId, Instant lastGrantedAt) { await db.AuthSessions .Where(s => s.Id == sessionId) .ExecuteUpdateAsync(s => s.SetProperty(x => x.LastGrantedAt, lastGrantedAt)); } public async Task UpdateAccountProfileLastSeenAt(Guid accountId, Instant lastSeenAt) { await db.AccountProfiles .Where(a => a.AccountId == accountId) .ExecuteUpdateAsync(a => a.SetProperty(x => x.LastSeenAt, lastSeenAt)); } public async Task> SearchAccountsAsync(string searchTerm) { return await db.Accounts .Where(a => EF.Functions.ILike(a.Name, $"%{searchTerm}%")) .OrderBy(a => a.Name) .Take(10) .ToListAsync(); } }