diff --git a/DysonNetwork.Pass/Account/AccountCurrentController.cs b/DysonNetwork.Pass/Account/AccountCurrentController.cs index 9104e0b..97a4913 100644 --- a/DysonNetwork.Pass/Account/AccountCurrentController.cs +++ b/DysonNetwork.Pass/Account/AccountCurrentController.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; using DysonNetwork.Pass.Auth; -using DysonNetwork.Pass.Permission; +using DysonNetwork.Shared.Permission; using DysonNetwork.Shared.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; diff --git a/DysonNetwork.Pass/Account/AccountProfileService.cs b/DysonNetwork.Pass/Account/AccountProfileService.cs new file mode 100644 index 0000000..fc94c64 --- /dev/null +++ b/DysonNetwork.Pass/Account/AccountProfileService.cs @@ -0,0 +1,72 @@ +using DysonNetwork.Shared.Models; +using DysonNetwork.Shared.Services; +using Microsoft.EntityFrameworkCore; +using MagicOnion.Server; + +namespace DysonNetwork.Pass.Account; + +public class AccountProfileService(AppDatabase db) : ServiceBase, IAccountProfileService +{ + public async Task GetAccountProfileByIdAsync(Guid accountId) + { + return await db.AccountProfiles.FirstOrDefaultAsync(p => p.AccountId == accountId); + } + + public async Task UpdateStellarMembershipAsync(Guid accountId, SubscriptionReferenceObject? subscription) + { + var profile = await db.AccountProfiles.FirstOrDefaultAsync(p => p.AccountId == accountId); + if (profile == null) + { + profile = new Profile { AccountId = accountId }; + db.AccountProfiles.Add(profile); + } + + profile.StellarMembership = subscription; + await db.SaveChangesAsync(); + return profile; + } + + public async Task> GetAccountsWithStellarMembershipAsync() + { + return await db.AccountProfiles + .Where(a => a.StellarMembership != null) + .ToListAsync(); + } + + public async Task ClearStellarMembershipsAsync(List accountIds) + { + return await db.AccountProfiles + .Where(a => accountIds.Contains(a.Id)) + .ExecuteUpdateAsync(s => s + .SetProperty(a => a.StellarMembership, p => null) + ); + } + + public async Task UpdateProfilePictureAsync(Guid accountId, CloudFileReferenceObject? picture) + { + var profile = await db.AccountProfiles.FirstOrDefaultAsync(p => p.AccountId == accountId); + if (profile == null) + { + profile = new Profile { AccountId = accountId }; + db.AccountProfiles.Add(profile); + } + + profile.Picture = picture; + await db.SaveChangesAsync(); + return profile; + } + + public async Task UpdateProfileBackgroundAsync(Guid accountId, CloudFileReferenceObject? background) + { + var profile = await db.AccountProfiles.FirstOrDefaultAsync(p => p.AccountId == accountId); + if (profile == null) + { + profile = new Profile { AccountId = accountId }; + db.AccountProfiles.Add(profile); + } + + profile.Background = background; + await db.SaveChangesAsync(); + return profile; + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Account/AccountService.cs b/DysonNetwork.Pass/Account/AccountService.cs index 8fc055b..161f9b0 100644 --- a/DysonNetwork.Pass/Account/AccountService.cs +++ b/DysonNetwork.Pass/Account/AccountService.cs @@ -11,18 +11,27 @@ 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, - // IStringLocalizer localizer, + MagicSpellService spells, + AccountUsernameService uname, + NotificationService nty, + // EmailService mailer, // Commented out for now + IStringLocalizer localizer, ICacheService cache, - ILogger logger + ILogger logger, + AuthService authService, + ActionLogService actionLogService, + RelationshipService relationshipService ) : ServiceBase, IAccountService { public static void SetCultureInfo(Shared.Models.Account account) @@ -134,15 +143,15 @@ public class AccountService( } else { - // var spell = await spells.CreateMagicSpell( - // account, - // MagicSpellType.AccountActivation, - // new Dictionary - // { - // { "contact_method", account.Contacts.First().Content } - // } - // ); - // await spells.NotifyMagicSpell(spell, true); + 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); @@ -167,9 +176,7 @@ public class AccountService( ? userInfo.DisplayName : $"{userInfo.FirstName} {userInfo.LastName}".Trim(); - // Generate username from email - // var username = await uname.GenerateUsernameFromEmailAsync(userInfo.Email); - var username = userInfo.Email.Split('@')[0]; // Placeholder + var username = await uname.GenerateUsernameFromEmailAsync(userInfo.Email); return await CreateAccount( username, @@ -184,28 +191,26 @@ public class AccountService( public async Task RequestAccountDeletion(Shared.Models.Account account) { - await Task.CompletedTask; - // var spell = await spells.CreateMagicSpell( - // account, - // MagicSpellType.AccountRemoval, - // new Dictionary(), - // SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)), - // preventRepeat: true - // ); - // await spells.NotifyMagicSpell(spell); + 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) { - await Task.CompletedTask; - // var spell = await spells.CreateMagicSpell( - // account, - // MagicSpellType.AuthPasswordReset, - // new Dictionary(), - // SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)), - // preventRepeat: true - // ); - // await spells.NotifyMagicSpell(spell); + 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) @@ -331,7 +336,6 @@ public class AccountService( { 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."); @@ -357,14 +361,14 @@ public class AccountService( 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 nty.SendNotification( + account, + "auth.verification", + localizer["AuthCodeTitle"], + null, + localizer["AuthCodeBody", code], + save: true + ); await _SetFactorCode(factor, code, TimeSpan.FromMinutes(5)); break; case AccountAuthFactorType.EmailCode: @@ -399,11 +403,11 @@ public class AccountService( return; } - // await mailer.SendTemplatedEmailAsync( + // await mailer.SendTemplatedEmailAsync( // account.Nick, // contact.Content, // localizer["VerificationEmail"], - // new VerificationEmailModel + // new DysonNetwork.Pass.Pages.Emails.VerificationEmailModel // { // Name = account.Name, // Code = code @@ -456,7 +460,7 @@ public class AccountService( ); } - public async Task UpdateSessionLabel(Shared.Models.Account account, Guid sessionId, string label) + public async Task UpdateSessionLabel(Shared.Models.Account account, Guid sessionId, string label) { var session = await db.AuthSessions .Include(s => s.Challenge) @@ -493,7 +497,7 @@ public class AccountService( .ToListAsync(); if (session.Challenge.DeviceId is not null) - // await nty.UnsubscribePushNotifications(session.Challenge.DeviceId); + await nty.UnsubscribePushNotifications(session.Challenge.DeviceId); // The current session should be included in the sessions' list await db.AuthSessions @@ -522,15 +526,14 @@ public class AccountService( public async Task VerifyContactMethod(Shared.Models.Account account, AccountContact contact) { - await Task.CompletedTask; - // 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); + 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) @@ -614,7 +617,7 @@ public class AccountService( try { var badge = await db.AccountBadges - .Where(b => b.AccountId == account.Id && b.Id == badgeId) + .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."); @@ -657,4 +660,246 @@ public class AccountService( await db.BulkInsertAsync(newProfiles); } } -} \ No newline at end of file + + 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(); + } + + + + +} diff --git a/DysonNetwork.Pass/Account/ActionLogService.cs b/DysonNetwork.Pass/Account/ActionLogService.cs index 6702ab6..4e21b22 100644 --- a/DysonNetwork.Pass/Account/ActionLogService.cs +++ b/DysonNetwork.Pass/Account/ActionLogService.cs @@ -31,28 +31,28 @@ public class ActionLogService : ServiceBase, IActionLogServic // fbs.Enqueue(log); } - public void CreateActionLogFromRequest(string action, Dictionary meta, HttpRequest request, - Shared.Models.Account? account = null) + public async Task CreateActionLogFromRequest(string type, Dictionary meta, string? ipAddress, string? userAgent, Shared.Models.Account? account = null) { var log = new ActionLog { - Action = action, + Action = type, Meta = meta, - UserAgent = request.Headers.UserAgent, - IpAddress = request.HttpContext.Connection.RemoteIpAddress?.ToString(), - // Location = geo.GetPointFromIp(request.HttpContext.Connection.RemoteIpAddress?.ToString()) + UserAgent = userAgent, + IpAddress = ipAddress, + // Location = geo.GetPointFromIp(ipAddress) }; - if (request.HttpContext.Items["CurrentUser"] is Shared.Models.Account currentUser) - log.AccountId = currentUser.Id; - else if (account != null) + if (account != null) log.AccountId = account.Id; else throw new ArgumentException("No user context was found"); - if (request.HttpContext.Items["CurrentSession"] is Session currentSession) - log.SessionId = currentSession.Id; + // For MagicOnion, HttpContext.Items["CurrentSession"] is not directly available. + // You might need to pass session ID explicitly if needed. + // if (request.HttpContext.Items["CurrentSession"] is Session currentSession) + // log.SessionId = currentSession.Id; // fbs.Enqueue(log); + return log; } } \ No newline at end of file diff --git a/DysonNetwork.Pass/Account/MagicSpellService.cs b/DysonNetwork.Pass/Account/MagicSpellService.cs index 1e6d6ea..0debfb8 100644 --- a/DysonNetwork.Pass/Account/MagicSpellService.cs +++ b/DysonNetwork.Pass/Account/MagicSpellService.cs @@ -70,6 +70,11 @@ public class MagicSpellService( return spell; } + public async Task GetMagicSpellByIdAsync(Guid spellId) + { + return await db.MagicSpells.FirstOrDefaultAsync(s => s.Id == spellId); + } + public async Task NotifyMagicSpell(MagicSpell spell, bool bypassVerify = false) { var contact = await db.AccountContacts diff --git a/DysonNetwork.Pass/Account/NotificationController.cs b/DysonNetwork.Pass/Account/NotificationController.cs index 98eb8a8..c938dc2 100644 --- a/DysonNetwork.Pass/Account/NotificationController.cs +++ b/DysonNetwork.Pass/Account/NotificationController.cs @@ -1,7 +1,7 @@ using System.ComponentModel.DataAnnotations; using DysonNetwork.Shared.Models; using DysonNetwork.Pass.Auth; -using DysonNetwork.Pass.Permission; +using DysonNetwork.Shared.Permission; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -141,7 +141,7 @@ public class NotificationController(AppDatabase db, NotificationService nty) : C [HttpPost("send")] [Authorize] - [RequiredPermission("global", "notifications.send")] + [DysonNetwork.Shared.Permission.RequiredPermission("global", "notifications.send")] public async Task SendNotification( [FromBody] NotificationWithAimRequest request, [FromQuery] bool save = false diff --git a/DysonNetwork.Pass/Account/RelationshipService.cs b/DysonNetwork.Pass/Account/RelationshipService.cs index fd49033..9291a7d 100644 --- a/DysonNetwork.Pass/Account/RelationshipService.cs +++ b/DysonNetwork.Pass/Account/RelationshipService.cs @@ -155,18 +155,22 @@ public class RelationshipService(AppDatabase db, ICacheService cache) : ServiceB return relationship; } - public async Task> ListAccountFriends(Shared.Models.Account account) + public async Task> ListAccountFriends(Shared.Models.Account account) { var cacheKey = $"{UserFriendsCacheKeyPrefix}{account.Id}"; - var friends = await cache.GetAsync>(cacheKey); + var friends = await cache.GetAsync>(cacheKey); if (friends == null) { - friends = await db.AccountRelationships + var friendIds = await db.AccountRelationships .Where(r => r.RelatedId == account.Id) .Where(r => r.Status == RelationshipStatus.Friends) .Select(r => r.AccountId) .ToListAsync(); + + friends = await db.Accounts + .Where(a => friendIds.Contains(a.Id)) + .ToListAsync(); await cache.SetAsync(cacheKey, friends, TimeSpan.FromHours(1)); } diff --git a/DysonNetwork.Pass/AppDatabase.cs b/DysonNetwork.Pass/AppDatabase.cs index 013c85e..75f36aa 100644 --- a/DysonNetwork.Pass/AppDatabase.cs +++ b/DysonNetwork.Pass/AppDatabase.cs @@ -50,6 +50,13 @@ public class AppDatabase( public DbSet MagicSpells { get; set; } public DbSet AbuseReports { get; set; } + public DbSet CustomApps { get; set; } + public DbSet CustomAppSecrets { get; set; } + + public DbSet Publishers { get; set; } + public DbSet PublisherMembers { get; set; } + public DbSet PublisherFeatures { get; set; } + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder.UseNpgsql( diff --git a/DysonNetwork.Pass/Auth/AuthService.cs b/DysonNetwork.Pass/Auth/AuthService.cs index d0db306..ef8a0e6 100644 --- a/DysonNetwork.Pass/Auth/AuthService.cs +++ b/DysonNetwork.Pass/Auth/AuthService.cs @@ -11,8 +11,8 @@ namespace DysonNetwork.Pass.Auth; public class AuthService( AppDatabase db, - IConfiguration config - // IHttpClientFactory httpClientFactory, + IConfiguration config, + IHttpClientFactory httpClientFactory // IHttpContextAccessor httpContextAccessor, // ICacheService cache ) @@ -108,53 +108,53 @@ public class AuthService( await Task.CompletedTask; if (string.IsNullOrWhiteSpace(token)) return false; - // var provider = config.GetSection("Captcha")["Provider"]?.ToLower(); - // var apiSecret = config.GetSection("Captcha")["ApiSecret"]; + var provider = config.GetSection("Captcha")["Provider"]?.ToLower(); + var apiSecret = config.GetSection("Captcha")["ApiSecret"]; - // var client = httpClientFactory.CreateClient(); + var client = httpClientFactory.CreateClient(); - // var jsonOpts = new JsonSerializerOptions - // { - // PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, - // DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower - // }; + var jsonOpts = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower + }; - // switch (provider) - // { - // case "cloudflare": - // var content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8, - // "application/x-www-form-urlencoded"); - // var response = await client.PostAsync("https://challenges.cloudflare.com/turnstile/v0/siteverify", - // content); - // response.EnsureSuccessStatusCode(); + switch (provider) + { + case "cloudflare": + var content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8, + "application/x-www-form-urlencoded"); + var response = await client.PostAsync("https://challenges.cloudflare.com/turnstile/v0/siteverify", + content); + response.EnsureSuccessStatusCode(); - // var json = await response.Content.ReadAsStringAsync(); - // var result = JsonSerializer.Deserialize(json, options: jsonOpts); + var json = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize(json, options: jsonOpts); - // return result?.Success == true; - // case "google": - // content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8, - // "application/x-www-form-urlencoded"); - // response = await client.PostAsync("https://www.google.com/recaptcha/siteverify", content); - // response.EnsureSuccessStatusCode(); + return result?.Success == true; + case "google": + content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8, + "application/x-www-form-urlencoded"); + response = await client.PostAsync("https://www.google.com/recaptcha/siteverify", content); + response.EnsureSuccessStatusCode(); - // json = await response.Content.ReadAsStringAsync(); - // result = JsonSerializer.Deserialize(json, options: jsonOpts); + json = await response.Content.ReadAsStringAsync(); + result = JsonSerializer.Deserialize(json, options: jsonOpts); - // return result?.Success == true; - // case "hcaptcha": - // content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8, - // "application/x-www-form-urlencoded"); - // response = await client.PostAsync("https://hcaptcha.com/siteverify", content); - // response.EnsureSuccessStatusCode(); + return result?.Success == true; + case "hcaptcha": + content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8, + "application/x-www-form-urlencoded"); + response = await client.PostAsync("https://hcaptcha.com/siteverify", content); + response.EnsureSuccessStatusCode(); - // json = await response.Content.ReadAsStringAsync(); - // result = JsonSerializer.Deserialize(json, options: jsonOpts); + json = await response.Content.ReadAsStringAsync(); + result = JsonSerializer.Deserialize(json, options: jsonOpts); - // return result?.Success == true; - // default: - // throw new ArgumentException("The server misconfigured for the captcha."); - // } + return result?.Success == true; + default: + throw new ArgumentException("The server misconfigured for the captcha."); + } return true; // Placeholder for captcha validation } diff --git a/DysonNetwork.Pass/Developer/CustomAppService.cs b/DysonNetwork.Pass/Developer/CustomAppService.cs new file mode 100644 index 0000000..5485360 --- /dev/null +++ b/DysonNetwork.Pass/Developer/CustomAppService.cs @@ -0,0 +1,29 @@ +using MagicOnion.Server; +using DysonNetwork.Shared.Services; +using DysonNetwork.Shared.Models; +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; + +namespace DysonNetwork.Pass.Developer; + +public class CustomAppService : ServiceBase, ICustomAppService +{ + private readonly AppDatabase _db; + + public CustomAppService(AppDatabase db) + { + _db = db; + } + + public async Task FindClientByIdAsync(Guid clientId) + { + return await _db.CustomApps.FirstOrDefaultAsync(app => app.Id == clientId); + } + + public async Task CountCustomAppsByPublisherId(Guid publisherId) + { + return await _db.CustomApps.CountAsync(app => app.PublisherId == publisherId); + } +} diff --git a/DysonNetwork.Pass/Localization/EmailResource.cs b/DysonNetwork.Pass/Localization/EmailResource.cs deleted file mode 100644 index 2ebcdb4..0000000 --- a/DysonNetwork.Pass/Localization/EmailResource.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace DysonNetwork.Pass.Localization; - -public class EmailResource -{ -} \ No newline at end of file diff --git a/DysonNetwork.Pass/Pages/Emails/AccountDeletionEmail.razor b/DysonNetwork.Pass/Pages/Emails/AccountDeletionEmail.razor index 2d4f94c..c393429 100644 --- a/DysonNetwork.Pass/Pages/Emails/AccountDeletionEmail.razor +++ b/DysonNetwork.Pass/Pages/Emails/AccountDeletionEmail.razor @@ -1,4 +1,5 @@ @using DysonNetwork.Pass.Localization +@using DysonNetwork.Shared.Localization @using Microsoft.Extensions.Localization diff --git a/DysonNetwork.Pass/Pages/Emails/ContactVerificationEmail.razor b/DysonNetwork.Pass/Pages/Emails/ContactVerificationEmail.razor index 1af0c55..4c1da35 100644 --- a/DysonNetwork.Pass/Pages/Emails/ContactVerificationEmail.razor +++ b/DysonNetwork.Pass/Pages/Emails/ContactVerificationEmail.razor @@ -1,6 +1,5 @@ -@using DysonNetwork.Pass.Localization +@using DysonNetwork.Shared.Localization @using Microsoft.Extensions.Localization -@using EmailResource = DysonNetwork.Pass.Localization.EmailResource diff --git a/DysonNetwork.Pass/Pages/Emails/LandingEmail.razor b/DysonNetwork.Pass/Pages/Emails/LandingEmail.razor index df1e79e..ec4dbf3 100644 --- a/DysonNetwork.Pass/Pages/Emails/LandingEmail.razor +++ b/DysonNetwork.Pass/Pages/Emails/LandingEmail.razor @@ -1,6 +1,5 @@ -@using DysonNetwork.Pass.Localization +@using DysonNetwork.Shared.Localization @using Microsoft.Extensions.Localization -@using EmailResource = DysonNetwork.Pass.Localization.EmailResource diff --git a/DysonNetwork.Pass/Pages/Emails/PasswordResetEmail.razor b/DysonNetwork.Pass/Pages/Emails/PasswordResetEmail.razor index 2b867d9..57ff8be 100644 --- a/DysonNetwork.Pass/Pages/Emails/PasswordResetEmail.razor +++ b/DysonNetwork.Pass/Pages/Emails/PasswordResetEmail.razor @@ -1,6 +1,6 @@ @using DysonNetwork.Pass.Localization +@using DysonNetwork.Shared.Localization @using Microsoft.Extensions.Localization -@using EmailResource = DysonNetwork.Pass.Localization.EmailResource diff --git a/DysonNetwork.Pass/Pages/Emails/VerificationEmail.razor b/DysonNetwork.Pass/Pages/Emails/VerificationEmail.razor index 2763b19..bffc86f 100644 --- a/DysonNetwork.Pass/Pages/Emails/VerificationEmail.razor +++ b/DysonNetwork.Pass/Pages/Emails/VerificationEmail.razor @@ -1,6 +1,6 @@ @using DysonNetwork.Pass.Localization +@using DysonNetwork.Shared.Localization @using Microsoft.Extensions.Localization -@using EmailResource = DysonNetwork.Pass.Localization.EmailResource diff --git a/DysonNetwork.Pass/Permission/PermissionMiddleware.cs b/DysonNetwork.Pass/Permission/PermissionMiddleware.cs deleted file mode 100644 index 4c85e5b..0000000 --- a/DysonNetwork.Pass/Permission/PermissionMiddleware.cs +++ /dev/null @@ -1,51 +0,0 @@ -using Microsoft.AspNetCore.Http; - -namespace DysonNetwork.Pass.Permission; - -[AttributeUsage(AttributeTargets.Method, Inherited = true)] -public class RequiredPermissionAttribute(string area, string key) : Attribute -{ - public string Area { get; set; } = area; - public string Key { get; } = key; -} - -public class PermissionMiddleware(RequestDelegate next) -{ - public async Task InvokeAsync(HttpContext httpContext, PermissionService pm) - { - var endpoint = httpContext.GetEndpoint(); - - var attr = endpoint?.Metadata - .OfType() - .FirstOrDefault(); - - if (attr != null) - { - if (httpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) - { - httpContext.Response.StatusCode = StatusCodes.Status403Forbidden; - await httpContext.Response.WriteAsync("Unauthorized"); - return; - } - - if (currentUser.IsSuperuser) - { - // Bypass the permission check for performance - await next(httpContext); - return; - } - - var actor = $"user:{currentUser.Id}"; - var permNode = await pm.GetPermissionAsync(actor, attr.Area, attr.Key); - - if (!permNode) - { - httpContext.Response.StatusCode = StatusCodes.Status403Forbidden; - await httpContext.Response.WriteAsync($"Permission {attr.Area}/{attr.Key} = {true} was required."); - return; - } - } - - await next(httpContext); - } -} \ No newline at end of file diff --git a/DysonNetwork.Pass/Program.cs b/DysonNetwork.Pass/Program.cs index 44450a3..5c55491 100644 --- a/DysonNetwork.Pass/Program.cs +++ b/DysonNetwork.Pass/Program.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using MagicOnion.Server; var builder = WebApplication.CreateBuilder(args); @@ -24,6 +25,8 @@ builder.Services.AddDbContext(options => builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); var app = builder.Build(); diff --git a/DysonNetwork.Pass/Publisher/PublisherService.cs b/DysonNetwork.Pass/Publisher/PublisherService.cs new file mode 100644 index 0000000..de827a5 --- /dev/null +++ b/DysonNetwork.Pass/Publisher/PublisherService.cs @@ -0,0 +1,53 @@ +using MagicOnion.Server; +using DysonNetwork.Shared.Services; +using DysonNetwork.Shared.Models; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using NodaTime; + +namespace DysonNetwork.Pass.Publisher; + +public class PublisherService : ServiceBase, IPublisherService +{ + private readonly AppDatabase _db; + + public PublisherService(AppDatabase db) + { + _db = db; + } + + public async Task GetPublisherByName(string name) + { + return await _db.Publishers.FirstOrDefaultAsync(p => p.Name == name); + } + + public async Task> GetUserPublishers(Guid accountId) + { + var publisherIds = await _db.PublisherMembers + .Where(m => m.AccountId == accountId) + .Select(m => m.PublisherId) + .ToListAsync(); + + return await _db.Publishers + .Where(p => publisherIds.Contains(p.Id)) + .ToListAsync(); + } + + public async Task IsMemberWithRole(Guid publisherId, Guid accountId, PublisherMemberRole role) + { + return await _db.PublisherMembers.AnyAsync(m => + m.PublisherId == publisherId && + m.AccountId == accountId && + m.Role >= role); + } + + public async Task> GetPublisherFeatures(Guid publisherId) + { + return await _db.PublisherFeatures + .Where(f => f.PublisherId == publisherId) + .ToListAsync(); + } +} diff --git a/DysonNetwork.Sphere/Safety/AbuseReportController.cs b/DysonNetwork.Pass/Safety/AbuseReportController.cs similarity index 92% rename from DysonNetwork.Sphere/Safety/AbuseReportController.cs rename to DysonNetwork.Pass/Safety/AbuseReportController.cs index 7d18e13..2bbba26 100644 --- a/DysonNetwork.Sphere/Safety/AbuseReportController.cs +++ b/DysonNetwork.Pass/Safety/AbuseReportController.cs @@ -1,10 +1,11 @@ using System.ComponentModel.DataAnnotations; -using DysonNetwork.Pass.Account; -using DysonNetwork.Sphere.Permission; +using DysonNetwork.Shared.Models; +using DysonNetwork.Shared.Permission; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace DysonNetwork.Sphere.Safety; +namespace DysonNetwork.Pass.Safety; [ApiController] [Route("/safety/reports")] @@ -85,7 +86,7 @@ public class AbuseReportController( [HttpGet("{id}")] [Authorize] - [RequiredPermission("safety", "reports.view")] + [DysonNetwork.Shared.Permission.RequiredPermission("safety", "reports.view")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task> GetReportById(Guid id) @@ -122,7 +123,7 @@ public class AbuseReportController( [HttpPost("{id}/resolve")] [Authorize] - [RequiredPermissionAttribute("safety", "reports.resolve")] + [DysonNetwork.Shared.Permission.RequiredPermission("safety", "reports.resolve")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task> ResolveReport(Guid id, [FromBody] ResolveReportRequest request) @@ -144,7 +145,7 @@ public class AbuseReportController( [HttpGet("count")] [Authorize] - [RequiredPermission("safety", "reports.view")] + [DysonNetwork.Shared.Permission.RequiredPermission("safety", "reports.view")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> GetReportsCount() { diff --git a/DysonNetwork.Pass/Safety/SafetyService.cs b/DysonNetwork.Pass/Safety/SafetyService.cs new file mode 100644 index 0000000..3f56483 --- /dev/null +++ b/DysonNetwork.Pass/Safety/SafetyService.cs @@ -0,0 +1,61 @@ +using DysonNetwork.Shared.Models; +using DysonNetwork.Shared.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace DysonNetwork.Pass.Safety; + +public class SafetyService(AppDatabase db, IAccountService accountService, ILogger logger) +{ + public async Task CreateReport(string resourceIdentifier, AbuseReportType type, string reason, Guid accountId) + { + // Check if a similar report already exists from this user + 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."); + } + + return await accountService.CreateAbuseReport(resourceIdentifier, type, reason, accountId); + } + + public async Task CountReports(bool includeResolved = false) + { + return await accountService.CountAbuseReports(includeResolved); + } + + public async Task CountUserReports(Guid accountId, bool includeResolved = false) + { + return await accountService.CountUserAbuseReports(accountId, includeResolved); + } + + public async Task> GetReports(int skip = 0, int take = 20, bool includeResolved = false) + { + return await accountService.GetAbuseReports(skip, take, includeResolved); + } + + public async Task> GetUserReports(Guid accountId, int skip = 0, int take = 20, bool includeResolved = false) + { + return await accountService.GetUserAbuseReports(accountId, skip, take, includeResolved); + } + + public async Task GetReportById(Guid id) + { + return await accountService.GetAbuseReport(id); + } + + public async Task ResolveReport(Guid id, string resolution) + { + return await accountService.ResolveAbuseReport(id, resolution); + } + + public async Task GetPendingReportsCount() + { + return await accountService.GetPendingAbuseReportsCount(); + } +} diff --git a/DysonNetwork.Pass/Startup/ServiceCollectionExtensions.cs b/DysonNetwork.Pass/Startup/ServiceCollectionExtensions.cs index 6f7032c..a82bcbc 100644 --- a/DysonNetwork.Pass/Startup/ServiceCollectionExtensions.cs +++ b/DysonNetwork.Pass/Startup/ServiceCollectionExtensions.cs @@ -47,6 +47,7 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Register OIDC services services.AddScoped(); diff --git a/DysonNetwork.Shared/DysonNetwork.Shared.csproj b/DysonNetwork.Shared/DysonNetwork.Shared.csproj index 08dfb52..5e26542 100644 --- a/DysonNetwork.Shared/DysonNetwork.Shared.csproj +++ b/DysonNetwork.Shared/DysonNetwork.Shared.csproj @@ -25,6 +25,10 @@ + + + + diff --git a/DysonNetwork.Shared/Localization/CultureInfoService.cs b/DysonNetwork.Shared/Localization/CultureInfoService.cs new file mode 100644 index 0000000..ac1e08d --- /dev/null +++ b/DysonNetwork.Shared/Localization/CultureInfoService.cs @@ -0,0 +1,18 @@ +using System.Globalization; + +namespace DysonNetwork.Shared.Localization; + +public abstract class CultureInfoService +{ + 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; + } +} \ No newline at end of file diff --git a/DysonNetwork.Shared/Localization/EmailResource.cs b/DysonNetwork.Shared/Localization/EmailResource.cs new file mode 100644 index 0000000..af70f94 --- /dev/null +++ b/DysonNetwork.Shared/Localization/EmailResource.cs @@ -0,0 +1,5 @@ +namespace DysonNetwork.Shared.Localization; + +public class EmailResource +{ +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Account/AbuseReport.cs b/DysonNetwork.Shared/Models/AbuseReport.cs similarity index 95% rename from DysonNetwork.Pass/Account/AbuseReport.cs rename to DysonNetwork.Shared/Models/AbuseReport.cs index 16da213..28bcc35 100644 --- a/DysonNetwork.Pass/Account/AbuseReport.cs +++ b/DysonNetwork.Shared/Models/AbuseReport.cs @@ -1,7 +1,7 @@ using System.ComponentModel.DataAnnotations; using NodaTime; -namespace DysonNetwork.Pass.Account; +namespace DysonNetwork.Shared.Models; public enum AbuseReportType { diff --git a/DysonNetwork.Shared/Permission/MagicOnionPermissionFilter.cs b/DysonNetwork.Shared/Permission/MagicOnionPermissionFilter.cs index d023d5b..fb706e5 100644 --- a/DysonNetwork.Shared/Permission/MagicOnionPermissionFilter.cs +++ b/DysonNetwork.Shared/Permission/MagicOnionPermissionFilter.cs @@ -5,10 +5,14 @@ using Microsoft.Extensions.DependencyInjection; using DysonNetwork.Shared.Models; using System.Threading.Tasks; using System; +using System.Reflection; +using Grpc.Core; +using MagicOnion; +using MagicOnion.Server.Hubs; namespace DysonNetwork.Shared.Permission; -public class MagicOnionPermissionFilter : IMagicOnionFilter +public class MagicOnionPermissionFilter : IMagicOnionServiceFilter { private readonly IPermissionService _permissionService; @@ -17,9 +21,20 @@ public class MagicOnionPermissionFilter : IMagicOnionFilter next) + public async ValueTask Invoke(ServiceContext context, Func next) { - var httpContext = context.GetHttpContext(); + var attribute = context.MethodInfo.GetCustomAttribute(); + + if (attribute == null) + { + // If no RequiredPermissionAttribute is present, just continue + await next(context); + return; + } + + // Correct way to get HttpContext from ServiceContext + var httpContext = context.CallContext.GetHttpContext(); + if (httpContext == null) { throw new InvalidOperationException("HttpContext is not available in ServiceContext."); @@ -27,7 +42,7 @@ public class MagicOnionPermissionFilter : IMagicOnionFilter _logger; + + public EmailService(IConfiguration configuration, ILogger logger) + { + _configuration = configuration; + _logger = logger; + } + + public async Task SendEmailAsync( + string toName, + string toEmail, + string subject, + string body, + Dictionary? headers = null + ) + { + var message = new MimeMessage(); + message.From.Add(new MailboxAddress( + _configuration["Email:SenderName"], + _configuration["Email:SenderEmail"] + )); + message.To.Add(new MailboxAddress(toName, toEmail)); + message.Subject = subject; + + var bodyBuilder = new BodyBuilder { HtmlBody = body }; + message.Body = bodyBuilder.ToMessageBody(); + + if (headers != null) + { + foreach (var header in headers) + { + message.Headers.Add(header.Key, header.Value); + } + } + + using var client = new SmtpClient(); + try + { + await client.ConnectAsync( + _configuration["Email:SmtpHost"], + int.Parse(_configuration["Email:SmtpPort"]), + MailKit.Security.SecureSocketOptions.StartTls + ); + await client.AuthenticateAsync( + _configuration["Email:SmtpUser"], + _configuration["Email:SmtpPass"] + ); + await client.SendAsync(message); + _logger.LogInformation("Email sent to {ToEmail}", toEmail); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send email to {ToEmail}", toEmail); + throw; + } + finally + { + await client.DisconnectAsync(true); + } + } + + public async Task SendTemplatedEmailAsync(string toName, string toEmail, string subject, string htmlBody) + { + await SendEmailAsync(toName, toEmail, subject, htmlBody); + } +} \ No newline at end of file diff --git a/DysonNetwork.Shared/Services/IAccountEventService.cs b/DysonNetwork.Shared/Services/IAccountEventService.cs index c631786..bf0d0cb 100644 --- a/DysonNetwork.Shared/Services/IAccountEventService.cs +++ b/DysonNetwork.Shared/Services/IAccountEventService.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using DysonNetwork.Shared.Models; using MagicOnion; using NodaTime; @@ -10,17 +11,22 @@ public interface IAccountEventService : IService /// Purges the status cache for a user /// void PurgeStatusCache(Guid userId); - + /// /// Gets the status of a user /// Task GetStatus(Guid userId); - + + /// + /// Gets the statuses of a list of users + /// + Task> GetStatuses(List userIds); + /// /// Performs a daily check-in for a user /// Task CheckInDaily(Account user); - + /// /// Gets the check-in streak for a user /// diff --git a/DysonNetwork.Shared/Services/IAccountProfileService.cs b/DysonNetwork.Shared/Services/IAccountProfileService.cs new file mode 100644 index 0000000..df0836a --- /dev/null +++ b/DysonNetwork.Shared/Services/IAccountProfileService.cs @@ -0,0 +1,54 @@ +using DysonNetwork.Shared.Models; +using MagicOnion; +using System; +using System.Threading.Tasks; + +namespace DysonNetwork.Shared.Services +{ + public interface IAccountProfileService : IService + { + /// + /// Gets an account profile by account ID. + /// + /// The ID of the account. + /// The account profile if found, otherwise null. + Task GetAccountProfileByIdAsync(Guid accountId); + + /// + /// Updates the StellarMembership of an account. + /// + /// The ID of the account. + /// The subscription to set as the StellarMembership. + /// The updated account profile. + Task UpdateStellarMembershipAsync(Guid accountId, SubscriptionReferenceObject? subscription); + + /// + /// Gets all account profiles that have a non-null StellarMembership. + /// + /// A list of account profiles with StellarMembership. + Task> GetAccountsWithStellarMembershipAsync(); + + /// + /// Clears the StellarMembership for a list of account IDs. + /// + /// The list of account IDs for which to clear the StellarMembership. + /// The number of accounts updated. + Task ClearStellarMembershipsAsync(List accountIds); + + /// + /// Updates the profile picture of an account. + /// + /// The ID of the account. + /// The new profile picture reference object. + /// The updated profile. + Task UpdateProfilePictureAsync(Guid accountId, CloudFileReferenceObject? picture); + + /// + /// Updates the profile background of an account. + /// + /// The ID of the account. + /// The new profile background reference object. + /// The updated profile. + Task UpdateProfileBackgroundAsync(Guid accountId, CloudFileReferenceObject? background); + } +} diff --git a/DysonNetwork.Shared/Services/IAccountService.cs b/DysonNetwork.Shared/Services/IAccountService.cs index 200120c..742d32c 100644 --- a/DysonNetwork.Shared/Services/IAccountService.cs +++ b/DysonNetwork.Shared/Services/IAccountService.cs @@ -1,5 +1,8 @@ using DysonNetwork.Shared.Models; using MagicOnion; +using System.Collections.Generic; +using System.Threading.Tasks; +using System; namespace DysonNetwork.Shared.Services; @@ -59,4 +62,247 @@ public interface IAccountService : IService /// The OpenID Connect user information /// The newly created account Task CreateAccount(OidcUserInfo userInfo); -} \ No newline at end of file + + /// + /// Gets an account by its ID. + /// + /// The ID of the account. + /// Join the profile table or not. + /// The account if found, otherwise null. + Task GetAccountById(Guid accountId, bool withProfile = false); + + /// + /// Gets an account profile by account ID. + /// + /// The ID of the account. + /// The account profile if found, otherwise null. + Task GetAccountProfile(Guid accountId); + + /// + /// Gets an authentication challenge by its ID. + /// + /// The ID of the challenge. + /// The authentication challenge if found, otherwise null. + Task GetAuthChallenge(Guid challengeId); + + /// + /// Gets an authentication challenge by account ID, IP address, and user agent. + /// + /// The ID of the account. + /// The IP address. + /// The user agent. + /// The current instant. + /// The authentication challenge if found, otherwise null. + Task GetAuthChallenge(Guid accountId, string? ipAddress, string? userAgent, NodaTime.Instant now); + + /// + /// Creates a new authentication challenge. + /// + /// The challenge to create. + /// The created challenge. + Task CreateAuthChallenge(Challenge challenge); + + + + /// + /// Gets an account authentication factor by its ID and account ID. + /// + /// The ID of the factor. + /// The ID of the account. + /// The account authentication factor if found, otherwise null. + Task GetAccountAuthFactor(Guid factorId, Guid accountId); + + /// + /// Gets a list of account authentication factors for a given account ID. + /// + /// The ID of the account. + /// A list of account authentication factors. + Task> GetAccountAuthFactors(Guid accountId); + + /// + /// Gets an authentication session by its ID. + /// + /// The ID of the session. + /// The authentication session if found, otherwise null. + Task GetAuthSession(Guid sessionId); + + /// + /// Gets a magic spell by its ID. + /// + /// The ID of the magic spell. + /// The magic spell if found, otherwise null. + Task GetMagicSpell(Guid spellId); + + /// + /// Gets an abuse report by its ID. + /// + /// The ID of the abuse report. + /// The abuse report if found, otherwise null. + Task GetAbuseReport(Guid reportId); + + /// + /// Creates a new abuse report. + /// + /// The identifier of the resource being reported. + /// The type of abuse report. + /// The reason for the report. + /// The ID of the account making the report. + /// The created abuse report. + Task CreateAbuseReport(string resourceIdentifier, AbuseReportType type, string reason, Guid accountId); + + /// + /// Counts abuse reports. + /// + /// Whether to include resolved reports. + /// The count of abuse reports. + Task CountAbuseReports(bool includeResolved = false); + + /// + /// Counts abuse reports by a specific user. + /// + /// The ID of the account. + /// Whether to include resolved reports. + /// The count of abuse reports by the user. + Task CountUserAbuseReports(Guid accountId, bool includeResolved = false); + + /// + /// Gets a list of abuse reports. + /// + /// Number of reports to skip. + /// Number of reports to take. + /// Whether to include resolved reports. + /// A list of abuse reports. + Task> GetAbuseReports(int skip = 0, int take = 20, bool includeResolved = false); + + /// + /// Gets a list of abuse reports by a specific user. + /// + /// The ID of the account. + /// Number of reports to skip. + /// Number of reports to take. + /// Whether to include resolved reports. + /// A list of abuse reports by the user. + Task> GetUserAbuseReports(Guid accountId, int skip = 0, int take = 20, bool includeResolved = false); + + /// + /// Resolves an abuse report. + /// + /// The ID of the report to resolve. + /// The resolution message. + /// The resolved abuse report. + Task ResolveAbuseReport(Guid id, string resolution); + + /// + /// Gets the count of pending abuse reports. + /// + /// The count of pending abuse reports. + Task GetPendingAbuseReportsCount(); + + /// + /// Checks if a relationship with a specific status exists between two accounts. + /// + /// The ID of the first account. + /// The ID of the second account. + /// The relationship status to check for. + /// True if the relationship exists, otherwise false. + Task HasRelationshipWithStatus(Guid accountId1, Guid accountId2, RelationshipStatus status); + + /// + /// Gets the statuses for a list of account IDs. + /// + /// A list of account IDs. + /// A dictionary where the key is the account ID and the value is the status. + Task> GetStatuses(List accountIds); + + /// + /// Sends a notification to an account. + /// + /// The target account. + /// The notification topic. + /// The notification title. + /// The notification subtitle. + /// The notification body. + /// The action URI for the notification. + /// A task representing the asynchronous operation. + Task SendNotification(Account account, string topic, string title, string? subtitle, string body, string? actionUri = null); + + /// + /// Lists the friends of an account. + /// + /// The account. + /// A list of friend accounts. + Task> ListAccountFriends(Account account); + + /// + /// Verifies an authentication factor code. + /// + /// The authentication factor. + /// The code to verify. + /// True if the code is valid, otherwise false. + Task VerifyFactorCode(AccountAuthFactor factor, string code); + + /// + /// 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 + Task SendFactorCode(Account account, AccountAuthFactor factor, string? hint = null); + + /// + /// Creates an action log entry. + /// + /// The type of action log. + /// Additional metadata for the action log. + /// The HTTP request. + /// The account associated with the action. + /// The created action log. + Task CreateActionLogFromRequest(string type, Dictionary meta, string? ipAddress, string? userAgent, Account? account = null); + + + + /// + /// Creates a new session. + /// + /// The last granted instant. + /// The expiration instant. + /// The associated account. + /// The associated challenge. + /// The created session. + Task CreateSession(NodaTime.Instant lastGrantedAt, NodaTime.Instant expiredAt, Account account, Challenge challenge); + + /// + /// Updates the LastGrantedAt for a session. + /// + /// The ID of the session. + /// The new LastGrantedAt instant. + Task UpdateSessionLastGrantedAt(Guid sessionId, NodaTime.Instant lastGrantedAt); + + /// + /// Updates the LastSeenAt for an account profile. + /// + /// The ID of the account. + /// The new LastSeenAt instant. + Task UpdateAccountProfileLastSeenAt(Guid accountId, NodaTime.Instant lastSeenAt); + + /// + /// Creates a token for a session. + /// + /// The session. + /// The token string. + string CreateToken(Session session); + + /// + /// Gets the AuthConstants.CookieTokenName. + /// + /// The cookie token name. + string GetAuthCookieTokenName(); + + /// + /// Searches for accounts by a search term. + /// + /// The term to search for. + /// A list of matching accounts. + Task> SearchAccountsAsync(string searchTerm); +} diff --git a/DysonNetwork.Shared/Services/IActionLogService.cs b/DysonNetwork.Shared/Services/IActionLogService.cs index 01ea69d..fa3fadf 100644 --- a/DysonNetwork.Shared/Services/IActionLogService.cs +++ b/DysonNetwork.Shared/Services/IActionLogService.cs @@ -1,7 +1,6 @@ using System.Collections.Generic; using DysonNetwork.Shared.Models; using MagicOnion; -using Microsoft.AspNetCore.Http; namespace DysonNetwork.Shared.Services; @@ -15,10 +14,11 @@ public interface IActionLogService : IService /// /// Creates an action log entry from an HTTP request /// - void CreateActionLogFromRequest( - string action, + Task CreateActionLogFromRequest( + string type, Dictionary meta, - HttpRequest request, + string? ipAddress, + string? userAgent, Account? account = null ); -} +} \ No newline at end of file diff --git a/DysonNetwork.Shared/Services/ICustomAppService.cs b/DysonNetwork.Shared/Services/ICustomAppService.cs new file mode 100644 index 0000000..c471a5e --- /dev/null +++ b/DysonNetwork.Shared/Services/ICustomAppService.cs @@ -0,0 +1,13 @@ +using MagicOnion; +using DysonNetwork.Shared.Models; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace DysonNetwork.Shared.Services; + +public interface ICustomAppService : IService +{ + Task FindClientByIdAsync(Guid clientId); + Task CountCustomAppsByPublisherId(Guid publisherId); +} diff --git a/DysonNetwork.Shared/Services/IMagicSpellService.cs b/DysonNetwork.Shared/Services/IMagicSpellService.cs index 768f60e..004f7a4 100644 --- a/DysonNetwork.Shared/Services/IMagicSpellService.cs +++ b/DysonNetwork.Shared/Services/IMagicSpellService.cs @@ -22,7 +22,21 @@ public interface IMagicSpellService : IService /// Gets a magic spell by its token /// Task GetMagicSpellAsync(string token); - + + /// + /// Gets a magic spell by its ID. + /// + /// The ID of the magic spell. + /// The magic spell if found, otherwise null. + Task GetMagicSpellByIdAsync(Guid spellId); + + /// + /// Applies a password reset magic spell. + /// + /// The magic spell object. + /// The new password. + Task ApplyPasswordReset(MagicSpell spell, string newPassword); + /// /// Consumes a magic spell /// diff --git a/DysonNetwork.Shared/Services/INotificationService.cs b/DysonNetwork.Shared/Services/INotificationService.cs index 9ce323d..d168bcb 100644 --- a/DysonNetwork.Shared/Services/INotificationService.cs +++ b/DysonNetwork.Shared/Services/INotificationService.cs @@ -1,5 +1,7 @@ using DysonNetwork.Shared.Models; using MagicOnion; +using System.Collections.Generic; +using System.Threading.Tasks; namespace DysonNetwork.Shared.Services; @@ -20,4 +22,25 @@ public interface INotificationService : IService string deviceId, string deviceToken ); + + Task SendNotification( + Account account, + string topic, + string? title = null, + string? subtitle = null, + string? content = null, + Dictionary? meta = null, + string? actionUri = null, + bool isSilent = false, + bool save = true + ); + + Task DeliveryNotification(Notification notification); + + Task MarkNotificationsViewed(ICollection notifications); + + Task BroadcastNotification(Notification notification, bool save = false); + + Task SendNotificationBatch(Notification notification, List accounts, + bool save = false); } diff --git a/DysonNetwork.Shared/Services/IPublisherService.cs b/DysonNetwork.Shared/Services/IPublisherService.cs new file mode 100644 index 0000000..43313a1 --- /dev/null +++ b/DysonNetwork.Shared/Services/IPublisherService.cs @@ -0,0 +1,15 @@ +using MagicOnion; +using DysonNetwork.Shared.Models; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace DysonNetwork.Shared.Services; + +public interface IPublisherService : IService +{ + Task GetPublisherByName(string name); + Task> GetUserPublishers(Guid accountId); + Task IsMemberWithRole(Guid publisherId, Guid accountId, PublisherMemberRole role); + Task> GetPublisherFeatures(Guid publisherId); +} diff --git a/DysonNetwork.Shared/Services/IRelationshipService.cs b/DysonNetwork.Shared/Services/IRelationshipService.cs index c2b1ed8..781a0fb 100644 --- a/DysonNetwork.Shared/Services/IRelationshipService.cs +++ b/DysonNetwork.Shared/Services/IRelationshipService.cs @@ -1,3 +1,6 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; using DysonNetwork.Shared.Models; using MagicOnion; @@ -9,7 +12,7 @@ public interface IRelationshipService : IService /// Checks if a relationship exists between two accounts /// Task HasExistingRelationship(Guid accountId, Guid relatedId); - + /// /// Gets a relationship between two accounts /// @@ -19,9 +22,58 @@ public interface IRelationshipService : IService RelationshipStatus? status = null, bool ignoreExpired = false ); - + /// /// Creates a new relationship between two accounts /// Task CreateRelationship(Account sender, Account target, RelationshipStatus status); + + /// + /// Blocks a user + /// + Task BlockAccount(Account sender, Account target); + + /// + /// Unblocks a user + /// + Task UnblockAccount(Account sender, Account target); + + /// + /// Sends a friend request to a user + /// + Task SendFriendRequest(Account sender, Account target); + + /// + /// Deletes a friend request + /// + Task DeleteFriendRequest(Guid accountId, Guid relatedId); + + /// + /// Accepts a friend request + /// + Task AcceptFriendRelationship( + Relationship relationship, + RelationshipStatus status = RelationshipStatus.Friends + ); + + /// + /// Updates a relationship between two users + /// + Task UpdateRelationship(Guid accountId, Guid relatedId, RelationshipStatus status); + + /// + /// Lists all friends of an account + /// + Task> ListAccountFriends(Account account); + + /// + /// Lists all blocked users of an account + /// + Task> ListAccountBlocked(Account account); + + /// + /// Checks if a relationship with a specific status exists between two accounts + /// + Task HasRelationshipWithStatus(Guid accountId, Guid relatedId, + RelationshipStatus status = RelationshipStatus.Friends); } diff --git a/DysonNetwork.Sphere/Activity/Activity.cs b/DysonNetwork.Sphere/Activity/Activity.cs index 41f6498..8d4367a 100644 --- a/DysonNetwork.Sphere/Activity/Activity.cs +++ b/DysonNetwork.Sphere/Activity/Activity.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using NodaTime; +using DysonNetwork.Shared.Models; namespace DysonNetwork.Sphere.Activity; diff --git a/DysonNetwork.Sphere/Activity/ActivityService.cs b/DysonNetwork.Sphere/Activity/ActivityService.cs index 1f3300e..3bc35b2 100644 --- a/DysonNetwork.Sphere/Activity/ActivityService.cs +++ b/DysonNetwork.Sphere/Activity/ActivityService.cs @@ -11,7 +11,7 @@ namespace DysonNetwork.Sphere.Activity; public class ActivityService( AppDatabase db, PublisherService pub, - DysonNetwork.Shared.Services.IRelationshipService rels, + Shared.Services.IRelationshipService rels, PostService ps, DiscoveryService ds ) @@ -125,7 +125,7 @@ public class ActivityService( ) { var activities = new List(); - var userFriends = await rels.ListAccountFriends(currentUser); + var userFriends = (await rels.ListAccountFriends(currentUser)).Select(x => x.Id).ToList(); var userPublishers = await pub.GetUserPublishers(currentUser.Id); debugInclude ??= []; diff --git a/DysonNetwork.Sphere/AppDatabase.cs b/DysonNetwork.Sphere/AppDatabase.cs index 75942cc..a01759d 100644 --- a/DysonNetwork.Sphere/AppDatabase.cs +++ b/DysonNetwork.Sphere/AppDatabase.cs @@ -10,6 +10,8 @@ using Microsoft.EntityFrameworkCore.Query; using NodaTime; using Quartz; +namespace DysonNetwork.Sphere; + public class AppDatabase( DbContextOptions options, IConfiguration configuration @@ -18,18 +20,18 @@ public class AppDatabase( public DbSet Files { get; set; } public DbSet FileReferences { get; set; } - public DbSet Publishers { get; set; } + public DbSet Publishers { get; set; } public DbSet PublisherMembers { get; set; } public DbSet PublisherSubscriptions { get; set; } public DbSet PublisherFeatures { get; set; } - public DbSet Posts { get; set; } + public DbSet Posts { get; set; } public DbSet PostReactions { get; set; } public DbSet PostTags { get; set; } public DbSet PostCategories { get; set; } public DbSet PostCollections { get; set; } - public DbSet Realms { get; set; } + public DbSet Realms { get; set; } public DbSet RealmMembers { get; set; } public DbSet ChatRooms { get; set; } @@ -38,10 +40,10 @@ public class AppDatabase( public DbSet ChatRealtimeCall { get; set; } public DbSet ChatReactions { get; set; } - public DbSet Stickers { get; set; } + public DbSet Stickers { get; set; } public DbSet StickerPacks { get; set; } - public DbSet Wallets { get; set; } + public DbSet Wallets { get; set; } public DbSet WalletPockets { get; set; } public DbSet PaymentOrders { get; set; } public DbSet PaymentTransactions { get; set; } @@ -95,7 +97,7 @@ public class AppDatabase( .HasForeignKey(ps => ps.AccountId) .OnDelete(DeleteBehavior.Cascade); - modelBuilder.Entity() + modelBuilder.Entity() .HasGeneratedTsVectorColumn(p => p.SearchVector, "simple", p => new { p.Title, p.Description, p.Content }) .HasIndex(p => p.SearchVector) .HasMethod("GIN"); @@ -110,25 +112,25 @@ public class AppDatabase( .HasForeignKey(s => s.AppId) .OnDelete(DeleteBehavior.Cascade); - modelBuilder.Entity() + modelBuilder.Entity() .HasOne(p => p.RepliedPost) .WithMany() .HasForeignKey(p => p.RepliedPostId) .OnDelete(DeleteBehavior.Restrict); - modelBuilder.Entity() + modelBuilder.Entity() .HasOne(p => p.ForwardedPost) .WithMany() .HasForeignKey(p => p.ForwardedPostId) .OnDelete(DeleteBehavior.Restrict); - modelBuilder.Entity() + modelBuilder.Entity() .HasMany(p => p.Tags) .WithMany(t => t.Posts) .UsingEntity(j => j.ToTable("post_tag_links")); - modelBuilder.Entity() + modelBuilder.Entity() .HasMany(p => p.Categories) .WithMany(c => c.Posts) .UsingEntity(j => j.ToTable("post_category_links")); - modelBuilder.Entity() + modelBuilder.Entity() .HasMany(p => p.Collections) .WithMany(c => c.Posts) .UsingEntity(j => j.ToTable("post_collection_links")); diff --git a/DysonNetwork.Sphere/Chat/ChatController.cs b/DysonNetwork.Sphere/Chat/ChatController.cs index 0c014c8..8fb4ab4 100644 --- a/DysonNetwork.Sphere/Chat/ChatController.cs +++ b/DysonNetwork.Sphere/Chat/ChatController.cs @@ -1,7 +1,7 @@ using System.ComponentModel.DataAnnotations; using System.Text.RegularExpressions; using DysonNetwork.Shared.Models; -using DysonNetwork.Sphere.Permission; +using DysonNetwork.Shared.Permission; using DysonNetwork.Sphere.Storage; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -137,7 +137,7 @@ public partial class ChatController(AppDatabase db, ChatService cs, ChatRoomServ [HttpPost("{roomId:guid}/messages")] [Authorize] - [RequiredPermissionAttribute("global", "chat.messages.create")] + [DysonNetwork.Shared.Permission.RequiredPermission("global", "chat.messages.create")] public async Task SendMessage([FromBody] SendMessageRequest request, Guid roomId) { if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized(); diff --git a/DysonNetwork.Sphere/Chat/ChatRoomController.cs b/DysonNetwork.Sphere/Chat/ChatRoomController.cs index 31385d9..52941cf 100644 --- a/DysonNetwork.Sphere/Chat/ChatRoomController.cs +++ b/DysonNetwork.Sphere/Chat/ChatRoomController.cs @@ -1,10 +1,10 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using System.ComponentModel.DataAnnotations; +using DysonNetwork.Pass.Account; using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Services; using DysonNetwork.Sphere.Localization; -using DysonNetwork.Sphere.Permission; using DysonNetwork.Sphere.Realm; using DysonNetwork.Sphere.Storage; using Microsoft.AspNetCore.Authorization; @@ -20,10 +20,12 @@ public class ChatRoomController( FileReferenceService fileRefService, ChatRoomService crs, RealmService rs, - DysonNetwork.Shared.Services.IActionLogService als, - DysonNetwork.Shared.Services.INotificationService nty, - DysonNetwork.Shared.Services.IRelationshipService rels, - DysonNetwork.Shared.Services.IAccountEventService aes + IAccountService accounts, + IActionLogService als, + INotificationService nty, + IRelationshipService rels, + IAccountEventService aes, + IStringLocalizer localizer ) : ControllerBase { [HttpGet("{id:guid}")] @@ -46,7 +48,7 @@ public class ChatRoomController( [Authorize] public async Task>> ListJoinedChatRooms() { - if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); var userId = currentUser.Id; @@ -72,10 +74,10 @@ public class ChatRoomController( [Authorize] public async Task> CreateDirectMessage([FromBody] DirectMessageRequest request) { - if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); - var relatedUser = await db.Accounts.FindAsync(request.RelatedUserId); + var relatedUser = await accounts.GetAccountById(request.RelatedUserId); if (relatedUser is null) return BadRequest("Related user was not found"); @@ -104,7 +106,7 @@ public class ChatRoomController( { AccountId = currentUser.Id, Role = ChatMemberRole.Owner, - JoinedAt = NodaTime.Instant.FromDateTimeUtc(DateTime.UtcNow) + JoinedAt = Instant.FromDateTimeUtc(DateTime.UtcNow) }, new() { @@ -118,9 +120,12 @@ public class ChatRoomController( db.ChatRooms.Add(dmRoom); await db.SaveChangesAsync(); - als.CreateActionLogFromRequest( + await als.CreateActionLogFromRequest( ActionLogType.ChatroomCreate, - new Dictionary { { "chatroom_id", dmRoom.Id } }, Request + new Dictionary { { "chatroom_id", dmRoom.Id } }, + Request.HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString(), + currentUser ); var invitedMember = dmRoom.Members.First(m => m.AccountId == request.RelatedUserId); @@ -161,7 +166,7 @@ public class ChatRoomController( [HttpPost] [Authorize] - [RequiredPermissionAttribute("global", "chat.create")] + [DysonNetwork.Shared.Permission.RequiredPermission("global", "chat.create")] public async Task> CreateChatRoom(ChatRoomRequest request) { if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized(); @@ -224,9 +229,12 @@ public class ChatRoomController( chatRoomResourceId ); - als.CreateActionLogFromRequest( + await als.CreateActionLogFromRequest( ActionLogType.ChatroomCreate, - new Dictionary { { "chatroom_id", chatRoom.Id } }, Request + new Dictionary { { "chatroom_id", chatRoom.Id } }, + Request.HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString(), + currentUser ); return Ok(chatRoom); @@ -310,9 +318,12 @@ public class ChatRoomController( db.ChatRooms.Update(chatRoom); await db.SaveChangesAsync(); - als.CreateActionLogFromRequest( + await als.CreateActionLogFromRequest( ActionLogType.ChatroomUpdate, - new Dictionary { { "chatroom_id", chatRoom.Id } }, Request + new Dictionary { { "chatroom_id", chatRoom.Id } }, + Request.HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString(), + currentUser ); return Ok(chatRoom); @@ -344,9 +355,12 @@ public class ChatRoomController( db.ChatRooms.Remove(chatRoom); await db.SaveChangesAsync(); - als.CreateActionLogFromRequest( + await als.CreateActionLogFromRequest( ActionLogType.ChatroomDelete, - new Dictionary { { "chatroom_id", chatRoom.Id } }, Request + new Dictionary { { "chatroom_id", chatRoom.Id } }, + Request.HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString(), + currentUser ); return NoContent(); @@ -435,7 +449,6 @@ public class ChatRoomController( } } - public class ChatMemberRequest { @@ -451,7 +464,7 @@ public class ChatRoomController( if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized(); var userId = currentUser.Id; - var relatedUser = await db.Accounts.FindAsync(request.RelatedUserId); + var relatedUser = await accounts.GetAccountById(request.RelatedUserId); if (relatedUser is null) return BadRequest("Related user was not found"); if (await rels.HasRelationshipWithStatus(currentUser.Id, relatedUser.Id, RelationshipStatus.Blocked)) @@ -507,9 +520,12 @@ public class ChatRoomController( newMember.ChatRoom = chatRoom; await _SendInviteNotify(newMember, currentUser); - als.CreateActionLogFromRequest( + await als.CreateActionLogFromRequest( ActionLogType.ChatroomInvite, - new Dictionary { { "chatroom_id", chatRoom.Id }, { "account_id", relatedUser.Id } }, Request + new Dictionary { { "chatroom_id", chatRoom.Id }, { "account_id", relatedUser.Id } }, + Request.HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString(), + currentUser ); return Ok(newMember); @@ -559,9 +575,12 @@ public class ChatRoomController( await db.SaveChangesAsync(); _ = crs.PurgeRoomMembersCache(roomId); - als.CreateActionLogFromRequest( + await als.CreateActionLogFromRequest( ActionLogType.ChatroomJoin, - new Dictionary { { "chatroom_id", roomId } }, Request + new Dictionary { { "chatroom_id", roomId } }, + Request.HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString(), + currentUser ); return Ok(member); @@ -675,7 +694,9 @@ public class ChatRoomController( ActionLogType.RealmAdjustRole, new Dictionary { { "chatroom_id", roomId }, { "account_id", memberId }, { "new_role", newRole } }, - Request + Request.HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString(), + currentUser ); return Ok(targetMember); @@ -722,7 +743,10 @@ public class ChatRoomController( als.CreateActionLogFromRequest( ActionLogType.ChatroomKick, - new Dictionary { { "chatroom_id", roomId }, { "account_id", memberId } }, Request + new Dictionary { { "chatroom_id", roomId }, { "account_id", memberId } }, + Request.HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString(), + currentUser ); return NoContent(); @@ -762,9 +786,12 @@ public class ChatRoomController( await db.SaveChangesAsync(); _ = crs.PurgeRoomMembersCache(roomId); - als.CreateActionLogFromRequest( + await als.CreateActionLogFromRequest( ActionLogType.ChatroomJoin, - new Dictionary { { "chatroom_id", roomId } }, Request + new Dictionary { { "chatroom_id", roomId } }, + Request.HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString(), + currentUser ); return Ok(chatRoom); @@ -799,15 +826,18 @@ public class ChatRoomController( await db.SaveChangesAsync(); await crs.PurgeRoomMembersCache(roomId); - als.CreateActionLogFromRequest( + await als.CreateActionLogFromRequest( ActionLogType.ChatroomLeave, - new Dictionary { { "chatroom_id", roomId } }, Request + new Dictionary { { "chatroom_id", roomId } }, + Request.HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString(), + currentUser ); return NoContent(); } - private async Task _SendInviteNotify(ChatMember member, Shared.Models.Account sender) + private async Task _SendInviteNotify(ChatMember member, Account sender) { string title = localizer["ChatInviteTitle"]; diff --git a/DysonNetwork.Sphere/Chat/ChatService.cs b/DysonNetwork.Sphere/Chat/ChatService.cs index 4b17f04..460573c 100644 --- a/DysonNetwork.Sphere/Chat/ChatService.cs +++ b/DysonNetwork.Sphere/Chat/ChatService.cs @@ -1,5 +1,6 @@ using System.Text.RegularExpressions; using DysonNetwork.Shared.Models; +using DysonNetwork.Shared.Services; using DysonNetwork.Sphere.Chat.Realtime; using DysonNetwork.Sphere.Connection; using DysonNetwork.Sphere.Storage; @@ -204,7 +205,7 @@ public partial class ChatService( using var scope = scopeFactory.CreateScope(); var scopedWs = scope.ServiceProvider.GetRequiredService(); - var scopedNty = scope.ServiceProvider.GetRequiredService(); + var scopedNty = scope.ServiceProvider.GetRequiredService(); var scopedCrs = scope.ServiceProvider.GetRequiredService(); var roomSubject = room is { Type: ChatRoomType.DirectMessage, Name: null } ? "DM" : diff --git a/DysonNetwork.Sphere/Connection/AutoCompletionController.cs b/DysonNetwork.Sphere/Connection/AutoCompletionController.cs index 94eae1c..4a7b6ef 100644 --- a/DysonNetwork.Sphere/Connection/AutoCompletionController.cs +++ b/DysonNetwork.Sphere/Connection/AutoCompletionController.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using DysonNetwork.Shared.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -6,7 +7,7 @@ namespace DysonNetwork.Sphere.Connection; [ApiController] [Route("completion")] -public class AutoCompletionController(AppDatabase db) +public class AutoCompletionController(IAccountService accounts, AppDatabase db) : ControllerBase { [HttpPost] @@ -38,19 +39,15 @@ public class AutoCompletionController(AppDatabase db) private async Task> GetAccountCompletions(string searchTerm) { - return await db.Accounts - .Where(a => EF.Functions.ILike(a.Name, $"%{searchTerm}%")) - .OrderBy(a => a.Name) - .Take(10) - .Select(a => new CompletionItem - { - Id = a.Id.ToString(), - DisplayName = a.Name, - SecondaryText = a.Nick, - Type = "account", - Data = a - }) - .ToListAsync(); + var data = await accounts.SearchAccountsAsync(searchTerm); + return data.Select(a => new CompletionItem + { + Id = a.Id.ToString(), + DisplayName = a.Name, + SecondaryText = a.Nick, + Type = "account", + Data = a + }).ToList(); } private async Task> GetStickerCompletions(string searchTerm) diff --git a/DysonNetwork.Sphere/Connection/WebReader/WebArticle.cs b/DysonNetwork.Sphere/Connection/WebReader/WebArticle.cs index 5f608c9..288635a 100644 --- a/DysonNetwork.Sphere/Connection/WebReader/WebArticle.cs +++ b/DysonNetwork.Sphere/Connection/WebReader/WebArticle.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json.Serialization; +using DysonNetwork.Shared.Models; namespace DysonNetwork.Sphere.Connection.WebReader; diff --git a/DysonNetwork.Sphere/Connection/WebReader/WebReaderController.cs b/DysonNetwork.Sphere/Connection/WebReader/WebReaderController.cs index af21877..505aae8 100644 --- a/DysonNetwork.Sphere/Connection/WebReader/WebReaderController.cs +++ b/DysonNetwork.Sphere/Connection/WebReader/WebReaderController.cs @@ -1,4 +1,4 @@ -using DysonNetwork.Sphere.Permission; +using DysonNetwork.Shared.Permission; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; @@ -59,7 +59,7 @@ public class WebReaderController(WebReaderService reader, ILogger [HttpDelete("link/cache")] [Authorize] - [RequiredPermissionAttribute("maintenance", "cache.scrap")] + [DysonNetwork.Shared.Permission.RequiredPermission("maintenance", "cache.scrap")] public async Task InvalidateCache([FromQuery] string url) { if (string.IsNullOrEmpty(url)) @@ -76,7 +76,7 @@ public class WebReaderController(WebReaderService reader, ILogger [HttpDelete("cache/all")] [Authorize] - [RequiredPermissionAttribute("maintenance", "cache.scrap")] + [DysonNetwork.Shared.Permission.RequiredPermission("maintenance", "cache.scrap")] public async Task InvalidateAllCache() { await reader.InvalidateAllCachedPreviewsAsync(); diff --git a/DysonNetwork.Sphere/Developer/DeveloperController.cs b/DysonNetwork.Sphere/Developer/DeveloperController.cs index 0a4a6ba..acb86d5 100644 --- a/DysonNetwork.Sphere/Developer/DeveloperController.cs +++ b/DysonNetwork.Sphere/Developer/DeveloperController.cs @@ -1,5 +1,5 @@ using DysonNetwork.Shared.Models; -using DysonNetwork.Sphere.Permission; +using DysonNetwork.Shared.Permission; using DysonNetwork.Sphere.Publisher; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -90,7 +90,7 @@ public class DeveloperController( [HttpPost("{name}/enroll")] [Authorize] - [RequiredPermissionAttribute("global", "developers.create")] + [DysonNetwork.Shared.Permission.RequiredPermission("global", "developers.create")] public async Task> EnrollDeveloperProgram(string name) { if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized(); diff --git a/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj b/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj index 9f32b49..61f6e10 100644 --- a/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj +++ b/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj @@ -27,6 +27,8 @@ + + @@ -153,6 +155,7 @@ NotificationResource.resx + diff --git a/DysonNetwork.Sphere/Email/EmailService.cs b/DysonNetwork.Sphere/Email/EmailService.cs deleted file mode 100644 index f533e57..0000000 --- a/DysonNetwork.Sphere/Email/EmailService.cs +++ /dev/null @@ -1,106 +0,0 @@ -using MailKit.Net.Smtp; -using Microsoft.AspNetCore.Components; -using MimeKit; - -namespace DysonNetwork.Sphere.Email; - -public class EmailServiceConfiguration -{ - public string Server { get; set; } = null!; - public int Port { get; set; } - public bool UseSsl { get; set; } - public string Username { get; set; } = null!; - public string Password { get; set; } = null!; - public string FromAddress { get; set; } = null!; - public string FromName { get; set; } = null!; - public string SubjectPrefix { get; set; } = null!; -} - -public class EmailService -{ - private readonly EmailServiceConfiguration _configuration; - private readonly RazorViewRenderer _viewRenderer; - private readonly ILogger _logger; - - public EmailService(IConfiguration configuration, RazorViewRenderer viewRenderer, ILogger logger) - { - var cfg = configuration.GetSection("Email").Get(); - _configuration = cfg ?? throw new ArgumentException("Email service was not configured."); - _viewRenderer = viewRenderer; - _logger = logger; - } - - public async Task SendEmailAsync(string? recipientName, string recipientEmail, string subject, string textBody) - { - await SendEmailAsync(recipientName, recipientEmail, subject, textBody, null); - } - - public async Task SendEmailAsync(string? recipientName, string recipientEmail, string subject, string textBody, - string? htmlBody) - { - subject = $"[{_configuration.SubjectPrefix}] {subject}"; - - var emailMessage = new MimeMessage(); - emailMessage.From.Add(new MailboxAddress(_configuration.FromName, _configuration.FromAddress)); - emailMessage.To.Add(new MailboxAddress(recipientName, recipientEmail)); - emailMessage.Subject = subject; - - var bodyBuilder = new BodyBuilder - { - TextBody = textBody - }; - - if (!string.IsNullOrEmpty(htmlBody)) - bodyBuilder.HtmlBody = htmlBody; - - emailMessage.Body = bodyBuilder.ToMessageBody(); - - using var client = new SmtpClient(); - await client.ConnectAsync(_configuration.Server, _configuration.Port, _configuration.UseSsl); - await client.AuthenticateAsync(_configuration.Username, _configuration.Password); - await client.SendAsync(emailMessage); - await client.DisconnectAsync(true); - } - - private static string _ConvertHtmlToPlainText(string html) - { - // Remove style tags and their contents - html = System.Text.RegularExpressions.Regex.Replace(html, "]*>.*?", "", - System.Text.RegularExpressions.RegexOptions.Singleline); - - // Replace header tags with text + newlines - html = System.Text.RegularExpressions.Regex.Replace(html, "]*>(.*?)", "$1\n\n", - System.Text.RegularExpressions.RegexOptions.IgnoreCase); - - // Replace line breaks - html = html.Replace("
", "\n").Replace("
", "\n").Replace("
", "\n"); - - // Remove all remaining HTML tags - html = System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", ""); - - // Decode HTML entities - html = System.Net.WebUtility.HtmlDecode(html); - - // Remove excess whitespace - html = System.Text.RegularExpressions.Regex.Replace(html, @"\s+", " ").Trim(); - - return html; - } - - public async Task SendTemplatedEmailAsync(string? recipientName, string recipientEmail, - string subject, TModel model) - where TComponent : IComponent - { - try - { - var htmlBody = await _viewRenderer.RenderComponentToStringAsync(model); - var fallbackTextBody = _ConvertHtmlToPlainText(htmlBody); - await SendEmailAsync(recipientName, recipientEmail, subject, fallbackTextBody, htmlBody); - } - catch (Exception err) - { - _logger.LogError(err, "Failed to render email template..."); - throw; - } - } -} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Pages/Auth/Authorize.cshtml.cs b/DysonNetwork.Sphere/Pages/Auth/Authorize.cshtml.cs index 13d0011..7443143 100644 --- a/DysonNetwork.Sphere/Pages/Auth/Authorize.cshtml.cs +++ b/DysonNetwork.Sphere/Pages/Auth/Authorize.cshtml.cs @@ -1,10 +1,10 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; -using DysonNetwork.Pass.Auth; using System.ComponentModel.DataAnnotations; using DysonNetwork.Shared.Models; using DysonNetwork.Pass.Auth; using DysonNetwork.Sphere.Developer; +using DysonNetwork.Pass.Auth.OidcProvider.Responses; namespace DysonNetwork.Sphere.Pages.Auth; diff --git a/DysonNetwork.Sphere/Pages/Auth/Login.cshtml.cs b/DysonNetwork.Sphere/Pages/Auth/Login.cshtml.cs index e72de7e..54237f8 100644 --- a/DysonNetwork.Sphere/Pages/Auth/Login.cshtml.cs +++ b/DysonNetwork.Sphere/Pages/Auth/Login.cshtml.cs @@ -11,13 +11,13 @@ using Microsoft.EntityFrameworkCore; namespace DysonNetwork.Sphere.Pages.Auth { public class LoginModel( - AppDatabase db, DysonNetwork.Shared.Services.IAccountService accounts, DysonNetwork.Pass.Auth.AuthService auth, GeoIpService geo, DysonNetwork.Shared.Services.IActionLogService als ) : PageModel { + [BindProperty] [Required] public string Username { get; set; } = string.Empty; [BindProperty] @@ -52,13 +52,7 @@ namespace DysonNetwork.Sphere.Pages.Auth var userAgent = HttpContext.Request.Headers.UserAgent.ToString(); var now = Instant.FromDateTimeUtc(DateTime.UtcNow); - var existingChallenge = await db.AuthChallenges - .Where(e => e.Account == account) - .Where(e => e.IpAddress == ipAddress) - .Where(e => e.UserAgent == userAgent) - .Where(e => e.StepRemain > 0) - .Where(e => e.ExpiredAt != null && now < e.ExpiredAt) - .FirstOrDefaultAsync(); + var existingChallenge = await accounts.GetAuthChallenge(account.Id, ipAddress, userAgent, now); if (existingChallenge is not null) { @@ -79,8 +73,7 @@ namespace DysonNetwork.Sphere.Pages.Auth AccountId = account.Id }.Normalize(); - await db.AuthChallenges.AddAsync(challenge); - await db.SaveChangesAsync(); + await accounts.CreateAuthChallenge(challenge); // If we have a return URL, pass it to the verify page if (TempData.TryGetValue("ReturnUrl", out var returnUrl) && returnUrl is string url) diff --git a/DysonNetwork.Sphere/Pages/Auth/SelectFactor.cshtml.cs b/DysonNetwork.Sphere/Pages/Auth/SelectFactor.cshtml.cs index ed44568..7ebd151 100644 --- a/DysonNetwork.Sphere/Pages/Auth/SelectFactor.cshtml.cs +++ b/DysonNetwork.Sphere/Pages/Auth/SelectFactor.cshtml.cs @@ -8,10 +8,8 @@ using DysonNetwork.Pass.Account; namespace DysonNetwork.Sphere.Pages.Auth; public class SelectFactorModel( - AppDatabase db, - AccountService accounts -) - : PageModel + DysonNetwork.Shared.Services.IAccountService accounts +) : PageModel { [BindProperty(SupportsGet = true)] public Guid Id { get; set; } [BindProperty(SupportsGet = true)] public string? ReturnUrl { get; set; } @@ -31,13 +29,11 @@ public class SelectFactorModel( public async Task OnPostSelectFactorAsync() { - var challenge = await db.AuthChallenges - .Include(e => e.Account) - .FirstOrDefaultAsync(e => e.Id == Id); + var challenge = await accounts.GetAuthChallenge(Id); if (challenge == null) return NotFound(); - var factor = await db.AccountAuthFactors.FindAsync(SelectedFactorId); + var factor = await accounts.GetAccountAuthFactor(SelectedFactorId, challenge.Account.Id); if (factor?.EnabledAt == null || factor.Trustworthy <= 0) return BadRequest("Invalid authentication method."); @@ -81,16 +77,11 @@ public class SelectFactorModel( private async Task LoadChallengeAndFactors() { - AuthChallenge = await db.AuthChallenges - .Include(e => e.Account) - .FirstOrDefaultAsync(e => e.Id == Id); + AuthChallenge = await accounts.GetAuthChallenge(Id); if (AuthChallenge != null) { - AuthFactors = await db.AccountAuthFactors - .Where(e => e.AccountId == AuthChallenge.Account.Id) - .Where(e => e.EnabledAt != null && e.Trustworthy >= 1) - .ToListAsync(); + AuthFactors = await accounts.GetAccountAuthFactors(AuthChallenge.Account.Id); } } diff --git a/DysonNetwork.Sphere/Pages/Auth/VerifyFactor.cshtml b/DysonNetwork.Sphere/Pages/Auth/VerifyFactor.cshtml index 2881f1a..5c99409 100644 --- a/DysonNetwork.Sphere/Pages/Auth/VerifyFactor.cshtml +++ b/DysonNetwork.Sphere/Pages/Auth/VerifyFactor.cshtml @@ -1,6 +1,5 @@ @page "/web/auth/challenge/{id:guid}/verify/{factorId:guid}" @using DysonNetwork.Shared.Models -@using DysonNetwork.Shared.Models @model DysonNetwork.Sphere.Pages.Auth.VerifyFactorModel @{ ViewData["Title"] = "Verify Your Identity"; diff --git a/DysonNetwork.Sphere/Pages/Auth/VerifyFactor.cshtml.cs b/DysonNetwork.Sphere/Pages/Auth/VerifyFactor.cshtml.cs index f278dbf..3fae7f9 100644 --- a/DysonNetwork.Sphere/Pages/Auth/VerifyFactor.cshtml.cs +++ b/DysonNetwork.Sphere/Pages/Auth/VerifyFactor.cshtml.cs @@ -4,20 +4,17 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.EntityFrameworkCore; using DysonNetwork.Shared.Services; -using DysonNetwork.Shared.Services; using NodaTime; namespace DysonNetwork.Sphere.Pages.Auth { public class VerifyFactorModel( AppDatabase db, - DysonNetwork.Shared.Services.IAccountService accountService, + IAccountService accountService, DysonNetwork.Pass.Auth.AuthService authService, - DysonNetwork.Shared.Services.IActionLogService actionLogService, - IConfiguration configuration, - IHttpClientFactory httpClientFactory - ) - : PageModel + IActionLogService actionLogService, + IConfiguration configuration + ) : PageModel { [BindProperty(SupportsGet = true)] public Guid Id { get; set; } @@ -55,30 +52,36 @@ namespace DysonNetwork.Sphere.Pages.Auth try { - if (await accounts.VerifyFactorCode(Factor, Code)) + if (await accountService.VerifyFactorCode(Factor, Code)) { AuthChallenge.StepRemain -= Factor.Trustworthy; AuthChallenge.StepRemain = Math.Max(0, AuthChallenge.StepRemain); - AuthChallenge.BlacklistFactors.Add(Factor.Id); - db.Update(AuthChallenge); - als.CreateActionLogFromRequest(ActionLogType.ChallengeSuccess, + await actionLogService.CreateActionLogFromRequest(ActionLogType.ChallengeSuccess, new Dictionary { { "challenge_id", AuthChallenge.Id }, { "factor_id", Factor?.Id.ToString() ?? string.Empty } - }, Request, AuthChallenge.Account); + }, + Request.HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString(), + AuthChallenge.Account + ); await db.SaveChangesAsync(); if (AuthChallenge.StepRemain == 0) { - als.CreateActionLogFromRequest(ActionLogType.NewLogin, + await actionLogService.CreateActionLogFromRequest(ActionLogType.NewLogin, new Dictionary { { "challenge_id", AuthChallenge.Id }, { "account_id", AuthChallenge.AccountId } - }, Request, AuthChallenge.Account); + }, + Request.HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString(), + AuthChallenge.Account + ); return await ExchangeTokenAndRedirect(); } @@ -98,16 +101,18 @@ namespace DysonNetwork.Sphere.Pages.Auth { if (AuthChallenge != null) { - AuthChallenge.FailedAttempts++; - db.Update(AuthChallenge); await db.SaveChangesAsync(); - als.CreateActionLogFromRequest(ActionLogType.ChallengeFailure, + await actionLogService.CreateActionLogFromRequest(ActionLogType.ChallengeFailure, new Dictionary { { "challenge_id", AuthChallenge.Id }, { "factor_id", Factor?.Id.ToString() ?? string.Empty } - }, Request, AuthChallenge.Account); + }, + Request.HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString(), + AuthChallenge.Account + ); } @@ -118,47 +123,30 @@ namespace DysonNetwork.Sphere.Pages.Auth private async Task LoadChallengeAndFactor() { - AuthChallenge = await db.AuthChallenges - .Include(e => e.Account) - .FirstOrDefaultAsync(e => e.Id == Id); + AuthChallenge = await accountService.GetAuthChallenge(Id); if (AuthChallenge?.Account != null) { - Factor = await db.AccountAuthFactors - .FirstOrDefaultAsync(e => e.Id == FactorId && - e.AccountId == AuthChallenge.Account.Id && - e.EnabledAt != null && - e.Trustworthy > 0); + Factor = await accountService.GetAccountAuthFactor(FactorId, AuthChallenge.Account.Id); } } private async Task ExchangeTokenAndRedirect() { - var challenge = await db.AuthChallenges - .Include(e => e.Account) - .FirstOrDefaultAsync(e => e.Id == Id); + var challenge = await accountService.GetAuthChallenge(Id); if (challenge == null) return BadRequest("Authorization code not found or expired."); if (challenge.StepRemain != 0) return BadRequest("Challenge not yet completed."); - var session = await db.AuthSessions - .FirstOrDefaultAsync(e => e.ChallengeId == challenge.Id); + var session = await accountService.CreateSession( + Instant.FromDateTimeUtc(DateTime.UtcNow), + Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(30)), + challenge.Account, + challenge + ); - if (session == null) - { - session = new Session - { - LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow), - ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(30)), - Account = challenge.Account, - Challenge = challenge, - }; - db.AuthSessions.Add(session); - await db.SaveChangesAsync(); - } - - var token = auth.CreateToken(session); - Response.Cookies.Append(AuthConstants.CookieTokenName, token, new CookieOptions + var token = authService.CreateToken(session); + Response.Cookies.Append(accountService.GetAuthCookieTokenName(), token, new CookieOptions { HttpOnly = true, Secure = !configuration.GetValue("Debug"), diff --git a/DysonNetwork.Sphere/Pages/Shared/_Layout.cshtml b/DysonNetwork.Sphere/Pages/Shared/_Layout.cshtml index 4475ef4..7c87adb 100644 --- a/DysonNetwork.Sphere/Pages/Shared/_Layout.cshtml +++ b/DysonNetwork.Sphere/Pages/Shared/_Layout.cshtml @@ -1,4 +1,4 @@ -using DysonNetwork.Pass.Auth; +@using DysonNetwork.Pass.Auth diff --git a/DysonNetwork.Sphere/Pages/Spell/MagicSpellPage.cshtml.cs b/DysonNetwork.Sphere/Pages/Spell/MagicSpellPage.cshtml.cs index f76678f..4671ac0 100644 --- a/DysonNetwork.Sphere/Pages/Spell/MagicSpellPage.cshtml.cs +++ b/DysonNetwork.Sphere/Pages/Spell/MagicSpellPage.cshtml.cs @@ -6,7 +6,7 @@ using NodaTime; namespace DysonNetwork.Sphere.Pages.Spell; -public class MagicSpellPage(AppDatabase db, DysonNetwork.Shared.Services.IMagicSpellService magicSpellService) : PageModel +public class MagicSpellPage(DysonNetwork.Shared.Services.IMagicSpellService magicSpellService) : PageModel { [BindProperty] public MagicSpell? CurrentSpell { get; set; } [BindProperty] public string? NewPassword { get; set; } @@ -17,12 +17,7 @@ public class MagicSpellPage(AppDatabase db, DysonNetwork.Shared.Services.IMa { spellWord = Uri.UnescapeDataString(spellWord); var now = SystemClock.Instance.GetCurrentInstant(); - CurrentSpell = await db.MagicSpells - .Where(e => e.Spell == spellWord) - .Where(e => e.ExpiresAt == null || now < e.ExpiresAt) - .Where(e => e.AffectedAt == null || now >= e.AffectedAt) - .Include(e => e.Account) - .FirstOrDefaultAsync(); + CurrentSpell = await magicSpellService.GetMagicSpellAsync(spellWord); return Page(); } @@ -33,19 +28,15 @@ public class MagicSpellPage(AppDatabase db, DysonNetwork.Shared.Services.IMa return Page(); var now = SystemClock.Instance.GetCurrentInstant(); - var spell = await db.MagicSpells - .Where(e => e.Id == CurrentSpell.Id) - .Where(e => e.ExpiresAt == null || now < e.ExpiresAt) - .Where(e => e.AffectedAt == null || now >= e.AffectedAt) - .FirstOrDefaultAsync(); + var spell = await magicSpellService.GetMagicSpellByIdAsync(CurrentSpell.Id); if (spell == null || spell.Type == MagicSpellType.AuthPasswordReset && string.IsNullOrWhiteSpace(NewPassword)) return Page(); if (spell.Type == MagicSpellType.AuthPasswordReset) - await spells.ApplyPasswordReset(spell, NewPassword!); + await magicSpellService.ApplyPasswordReset(spell, NewPassword!); else - await spells.ApplyMagicSpell(spell); + await magicSpellService.ApplyMagicSpell(spell.Spell); IsSuccess = true; return Page(); } diff --git a/DysonNetwork.Sphere/Permission/RequiredPermissionAttribute.cs b/DysonNetwork.Sphere/Permission/RequiredPermissionAttribute.cs deleted file mode 100644 index ee38236..0000000 --- a/DysonNetwork.Sphere/Permission/RequiredPermissionAttribute.cs +++ /dev/null @@ -1,43 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Filters; -using DysonNetwork.Shared.Services; -using MagicOnion; - -namespace DysonNetwork.Sphere.Permission; - -public class RequiredPermissionAttribute : TypeFilterAttribute -{ - public RequiredPermissionAttribute(string scope, string permission) : base(typeof(RequiredPermissionFilter)) - { - Arguments = new object[] { scope, permission }; - } - - private class RequiredPermissionFilter : IAsyncActionFilter - { - private readonly IPermissionService _permissionService; - private readonly string _scope; - private readonly string _permission; - - public RequiredPermissionFilter(IPermissionService permissionService, string scope, string permission) - { - _permissionService = permissionService; - _scope = scope; - _permission = permission; - } - - public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) - { - // Assuming the actor is always "user:current" for client-side checks - // You might need to adjust this based on how your client identifies itself - var hasPermission = await _permissionService.CheckPermission(_scope, _permission); - - if (!hasPermission) - { - context.Result = new ForbidResult(); - return; - } - - await next(); - } - } -} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Post/PostController.cs b/DysonNetwork.Sphere/Post/PostController.cs index 83392e0..3e319f7 100644 --- a/DysonNetwork.Sphere/Post/PostController.cs +++ b/DysonNetwork.Sphere/Post/PostController.cs @@ -2,7 +2,7 @@ using System.ComponentModel.DataAnnotations; using System.Text.Json; using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Services; -using DysonNetwork.Sphere.Permission; +using DysonNetwork.Shared.Permission; using DysonNetwork.Sphere.Publisher; using DysonNetwork.Sphere.Storage; using Microsoft.AspNetCore.Authorization; @@ -33,7 +33,9 @@ public class PostController( { HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); var currentUser = currentUserValue as Shared.Models.Account; - var userFriends = currentUser is null ? [] : await rels.ListAccountFriends(currentUser); + var userFriends = currentUser is null + ? [] + : (await rels.ListAccountFriends(currentUser)).Select(e => e.Id).ToList(); var userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(currentUser.Id); var publisher = pubName == null ? null : await db.Publishers.FirstOrDefaultAsync(p => p.Name == pubName); @@ -67,8 +69,10 @@ public class PostController( public async Task> GetPost(Guid id) { HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); - var currentUser = currentUserValue as Shared.Models.Account; - var userFriends = currentUser is null ? [] : await rels.ListAccountFriends(currentUser); + var currentUser = currentUserValue as Account; + var userFriends = currentUser is null + ? [] + : (await rels.ListAccountFriends(currentUser)).Select(e => e.Id).ToList(); var userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(currentUser.Id); var post = await db.Posts @@ -99,8 +103,10 @@ public class PostController( return BadRequest("Search query cannot be empty"); HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); - var currentUser = currentUserValue as Shared.Models.Account; - var userFriends = currentUser is null ? [] : await rels.ListAccountFriends(currentUser); + var currentUser = currentUserValue as Account; + var userFriends = currentUser is null + ? [] + : (await rels.ListAccountFriends(currentUser)).Select(e => e.Id).ToList(); var userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(currentUser.Id); var queryable = db.Posts @@ -136,8 +142,10 @@ public class PostController( [FromQuery] int take = 20) { HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); - var currentUser = currentUserValue as Shared.Models.Account; - var userFriends = currentUser is null ? [] : await rels.ListAccountFriends(currentUser); + var currentUser = currentUserValue as Account; + var userFriends = currentUser is null + ? [] + : (await rels.ListAccountFriends(currentUser)).Select(e => e.Id).ToList(); var userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(currentUser.Id); var parent = await db.Posts @@ -264,9 +272,12 @@ public class PostController( return BadRequest(err.Message); } - als.CreateActionLogFromRequest( + await als.CreateActionLogFromRequest( ActionLogType.PostCreate, - new Dictionary { { "post_id", post.Id } }, Request + new Dictionary { { "post_id", post.Id } }, + Request.HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString(), + currentUser ); return post; @@ -280,12 +291,12 @@ public class PostController( [HttpPost("{id:guid}/reactions")] [Authorize] - [RequiredPermissionAttribute("global", "posts.react")] + [RequiredPermission("global", "posts.react")] public async Task> ReactPost(Guid id, [FromBody] PostReactionRequest request) { HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); - if (currentUserValue is not Shared.Models.Account currentUser) return Unauthorized(); - var userFriends = await rels.ListAccountFriends(currentUser); + if (currentUserValue is not Account currentUser) return Unauthorized(); + var userFriends = (await rels.ListAccountFriends(currentUser)).Select(e => e.Id).ToList(); var userPublishers = await pub.GetUserPublishers(currentUser.Id); var post = await db.Posts @@ -319,9 +330,12 @@ public class PostController( if (isRemoving) return NoContent(); - als.CreateActionLogFromRequest( + await als.CreateActionLogFromRequest( ActionLogType.PostReact, - new Dictionary { { "post_id", post.Id }, { "reaction", request.Symbol } }, Request + new Dictionary { { "post_id", post.Id }, { "reaction", request.Symbol } }, + Request.HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString(), + currentUser ); return Ok(reaction); @@ -368,9 +382,12 @@ public class PostController( return BadRequest(err.Message); } - als.CreateActionLogFromRequest( + await als.CreateActionLogFromRequest( ActionLogType.PostUpdate, - new Dictionary { { "post_id", post.Id } }, Request + new Dictionary { { "post_id", post.Id } }, + Request.HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString(), + currentUser ); return Ok(post); @@ -392,9 +409,12 @@ public class PostController( await ps.DeletePostAsync(post); - als.CreateActionLogFromRequest( + await als.CreateActionLogFromRequest( ActionLogType.PostDelete, - new Dictionary { { "post_id", post.Id } }, Request + new Dictionary { { "post_id", post.Id } }, + Request.HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString(), + currentUser ); return NoContent(); diff --git a/DysonNetwork.Sphere/Post/PostService.cs b/DysonNetwork.Sphere/Post/PostService.cs index 8cd588e..50e9b49 100644 --- a/DysonNetwork.Sphere/Post/PostService.cs +++ b/DysonNetwork.Sphere/Post/PostService.cs @@ -1,6 +1,7 @@ using System.Text.RegularExpressions; using DysonNetwork.Shared.Cache; -using DysonNetwork.Shared.Models; +using DysonNetwork.Shared.Localization; +using DysonNetwork.Shared.Services; using DysonNetwork.Sphere.Connection.WebReader; using DysonNetwork.Sphere.Localization; using DysonNetwork.Sphere.Publisher; @@ -158,14 +159,13 @@ public partial class PostService( var sender = post.Publisher; using var scope = factory.CreateScope(); var pub = scope.ServiceProvider.GetRequiredService(); - var nty = scope.ServiceProvider.GetRequiredService(); - var logger = scope.ServiceProvider.GetRequiredService>(); + var nty = scope.ServiceProvider.GetRequiredService(); try { var members = await pub.GetPublisherMembers(post.RepliedPost.PublisherId); foreach (var member in members) { - AccountService.SetCultureInfo(member.Account); + CultureInfoService.SetCultureInfo(member.Account); var (_, content) = ChopPostForNotification(post); await nty.SendNotification( member.Account, @@ -439,14 +439,14 @@ public partial class PostService( { using var scope = factory.CreateScope(); var pub = scope.ServiceProvider.GetRequiredService(); - var nty = scope.ServiceProvider.GetRequiredService(); + var nty = scope.ServiceProvider.GetRequiredService(); var logger = scope.ServiceProvider.GetRequiredService>(); try { var members = await pub.GetPublisherMembers(post.PublisherId); foreach (var member in members) { - AccountService.SetCultureInfo(member.Account); + CultureInfoService.SetCultureInfo(member.Account); await nty.SendNotification( member.Account, "posts.reactions.new", diff --git a/DysonNetwork.Sphere/Program.cs b/DysonNetwork.Sphere/Program.cs index e35aa43..b0e551e 100644 --- a/DysonNetwork.Sphere/Program.cs +++ b/DysonNetwork.Sphere/Program.cs @@ -2,6 +2,9 @@ using DysonNetwork.Sphere; using DysonNetwork.Sphere.Startup; using Microsoft.EntityFrameworkCore; using tusdotnet.Stores; +using MagicOnion.Client; +using DysonNetwork.Shared.Services; +using Grpc.Net.Client; var builder = WebApplication.CreateBuilder(args); @@ -20,6 +23,42 @@ builder.Services.AddAppSwagger(); // Add gRPC services builder.Services.AddGrpc(); +// Configure MagicOnion client for IAccountService +builder.Services.AddSingleton(provider => +{ + var passServiceUrl = builder.Configuration["PassService:Url"]; + if (string.IsNullOrEmpty(passServiceUrl)) + { + throw new InvalidOperationException("PassService:Url configuration is missing."); + } + var channel = GrpcChannel.ForAddress(passServiceUrl); + return MagicOnionClient.Create(channel); +}); + +// Configure MagicOnion client for IPublisherService +builder.Services.AddSingleton(provider => +{ + var passServiceUrl = builder.Configuration["PassService:Url"]; + if (string.IsNullOrEmpty(passServiceUrl)) + { + throw new InvalidOperationException("PassService:Url configuration is missing."); + } + var channel = GrpcChannel.ForAddress(passServiceUrl); + return MagicOnionClient.Create(channel); +}); + +// Configure MagicOnion client for ICustomAppService +builder.Services.AddSingleton(provider => +{ + var passServiceUrl = builder.Configuration["PassService:Url"]; + if (string.IsNullOrEmpty(passServiceUrl)) + { + throw new InvalidOperationException("PassService:Url configuration is missing."); + } + var channel = GrpcChannel.ForAddress(passServiceUrl); + return MagicOnionClient.Create(channel); +}); + // Add file storage builder.Services.AddAppFileStorage(builder.Configuration); @@ -47,8 +86,8 @@ var tusDiskStore = app.Services.GetRequiredService(); // Configure application middleware pipeline app.ConfigureAppMiddleware(builder.Configuration, tusDiskStore); -// Map gRPC services -app.MapGrpcService(); -app.MapGrpcService(); +// Remove direct gRPC service mappings for Pass services +// app.MapGrpcService(); +// app.MapGrpcService(); app.Run(); \ No newline at end of file diff --git a/DysonNetwork.Sphere/Publisher/PublisherController.cs b/DysonNetwork.Sphere/Publisher/PublisherController.cs index a5c91f8..8c63799 100644 --- a/DysonNetwork.Sphere/Publisher/PublisherController.cs +++ b/DysonNetwork.Sphere/Publisher/PublisherController.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using DysonNetwork.Shared.Models; -using DysonNetwork.Sphere.Permission; +using DysonNetwork.Shared.Permission; +using DysonNetwork.Shared.Services; using DysonNetwork.Sphere.Realm; using DysonNetwork.Sphere.Storage; using Microsoft.AspNetCore.Authorization; @@ -16,7 +17,9 @@ public class PublisherController( AppDatabase db, PublisherService ps, FileReferenceService fileRefService, - DysonNetwork.Shared.Services.IActionLogService als) + IAccountService accounts, + IActionLogService als +) : ControllerBase { [HttpGet("{name}")] @@ -28,10 +31,7 @@ public class PublisherController( if (publisher is null) return NotFound(); if (publisher.AccountId is null) return Ok(publisher); - var account = await db.Accounts - .Where(a => a.Id == publisher.AccountId) - .Include(a => a.Profile) - .FirstOrDefaultAsync(); + var account = await accounts.GetAccountById(publisher.AccountId.Value, true); publisher.Account = account; return Ok(publisher); @@ -79,7 +79,7 @@ public class PublisherController( public class PublisherMemberRequest { - [Required] public long RelatedUserId { get; set; } + [Required] public Guid RelatedUserId { get; set; } [Required] public PublisherMemberRole Role { get; set; } } @@ -91,7 +91,7 @@ public class PublisherController( if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized(); var userId = currentUser.Id; - var relatedUser = await db.Accounts.FindAsync(request.RelatedUserId); + var relatedUser = await accounts.GetAccountById(request.RelatedUserId); if (relatedUser is null) return BadRequest("Related user was not found"); var publisher = await db.Publishers @@ -112,13 +112,16 @@ public class PublisherController( db.PublisherMembers.Add(newMember); await db.SaveChangesAsync(); - als.CreateActionLogFromRequest( + await als.CreateActionLogFromRequest( ActionLogType.PublisherMemberInvite, new Dictionary { { "publisher_id", publisher.Id }, { "account_id", relatedUser.Id } - }, Request + }, + Request.HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString(), + currentUser ); return Ok(newMember); @@ -142,9 +145,12 @@ public class PublisherController( db.Update(member); await db.SaveChangesAsync(); - als.CreateActionLogFromRequest( + await als.CreateActionLogFromRequest( ActionLogType.PublisherMemberJoin, - new Dictionary { { "account_id", member.AccountId } }, Request + new Dictionary { { "account_id", member.AccountId } }, + Request.HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString(), + currentUser ); return Ok(member); @@ -167,9 +173,12 @@ public class PublisherController( db.PublisherMembers.Remove(member); await db.SaveChangesAsync(); - als.CreateActionLogFromRequest( + await als.CreateActionLogFromRequest( ActionLogType.PublisherMemberLeave, - new Dictionary { { "account_id", member.AccountId } }, Request + new Dictionary { { "account_id", member.AccountId } }, + Request.HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString(), + currentUser ); return NoContent(); @@ -197,13 +206,16 @@ public class PublisherController( db.PublisherMembers.Remove(member); await db.SaveChangesAsync(); - als.CreateActionLogFromRequest( + await als.CreateActionLogFromRequest( ActionLogType.PublisherMemberKick, new Dictionary { { "publisher_id", publisher.Id }, { "account_id", memberId } - }, Request + }, + Request.HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString(), + currentUser ); return NoContent(); @@ -221,8 +233,9 @@ public class PublisherController( [HttpPost("individual")] [Authorize] - [RequiredPermission("global", "publishers.create")] - public async Task> CreatePublisherIndividual([FromBody] PublisherRequest request) + [DysonNetwork.Shared.Permission.RequiredPermission("global", "publishers.create")] + public async Task> CreatePublisherIndividual( + [FromBody] PublisherRequest request) { if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized(); @@ -260,9 +273,12 @@ public class PublisherController( background ); - als.CreateActionLogFromRequest( + await als.CreateActionLogFromRequest( ActionLogType.PublisherCreate, - new Dictionary { { "publisher_id", publisher.Id } }, Request + new Dictionary { { "publisher_id", publisher.Id } }, + Request.HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString(), + currentUser ); return Ok(publisher); @@ -270,7 +286,7 @@ public class PublisherController( [HttpPost("organization/{realmSlug}")] [Authorize] - [RequiredPermission("global", "publishers.create")] + [DysonNetwork.Shared.Permission.RequiredPermission("global", "publishers.create")] public async Task> CreatePublisherOrganization(string realmSlug, [FromBody] PublisherRequest request) { @@ -315,9 +331,12 @@ public class PublisherController( background ); - als.CreateActionLogFromRequest( + await als.CreateActionLogFromRequest( ActionLogType.PublisherCreate, - new Dictionary { { "publisher_id", publisher.Id } }, Request + new Dictionary { { "publisher_id", publisher.Id } }, + Request.HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString(), + currentUser ); return Ok(publisher); @@ -393,9 +412,12 @@ public class PublisherController( db.Update(publisher); await db.SaveChangesAsync(); - als.CreateActionLogFromRequest( + await als.CreateActionLogFromRequest( ActionLogType.PublisherUpdate, - new Dictionary { { "publisher_id", publisher.Id } }, Request + new Dictionary { { "publisher_id", publisher.Id } }, + Request.HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString(), + currentUser ); return Ok(publisher); @@ -431,9 +453,12 @@ public class PublisherController( db.Publishers.Remove(publisher); await db.SaveChangesAsync(); - als.CreateActionLogFromRequest( + await als.CreateActionLogFromRequest( ActionLogType.PublisherDelete, - new Dictionary { { "publisher_id", publisher.Id } }, Request + new Dictionary { { "publisher_id", publisher.Id } }, + Request.HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString(), + currentUser ); return NoContent(); @@ -530,7 +555,7 @@ public class PublisherController( [HttpPost("{name}/features")] [Authorize] - [RequiredPermissionAttribute("maintenance", "publishers.features")] + [DysonNetwork.Shared.Permission.RequiredPermission("maintenance", "publishers.features")] public async Task> AddPublisherFeature(string name, [FromBody] PublisherFeatureRequest request) { @@ -554,7 +579,7 @@ public class PublisherController( [HttpDelete("{name}/features/{flag}")] [Authorize] - [RequiredPermissionAttribute("maintenance", "publishers.features")] + [RequiredPermission("maintenance", "publishers.features")] public async Task RemovePublisherFeature(string name, string flag) { var publisher = await db.Publishers diff --git a/DysonNetwork.Sphere/Realm/RealmController.cs b/DysonNetwork.Sphere/Realm/RealmController.cs index 9f4357a..f4550bd 100644 --- a/DysonNetwork.Sphere/Realm/RealmController.cs +++ b/DysonNetwork.Sphere/Realm/RealmController.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using DysonNetwork.Shared.Models; using DysonNetwork.Pass.Account; +using DysonNetwork.Shared.Services; using DysonNetwork.Sphere.Storage; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Authorization; @@ -15,9 +16,10 @@ public class RealmController( AppDatabase db, RealmService rs, FileReferenceService fileRefService, - RelationshipService rels, - ActionLogService als, - AccountEventService aes + IRelationshipService rels, + IActionLogService als, + IAccountEventService aes, + IAccountService accounts ) : Controller { [HttpGet("{slug}")] @@ -79,7 +81,7 @@ public class RealmController( if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized(); var userId = currentUser.Id; - var relatedUser = await db.Accounts.FindAsync(request.RelatedUserId); + var relatedUser = await accounts.GetAccountById(request.RelatedUserId); if (relatedUser is null) return BadRequest("Related user was not found"); if (await rels.HasRelationshipWithStatus(currentUser.Id, relatedUser.Id, RelationshipStatus.Blocked)) @@ -111,9 +113,12 @@ public class RealmController( db.RealmMembers.Add(member); await db.SaveChangesAsync(); - als.CreateActionLogFromRequest( + await als.CreateActionLogFromRequest( ActionLogType.RealmInvite, - new Dictionary { { "realm_id", realm.Id }, { "account_id", member.AccountId } }, Request + new Dictionary { { "realm_id", realm.Id }, { "account_id", member.AccountId } }, + Request.HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString(), + currentUser ); member.Account = relatedUser; @@ -141,10 +146,12 @@ public class RealmController( db.Update(member); await db.SaveChangesAsync(); - als.CreateActionLogFromRequest( + await als.CreateActionLogFromRequest( ActionLogType.RealmJoin, new Dictionary { { "realm_id", member.RealmId }, { "account_id", member.AccountId } }, - Request + Request.HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString(), + currentUser ); return Ok(member); @@ -167,10 +174,12 @@ public class RealmController( member.LeaveAt = SystemClock.Instance.GetCurrentInstant(); await db.SaveChangesAsync(); - als.CreateActionLogFromRequest( + await als.CreateActionLogFromRequest( ActionLogType.RealmLeave, new Dictionary { { "realm_id", member.RealmId }, { "account_id", member.AccountId } }, - Request + Request.HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString(), + currentUser ); return NoContent(); @@ -245,7 +254,6 @@ public class RealmController( } - [HttpGet("{slug}/members/me")] [Authorize] public async Task> GetCurrentIdentity(string slug) @@ -284,10 +292,12 @@ public class RealmController( member.LeaveAt = SystemClock.Instance.GetCurrentInstant(); await db.SaveChangesAsync(); - als.CreateActionLogFromRequest( + await als.CreateActionLogFromRequest( ActionLogType.RealmLeave, new Dictionary { { "realm_id", member.RealmId }, { "account_id", member.AccountId } }, - Request + Request.HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString(), + currentUser ); return NoContent(); @@ -349,9 +359,12 @@ public class RealmController( db.Realms.Add(realm); await db.SaveChangesAsync(); - als.CreateActionLogFromRequest( + await als.CreateActionLogFromRequest( ActionLogType.RealmCreate, - new Dictionary { { "realm_id", realm.Id } }, Request + new Dictionary { { "realm_id", realm.Id } }, + Request.HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString(), + currentUser ); var realmResourceId = $"realm:{realm.Id}"; @@ -455,9 +468,12 @@ public class RealmController( db.Realms.Update(realm); await db.SaveChangesAsync(); - als.CreateActionLogFromRequest( + await als.CreateActionLogFromRequest( ActionLogType.RealmUpdate, - new Dictionary { { "realm_id", realm.Id } }, Request + new Dictionary { { "realm_id", realm.Id } }, + Request.HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString(), + currentUser ); return Ok(realm); @@ -494,10 +510,12 @@ public class RealmController( db.RealmMembers.Add(member); await db.SaveChangesAsync(); - als.CreateActionLogFromRequest( + await als.CreateActionLogFromRequest( ActionLogType.RealmJoin, new Dictionary { { "realm_id", realm.Id }, { "account_id", currentUser.Id } }, - Request + Request.HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString(), + currentUser ); return Ok(member); @@ -525,10 +543,12 @@ public class RealmController( member.LeaveAt = SystemClock.Instance.GetCurrentInstant(); await db.SaveChangesAsync(); - als.CreateActionLogFromRequest( + await als.CreateActionLogFromRequest( ActionLogType.ChatroomKick, new Dictionary { { "realm_id", realm.Id }, { "account_id", memberId } }, - Request + Request.HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString(), + currentUser ); return NoContent(); @@ -559,11 +579,13 @@ public class RealmController( db.RealmMembers.Update(member); await db.SaveChangesAsync(); - als.CreateActionLogFromRequest( + await als.CreateActionLogFromRequest( ActionLogType.RealmAdjustRole, new Dictionary { { "realm_id", realm.Id }, { "account_id", memberId }, { "new_role", newRole } }, - Request + Request.HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString(), + currentUser ); return Ok(member); @@ -588,9 +610,12 @@ public class RealmController( db.Realms.Remove(realm); await db.SaveChangesAsync(); - als.CreateActionLogFromRequest( + await als.CreateActionLogFromRequest( ActionLogType.RealmDelete, - new Dictionary { { "realm_id", realm.Id } }, Request + new Dictionary { { "realm_id", realm.Id } }, + Request.HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString(), + currentUser ); // Delete all file references for this realm @@ -599,4 +624,4 @@ public class RealmController( return NoContent(); } -} +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Realm/RealmService.cs b/DysonNetwork.Sphere/Realm/RealmService.cs index 261e79b..13dae04 100644 --- a/DysonNetwork.Sphere/Realm/RealmService.cs +++ b/DysonNetwork.Sphere/Realm/RealmService.cs @@ -2,6 +2,7 @@ using DysonNetwork.Shared.Models; using DysonNetwork.Sphere.Localization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Localization; +using DysonNetwork.Shared.Localization; namespace DysonNetwork.Sphere.Realm; @@ -9,7 +10,7 @@ public class RealmService(AppDatabase db, DysonNetwork.Shared.Services.INoti { public async Task SendInviteNotify(RealmMember member) { - AccountService.SetCultureInfo(member.Account); + CultureInfoService.SetCultureInfo(member.Account); await nty.SendNotification( member.Account, "invites.realms", diff --git a/DysonNetwork.Sphere/Safety/SafetyService.cs b/DysonNetwork.Sphere/Safety/SafetyService.cs deleted file mode 100644 index 5a42a87..0000000 --- a/DysonNetwork.Sphere/Safety/SafetyService.cs +++ /dev/null @@ -1,105 +0,0 @@ -using DysonNetwork.Pass.Account; -using Microsoft.EntityFrameworkCore; -using NodaTime; - -namespace DysonNetwork.Sphere.Safety; - -public class SafetyService(AppDatabase db, ILogger logger) -{ - public async Task CreateReport(string resourceIdentifier, AbuseReportType type, string reason, Guid accountId) - { - // Check if a similar report already exists from this user - 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 CountReports(bool includeResolved = false) - { - return await db.AbuseReports - .Where(r => includeResolved || r.ResolvedAt == null) - .CountAsync(); - } - - public async Task CountUserReports(Guid accountId, bool includeResolved = false) - { - return await db.AbuseReports - .Where(r => r.AccountId == accountId) - .Where(r => includeResolved || r.ResolvedAt == null) - .CountAsync(); - } - - public async Task> GetReports(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> GetUserReports(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 GetReportById(Guid id) - { - return await db.AbuseReports - .Include(r => r.Account) - .FirstOrDefaultAsync(r => r.Id == id); - } - - public async Task ResolveReport(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 GetPendingReportsCount() - { - return await db.AbuseReports - .Where(r => r.ResolvedAt == null) - .CountAsync(); - } -} diff --git a/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs b/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs index 83d6814..5f62252 100644 --- a/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs +++ b/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs @@ -20,11 +20,11 @@ using NodaTime.Serialization.SystemTextJson; using StackExchange.Redis; using System.Text.Json; using System.Threading.RateLimiting; +using DysonNetwork.Pass.Safety; using DysonNetwork.Shared.Cache; using DysonNetwork.Sphere.Connection.WebReader; using DysonNetwork.Sphere.Developer; using DysonNetwork.Sphere.Discovery; -using DysonNetwork.Sphere.Safety; using DysonNetwork.Sphere.Wallet.PaymentHandlers; using tusdotnet.Stores; using DysonNetwork.Shared.Etcd; @@ -189,7 +189,6 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/DysonNetwork.Sphere/Sticker/StickerController.cs b/DysonNetwork.Sphere/Sticker/StickerController.cs index 4866d5f..89ac55e 100644 --- a/DysonNetwork.Sphere/Sticker/StickerController.cs +++ b/DysonNetwork.Sphere/Sticker/StickerController.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; using DysonNetwork.Shared.Models; -using DysonNetwork.Sphere.Permission; +using DysonNetwork.Shared.Permission; using DysonNetwork.Sphere.Post; using DysonNetwork.Sphere.Publisher; using DysonNetwork.Sphere.Storage; @@ -76,7 +76,7 @@ public class StickerController(AppDatabase db, StickerService st) : ControllerBa } [HttpPost] - [RequiredPermission("global", "stickers.packs.create")] + [DysonNetwork.Shared.Permission.RequiredPermission("global", "stickers.packs.create")] public async Task> CreateStickerPack([FromBody] StickerPackRequest request) { if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) return Unauthorized(); @@ -271,7 +271,7 @@ public class StickerController(AppDatabase db, StickerService st) : ControllerBa public const int MaxStickersPerPack = 24; [HttpPost("{packId:guid}/content")] - [RequiredPermissionAttribute("global", "stickers.create")] + [DysonNetwork.Shared.Permission.RequiredPermission("global", "stickers.create")] public async Task CreateSticker(Guid packId, [FromBody] StickerRequest request) { if (HttpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) diff --git a/DysonNetwork.Sphere/Storage/FileController.cs b/DysonNetwork.Sphere/Storage/FileController.cs index 6589dac..f540762 100644 --- a/DysonNetwork.Sphere/Storage/FileController.cs +++ b/DysonNetwork.Sphere/Storage/FileController.cs @@ -1,5 +1,5 @@ using DysonNetwork.Shared.Models; -using DysonNetwork.Sphere.Permission; +using DysonNetwork.Shared.Permission; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -13,8 +13,7 @@ public class FileController( AppDatabase db, FileService fs, IConfiguration configuration, - IWebHostEnvironment env, - FileReferenceMigrationService rms + IWebHostEnvironment env ) : ControllerBase { [HttpGet("{id}")] @@ -108,13 +107,4 @@ public class FileController( return NoContent(); } - - [HttpPost("/maintenance/migrateReferences")] - [Authorize] - [RequiredPermissionAttribute("maintenance", "files.references")] - public async Task MigrateFileReferences() - { - await rms.ScanAndMigrateReferences(); - return Ok(); - } } \ No newline at end of file diff --git a/DysonNetwork.Sphere/Storage/FileService.ReferenceMigration.cs b/DysonNetwork.Sphere/Storage/FileService.ReferenceMigration.cs deleted file mode 100644 index ce7adf9..0000000 --- a/DysonNetwork.Sphere/Storage/FileService.ReferenceMigration.cs +++ /dev/null @@ -1,340 +0,0 @@ -using DysonNetwork.Shared.Models; -using EFCore.BulkExtensions; -using Microsoft.EntityFrameworkCore; -using NodaTime; - -namespace DysonNetwork.Sphere.Storage; - -public class FileReferenceMigrationService(AppDatabase db) -{ - public async Task ScanAndMigrateReferences() - { - // Scan Posts for file references - await ScanPosts(); - - // Scan Messages for file references - await ScanMessages(); - - // Scan Profiles for file references - await ScanProfiles(); - - // Scan Chat entities for file references - await ScanChatRooms(); - - // Scan Realms for file references - await ScanRealms(); - - // Scan Publishers for file references - await ScanPublishers(); - - // Scan Stickers for file references - await ScanStickers(); - } - - private async Task ScanPosts() - { - var posts = await db.Posts - .Include(p => p.OutdatedAttachments) - .Where(p => p.OutdatedAttachments.Any()) - .ToListAsync(); - - foreach (var post in posts) - { - var updatedAttachments = new List(); - - foreach (var attachment in post.OutdatedAttachments) - { - var file = await db.Files.FirstOrDefaultAsync(f => f.Id == attachment.Id); - if (file != null) - { - // Create a reference for the file - var reference = new CloudFileReference - { - FileId = file.Id, - File = file, - Usage = "post", - ResourceId = post.ResourceIdentifier - }; - - await db.FileReferences.AddAsync(reference); - updatedAttachments.Add(file.ToReferenceObject()); - } - else - { - // Keep the existing reference object if file not found - updatedAttachments.Add(attachment.ToReferenceObject()); - } - } - - post.Attachments = updatedAttachments; - db.Posts.Update(post); - } - - await db.SaveChangesAsync(); - } - - private async Task ScanMessages() - { - var messages = await db.ChatMessages - .Include(m => m.OutdatedAttachments) - .Where(m => m.OutdatedAttachments.Any()) - .ToListAsync(); - - var fileReferences = messages.SelectMany(message => message.OutdatedAttachments.Select(attachment => - new CloudFileReference - { - FileId = attachment.Id, - File = attachment, - Usage = "chat", - ResourceId = message.ResourceIdentifier, - CreatedAt = SystemClock.Instance.GetCurrentInstant(), - UpdatedAt = SystemClock.Instance.GetCurrentInstant() - }) - ).ToList(); - - foreach (var message in messages) - { - message.Attachments = message.OutdatedAttachments.Select(a => a.ToReferenceObject()).ToList(); - db.ChatMessages.Update(message); - } - - await db.BulkInsertAsync(fileReferences); - await db.SaveChangesAsync(); - } - - private async Task ScanProfiles() - { - var profiles = await db.AccountProfiles - .Where(p => p.PictureId != null || p.BackgroundId != null) - .ToListAsync(); - - foreach (var profile in profiles) - { - if (profile is { PictureId: not null, Picture: null }) - { - var avatarFile = await db.Files.FirstOrDefaultAsync(f => f.Id == profile.PictureId); - if (avatarFile != null) - { - // Create a reference for the avatar file - var reference = new CloudFileReference - { - FileId = avatarFile.Id, - File = avatarFile, - Usage = "profile.picture", - ResourceId = profile.Id.ToString() - }; - - await db.FileReferences.AddAsync(reference); - profile.Picture = avatarFile.ToReferenceObject(); - db.AccountProfiles.Update(profile); - } - } - - // Also check for the banner if it exists - if (profile is not { BackgroundId: not null, Background: null }) continue; - var bannerFile = await db.Files.FirstOrDefaultAsync(f => f.Id == profile.BackgroundId); - if (bannerFile == null) continue; - { - // Create a reference for the banner file - var reference = new CloudFileReference - { - FileId = bannerFile.Id, - File = bannerFile, - Usage = "profile.background", - ResourceId = profile.Id.ToString() - }; - - await db.FileReferences.AddAsync(reference); - profile.Background = bannerFile.ToReferenceObject(); - db.AccountProfiles.Update(profile); - } - } - - await db.SaveChangesAsync(); - } - - private async Task ScanChatRooms() - { - var chatRooms = await db.ChatRooms - .Where(c => c.PictureId != null || c.BackgroundId != null) - .ToListAsync(); - - foreach (var chatRoom in chatRooms) - { - if (chatRoom is { PictureId: not null, Picture: null }) - { - var avatarFile = await db.Files.FirstOrDefaultAsync(f => f.Id == chatRoom.PictureId); - if (avatarFile != null) - { - // Create a reference for the avatar file - var reference = new CloudFileReference - { - FileId = avatarFile.Id, - File = avatarFile, - Usage = "chatroom.picture", - ResourceId = chatRoom.ResourceIdentifier - }; - - await db.FileReferences.AddAsync(reference); - chatRoom.Picture = avatarFile.ToReferenceObject(); - db.ChatRooms.Update(chatRoom); - } - } - - if (chatRoom is not { BackgroundId: not null, Background: null }) continue; - var bannerFile = await db.Files.FirstOrDefaultAsync(f => f.Id == chatRoom.BackgroundId); - if (bannerFile == null) continue; - { - // Create a reference for the banner file - var reference = new CloudFileReference - { - FileId = bannerFile.Id, - File = bannerFile, - Usage = "chatroom.background", - ResourceId = chatRoom.ResourceIdentifier - }; - - await db.FileReferences.AddAsync(reference); - chatRoom.Background = bannerFile.ToReferenceObject(); - db.ChatRooms.Update(chatRoom); - } - } - - await db.SaveChangesAsync(); - } - - private async Task ScanRealms() - { - var realms = await db.Realms - .Where(r => r.PictureId != null && r.BackgroundId != null) - .ToListAsync(); - - foreach (var realm in realms) - { - // Process avatar if it exists - if (realm is { PictureId: not null, Picture: null }) - { - var avatarFile = await db.Files.FirstOrDefaultAsync(f => f.Id == realm.PictureId); - if (avatarFile != null) - { - // Create a reference for the avatar file - var reference = new CloudFileReference - { - FileId = avatarFile.Id, - File = avatarFile, - Usage = "realm.picture", - ResourceId = realm.ResourceIdentifier - }; - - await db.FileReferences.AddAsync(reference); - realm.Picture = avatarFile.ToReferenceObject(); - } - } - - // Process banner if it exists - if (realm is { BackgroundId: not null, Background: null }) - { - var bannerFile = await db.Files.FirstOrDefaultAsync(f => f.Id == realm.BackgroundId); - if (bannerFile != null) - { - // Create a reference for the banner file - var reference = new CloudFileReference - { - FileId = bannerFile.Id, - File = bannerFile, - Usage = "realm.background", - ResourceId = realm.ResourceIdentifier - }; - - await db.FileReferences.AddAsync(reference); - realm.Background = bannerFile.ToReferenceObject(); - } - } - - db.Realms.Update(realm); - } - - await db.SaveChangesAsync(); - } - - private async Task ScanPublishers() - { - var publishers = await db.Publishers - .Where(p => p.PictureId != null || p.BackgroundId != null) - .ToListAsync(); - - foreach (var publisher in publishers) - { - if (publisher is { PictureId: not null, Picture: null }) - { - var pictureFile = await db.Files.FirstOrDefaultAsync(f => f.Id == publisher.PictureId); - if (pictureFile != null) - { - // Create a reference for the picture file - var reference = new CloudFileReference - { - FileId = pictureFile.Id, - File = pictureFile, - Usage = "publisher.picture", - ResourceId = publisher.Id.ToString() - }; - - await db.FileReferences.AddAsync(reference); - publisher.Picture = pictureFile.ToReferenceObject(); - } - } - - if (publisher is { BackgroundId: not null, Background: null }) - { - var backgroundFile = await db.Files.FirstOrDefaultAsync(f => f.Id == publisher.BackgroundId); - if (backgroundFile != null) - { - // Create a reference for the background file - var reference = new CloudFileReference - { - FileId = backgroundFile.Id, - File = backgroundFile, - Usage = "publisher.background", - ResourceId = publisher.ResourceIdentifier - }; - - await db.FileReferences.AddAsync(reference); - publisher.Background = backgroundFile.ToReferenceObject(); - } - } - - db.Publishers.Update(publisher); - } - - await db.SaveChangesAsync(); - } - - private async Task ScanStickers() - { - var stickers = await db.Stickers - .Where(s => s.ImageId != null && s.Image == null) - .ToListAsync(); - - foreach (var sticker in stickers) - { - var imageFile = await db.Files.FirstOrDefaultAsync(f => f.Id == sticker.ImageId); - if (imageFile != null) - { - // Create a reference for the sticker image file - var reference = new CloudFileReference - { - FileId = imageFile.Id, - File = imageFile, - Usage = "sticker.image", - ResourceId = sticker.ResourceIdentifier - }; - - await db.FileReferences.AddAsync(reference); - sticker.Image = imageFile.ToReferenceObject(); - db.Stickers.Update(sticker); - } - } - - await db.SaveChangesAsync(); - } -} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Storage/Handlers/LastActiveFlushHandler.cs b/DysonNetwork.Sphere/Storage/Handlers/LastActiveFlushHandler.cs index 08b6720..1cd797d 100644 --- a/DysonNetwork.Sphere/Storage/Handlers/LastActiveFlushHandler.cs +++ b/DysonNetwork.Sphere/Storage/Handlers/LastActiveFlushHandler.cs @@ -1,24 +1,21 @@ using DysonNetwork.Shared.Models; -using Microsoft.EntityFrameworkCore; using NodaTime; using Quartz; +using DysonNetwork.Shared.Services; namespace DysonNetwork.Sphere.Storage.Handlers; public class LastActiveInfo { public Session Session { get; set; } = null!; - public Shared.Models.Account Account { get; set; } = null!; + public Account Account { get; set; } = null!; public Instant SeenAt { get; set; } } -public class LastActiveFlushHandler(IServiceProvider serviceProvider) : IFlushHandler +public class LastActiveFlushHandler(DysonNetwork.Shared.Services.IAccountService accounts, DysonNetwork.Shared.Services.IAccountProfileService profiles) : IFlushHandler { public async Task FlushAsync(IReadOnlyList items) { - using var scope = serviceProvider.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - // Remove duplicates by grouping on (sessionId, accountId), taking the most recent SeenAt var distinctItems = items .GroupBy(x => (SessionId: x.Session.Id, AccountId: x.Account.Id)) @@ -36,19 +33,11 @@ public class LastActiveFlushHandler(IServiceProvider serviceProvider) : IFlushHa // Update sessions using native EF Core ExecuteUpdateAsync foreach (var kvp in sessionIdMap) - { - await db.AuthSessions - .Where(s => s.Id == kvp.Key) - .ExecuteUpdateAsync(s => s.SetProperty(x => x.LastGrantedAt, kvp.Value)); - } + await accounts.UpdateSessionLastGrantedAt(kvp.Key, kvp.Value); // Update account profiles using native EF Core ExecuteUpdateAsync foreach (var kvp in accountIdMap) - { - await db.AccountProfiles - .Where(a => a.AccountId == kvp.Key) - .ExecuteUpdateAsync(a => a.SetProperty(x => x.LastSeenAt, kvp.Value)); - } + await accounts.UpdateAccountProfileLastSeenAt(kvp.Key, kvp.Value); } } diff --git a/DysonNetwork.Sphere/Wallet/PaymentService.cs b/DysonNetwork.Sphere/Wallet/PaymentService.cs index fe85e9a..5dab6dc 100644 --- a/DysonNetwork.Sphere/Wallet/PaymentService.cs +++ b/DysonNetwork.Sphere/Wallet/PaymentService.cs @@ -1,5 +1,6 @@ using System.Globalization; using DysonNetwork.Shared.Models; +using DysonNetwork.Shared.Services; using DysonNetwork.Sphere.Localization; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; @@ -11,7 +12,8 @@ namespace DysonNetwork.Sphere.Wallet; public class PaymentService( AppDatabase db, WalletService wat, - DysonNetwork.Shared.Services.INotificationService nty, + INotificationService nty, + IAccountService acc, IStringLocalizer localizer ) { @@ -196,10 +198,10 @@ public class PaymentService( private async Task NotifyOrderPaid(Order order) { if (order.PayeeWallet is null) return; - var account = await db.Accounts.FirstOrDefaultAsync(a => a.Id == order.PayeeWallet.AccountId); + var account = await acc.GetAccountById(order.PayeeWallet.AccountId); if (account is null) return; - AccountService.SetCultureInfo(account); + // AccountService.SetCultureInfo(account); // Due to ID is uuid, it longer than 8 words for sure var readableOrderId = order.Id.ToString().Replace("-", "")[..8]; diff --git a/DysonNetwork.Sphere/Wallet/SubscriptionRenewalJob.cs b/DysonNetwork.Sphere/Wallet/SubscriptionRenewalJob.cs index 07090e6..8132803 100644 --- a/DysonNetwork.Sphere/Wallet/SubscriptionRenewalJob.cs +++ b/DysonNetwork.Sphere/Wallet/SubscriptionRenewalJob.cs @@ -5,11 +5,14 @@ using Quartz; namespace DysonNetwork.Sphere.Wallet; +using DysonNetwork.Shared.Services; + public class SubscriptionRenewalJob( AppDatabase db, SubscriptionService subscriptionService, PaymentService paymentService, WalletService walletService, + IAccountProfileService accountProfileService, ILogger logger ) : IJob { @@ -138,10 +141,7 @@ public class SubscriptionRenewalJob( logger.LogInformation("Validating user stellar memberships..."); // Get all account IDs with StellarMembership - var accountsWithMemberships = await db.AccountProfiles - .Where(a => a.StellarMembership != null) - .Select(a => new { a.Id, a.StellarMembership }) - .ToListAsync(); + var accountsWithMemberships = await accountProfileService.GetAccountsWithStellarMembershipAsync(); logger.LogInformation("Found {Count} accounts with stellar memberships to validate", accountsWithMemberships.Count); @@ -187,11 +187,7 @@ public class SubscriptionRenewalJob( } // Update all accounts in a single batch operation - var updatedCount = await db.AccountProfiles - .Where(a => accountIdsToUpdate.Contains(a.Id)) - .ExecuteUpdateAsync(s => s - .SetProperty(a => a.StellarMembership, p => null) - ); + var updatedCount = await accountProfileService.ClearStellarMembershipsAsync(accountIdsToUpdate); logger.LogInformation("Updated {Count} accounts with expired/invalid stellar memberships", updatedCount); } diff --git a/DysonNetwork.Sphere/Wallet/SubscriptionService.cs b/DysonNetwork.Sphere/Wallet/SubscriptionService.cs index 628d840..5040afa 100644 --- a/DysonNetwork.Sphere/Wallet/SubscriptionService.cs +++ b/DysonNetwork.Sphere/Wallet/SubscriptionService.cs @@ -1,5 +1,6 @@ using System.Text.Json; using DysonNetwork.Shared.Cache; +using DysonNetwork.Shared.Localization; using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Services; using DysonNetwork.Sphere.Localization; @@ -14,8 +15,9 @@ namespace DysonNetwork.Sphere.Wallet; public class SubscriptionService( AppDatabase db, PaymentService payment, - DysonNetwork.Shared.Services.IAccountService account, - DysonNetwork.Shared.Services.INotificationService nty, + IAccountService accounts, + IAccountProfileService profiles, + INotificationService nty, IStringLocalizer localizer, IConfiguration configuration, ICacheService cache, @@ -23,7 +25,7 @@ public class SubscriptionService( ) { public async Task CreateSubscriptionAsync( - Shared.Models.Account account, + Account account, string identifier, string paymentMethod, PaymentDetails paymentDetails, @@ -57,9 +59,7 @@ public class SubscriptionService( if (subscriptionInfo.RequiredLevel > 0) { - var profile = await db.AccountProfiles - .Where(p => p.AccountId == account.Id) - .FirstOrDefaultAsync(); + var profile = await profiles.GetAccountProfileByIdAsync(account.Id); if (profile is null) throw new InvalidOperationException("Account profile was not found."); if (profile.Level < subscriptionInfo.RequiredLevel) throw new InvalidOperationException( @@ -141,7 +141,7 @@ public class SubscriptionService( if (!string.IsNullOrEmpty(provider)) account = await accounts.LookupAccountByConnection(order.AccountId, provider); else if (Guid.TryParse(order.AccountId, out var accountId)) - account = await db.Accounts.FirstOrDefaultAsync(a => a.Id == accountId); + account = await accounts.GetAccountById(accountId); if (account is null) throw new InvalidOperationException($"Account was not found with identifier {order.AccountId}"); @@ -302,9 +302,7 @@ public class SubscriptionService( if (subscription.Identifier.StartsWith(SubscriptionType.StellarProgram)) { - await db.AccountProfiles - .Where(a => a.AccountId == subscription.AccountId) - .ExecuteUpdateAsync(s => s.SetProperty(a => a.StellarMembership, subscription.ToReference())); + await profiles.UpdateStellarMembershipAsync(subscription.AccountId, subscription.ToReference()); } await NotifySubscriptionBegun(subscription); @@ -348,10 +346,10 @@ public class SubscriptionService( private async Task NotifySubscriptionBegun(Subscription subscription) { - var account = await db.Accounts.FirstOrDefaultAsync(a => a.Id == subscription.AccountId); + var account = await accounts.GetAccountById(subscription.AccountId); if (account is null) return; - AccountService.SetCultureInfo(account); + CultureInfoService.SetCultureInfo(account); var humanReadableName = SubscriptionTypeData.SubscriptionHumanReadable.TryGetValue(subscription.Identifier, out var humanReadable) diff --git a/DysonNetwork.Sphere/Wallet/WalletController.cs b/DysonNetwork.Sphere/Wallet/WalletController.cs index 6af4ee6..0474472 100644 --- a/DysonNetwork.Sphere/Wallet/WalletController.cs +++ b/DysonNetwork.Sphere/Wallet/WalletController.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; using DysonNetwork.Shared.Models; -using DysonNetwork.Sphere.Permission; +using DysonNetwork.Shared.Permission; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -75,7 +75,7 @@ public class WalletController(AppDatabase db, WalletService ws, PaymentService p [HttpPost("balance")] [Authorize] - [RequiredPermissionAttribute("maintenance", "wallets.balance.modify")] + [DysonNetwork.Shared.Permission.RequiredPermission("maintenance", "wallets.balance.modify")] public async Task> ModifyWalletBalance([FromBody] WalletBalanceRequest request) { var wallet = await ws.GetWalletAsync(request.AccountId);