From 8d2f4a4c4714b8df6d9bf49700ab591161613528 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Mon, 7 Jul 2025 21:54:51 +0800 Subject: [PATCH] :recycle: Moving to MagicOnion --- .../Account/AccountEventService.cs | 98 +++++-- .../Account/AccountGrpcService.cs | 61 ----- DysonNetwork.Pass/Account/AccountService.cs | 6 +- .../Account/AccountUsernameService.cs | 4 +- DysonNetwork.Pass/Account/ActionLogService.cs | 19 +- .../Account/MagicSpellService.cs | 26 +- .../Account/NotificationService.cs | 244 +++++++++--------- .../Account/RelationshipService.cs | 32 ++- DysonNetwork.Pass/Auth/AuthGrpcService.cs | 96 ------- .../Auth/OpenId/AfdianOidcService.cs | 2 +- .../Auth/OpenId/AppleMobileSignInRequest.cs | 4 +- .../Auth/OpenId/AppleOidcService.cs | 2 +- .../Auth/OpenId/ConnectionController.cs | 1 - .../Auth/OpenId/DiscordOidcService.cs | 2 +- .../Auth/OpenId/GitHubOidcService.cs | 2 +- .../Auth/OpenId/GoogleOidcService.cs | 2 +- .../Auth/OpenId/MicrosoftOidcService.cs | 2 +- .../Auth/OpenId/OidcController.cs | 1 - DysonNetwork.Pass/Auth/OpenId/OidcService.cs | 1 - DysonNetwork.Pass/Auth/OpenId/OidcState.cs | 2 +- DysonNetwork.Pass/DysonNetwork.Pass.csproj | 13 +- DysonNetwork.Pass/Program.cs | 16 +- .../Startup/ServiceCollectionExtensions.cs | 186 +++++++++++++ .../DysonNetwork.Shared.csproj | 16 +- .../Models}/MagicSpell.cs | 2 +- .../Models}/OidcUserInfo.cs | 2 +- DysonNetwork.Shared/Protos/account.proto | 51 ---- DysonNetwork.Shared/Protos/auth.proto | 69 ----- .../Services/IAccountEventService.cs | 28 ++ .../Services/IAccountService.cs | 62 +++++ .../Services/IAccountUsernameService.cs | 23 ++ .../Services/IActionLogService.cs | 24 ++ .../Services/IMagicSpellService.cs | 30 +++ .../Services/INotificationService.cs | 23 ++ .../Services/IRelationshipService.cs | 27 ++ .../Startup/ApplicationConfiguration.cs | 60 +++++ .../Startup/KestrelConfiguration.cs | 21 ++ .../DysonNetwork.Sphere.csproj | 11 +- .../Startup/ApplicationConfiguration.cs | 2 - .../Startup/ServiceCollectionExtensions.cs | 46 +--- DysonNetwork.sln.DotSettings.user | 1 + 41 files changed, 790 insertions(+), 530 deletions(-) delete mode 100644 DysonNetwork.Pass/Account/AccountGrpcService.cs delete mode 100644 DysonNetwork.Pass/Auth/AuthGrpcService.cs create mode 100644 DysonNetwork.Pass/Startup/ServiceCollectionExtensions.cs rename {DysonNetwork.Pass/Account => DysonNetwork.Shared/Models}/MagicSpell.cs (95%) rename {DysonNetwork.Pass/Auth/OpenId => DysonNetwork.Shared/Models}/OidcUserInfo.cs (97%) delete mode 100644 DysonNetwork.Shared/Protos/account.proto delete mode 100644 DysonNetwork.Shared/Protos/auth.proto create mode 100644 DysonNetwork.Shared/Services/IAccountEventService.cs create mode 100644 DysonNetwork.Shared/Services/IAccountService.cs create mode 100644 DysonNetwork.Shared/Services/IAccountUsernameService.cs create mode 100644 DysonNetwork.Shared/Services/IActionLogService.cs create mode 100644 DysonNetwork.Shared/Services/IMagicSpellService.cs create mode 100644 DysonNetwork.Shared/Services/INotificationService.cs create mode 100644 DysonNetwork.Shared/Services/IRelationshipService.cs create mode 100644 DysonNetwork.Shared/Startup/ApplicationConfiguration.cs create mode 100644 DysonNetwork.Shared/Startup/KestrelConfiguration.cs diff --git a/DysonNetwork.Pass/Account/AccountEventService.cs b/DysonNetwork.Pass/Account/AccountEventService.cs index 5ea0995..d6f4e63 100644 --- a/DysonNetwork.Pass/Account/AccountEventService.cs +++ b/DysonNetwork.Pass/Account/AccountEventService.cs @@ -1,7 +1,9 @@ using System.Globalization; +using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Models; +using DysonNetwork.Shared.Services; +using MagicOnion.Server; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Localization; using NodaTime; @@ -9,11 +11,9 @@ namespace DysonNetwork.Pass.Account; public class AccountEventService( AppDatabase db, - // WebSocketService ws, - // ICacheService cache, - // PaymentService payment, + ICacheService cache, IStringLocalizer localizer -) +) : ServiceBase, IAccountEventService { private static readonly Random Random = new(); private const string StatusCacheKey = "AccountStatus_"; @@ -21,18 +21,18 @@ public class AccountEventService( public void PurgeStatusCache(Guid userId) { var cacheKey = $"{StatusCacheKey}{userId}"; - // cache.RemoveAsync(cacheKey); + cache.RemoveAsync(cacheKey); } public async Task GetStatus(Guid userId) { var cacheKey = $"{StatusCacheKey}{userId}"; - // var cachedStatus = await cache.GetAsync(cacheKey); - // if (cachedStatus is not null) - // { - // cachedStatus!.IsOnline = !cachedStatus.IsInvisible && ws.GetAccountIsConnected(userId); - // return cachedStatus; - // } + var cachedStatus = await cache.GetAsync(cacheKey); + if (cachedStatus is not null) + { + cachedStatus!.IsOnline = !cachedStatus.IsInvisible /*&& ws.GetAccountIsConnected(userId)*/; + return cachedStatus; + } var now = SystemClock.Instance.GetCurrentInstant(); var status = await db.AccountStatuses @@ -45,8 +45,12 @@ public class AccountEventService( if (status is not null) { status.IsOnline = !status.IsInvisible && isOnline; - // await cache.SetWithGroupsAsync(cacheKey, status, [$"{AccountService.AccountCachePrefix}{status.AccountId}"], - // TimeSpan.FromMinutes(5)); + await cache.SetWithGroupsAsync( + cacheKey, + status, + [$"{AccountService.AccountCachePrefix}{status.AccountId}"], + TimeSpan.FromMinutes(5) + ); return status; } @@ -62,7 +66,7 @@ public class AccountEventService( }; } - return new Status + return new Status { Attitude = StatusAttitude.Neutral, IsOnline = false, @@ -88,7 +92,7 @@ public class AccountEventService( // } // else // { - cacheMissUserIds.Add(userId); + cacheMissUserIds.Add(userId); // } } @@ -192,27 +196,28 @@ public class AccountEventService( return lastDate < currentDate; } - public const string CheckInLockKey = "CheckInLock_"; + private const string CheckInLockKey = "checkin-lock:"; public async Task CheckInDaily(Shared.Models.Account user) { var lockKey = $"{CheckInLockKey}{user.Id}"; - + try { - // var lk = await cache.AcquireLockAsync(lockKey, TimeSpan.FromMinutes(1), TimeSpan.FromMilliseconds(100)); - - // if (lk != null) - // await lk.ReleaseAsync(); + var lk = await cache.AcquireLockAsync(lockKey, TimeSpan.FromMinutes(1), TimeSpan.FromMilliseconds(100)); + + if (lk != null) + await lk.ReleaseAsync(); } catch { // Ignore errors from this pre-check } - + // Now try to acquire the lock properly - // await using var lockObj = await cache.AcquireLockAsync(lockKey, TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(5)); - // if (lockObj is null) throw new InvalidOperationException("Check-in was in progress."); + await using var lockObj = + await cache.AcquireLockAsync(lockKey, TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(5)); + if (lockObj is null) throw new InvalidOperationException("Check-in was in progress."); var cultureInfo = new CultureInfo(user.Language, false); CultureInfo.CurrentCulture = cultureInfo; @@ -274,12 +279,53 @@ public class AccountEventService( s.SetProperty(b => b.Experience, b => b.Experience + result.RewardExperience) ); db.AccountCheckInResults.Add(result); - await db.SaveChangesAsync(); // Don't forget to save changes to the database + await db.SaveChangesAsync(); // Remember to save changes to the database // The lock will be automatically released by the await using statement return result; } + public async Task GetCheckInStreak(Shared.Models.Account user) + { + var today = SystemClock.Instance.GetCurrentInstant().InUtc().Date; + var yesterdayEnd = today.PlusDays(-1).AtMidnight().InUtc().ToInstant(); + var yesterdayStart = today.PlusDays(-1).AtStartOfDayInZone(DateTimeZone.Utc).ToInstant(); + var tomorrowEnd = today.PlusDays(1).AtMidnight().InUtc().ToInstant(); + var tomorrowStart = today.PlusDays(1).AtStartOfDayInZone(DateTimeZone.Utc).ToInstant(); + + var yesterdayResult = await db.AccountCheckInResults + .Where(x => x.AccountId == user.Id) + .Where(x => x.CreatedAt >= yesterdayStart) + .Where(x => x.CreatedAt < yesterdayEnd) + .FirstOrDefaultAsync(); + + var tomorrowResult = await db.AccountCheckInResults + .Where(x => x.AccountId == user.Id) + .Where(x => x.CreatedAt >= tomorrowStart) + .Where(x => x.CreatedAt < tomorrowEnd) + .FirstOrDefaultAsync(); + + if (yesterdayResult is null && tomorrowResult is null) + return 1; + + var results = await db.AccountCheckInResults + .Where(x => x.AccountId == user.Id) + .OrderByDescending(x => x.CreatedAt) + .ToListAsync(); + + var streak = 0; + var day = today; + while (results.Any(x => + x.CreatedAt >= day.AtStartOfDayInZone(DateTimeZone.Utc).ToInstant() && + x.CreatedAt < day.AtMidnight().InUtc().ToInstant())) + { + streak++; + day = day.PlusDays(-1); + } + + return streak; + } + public async Task> GetEventCalendar(Shared.Models.Account user, int month, int year = 0, bool replaceInvisible = false) { diff --git a/DysonNetwork.Pass/Account/AccountGrpcService.cs b/DysonNetwork.Pass/Account/AccountGrpcService.cs deleted file mode 100644 index 2f53a6b..0000000 --- a/DysonNetwork.Pass/Account/AccountGrpcService.cs +++ /dev/null @@ -1,61 +0,0 @@ -using DysonNetwork.Shared.Models; -using DysonNetwork.Shared.Protos.Account; -using Grpc.Core; -using Google.Protobuf.WellKnownTypes; -using Microsoft.EntityFrameworkCore; -using DysonNetwork.Pass.Auth; - -namespace DysonNetwork.Pass.Account; - -public class AccountGrpcService(AppDatabase db, AuthService auth) - : DysonNetwork.Shared.Protos.Account.AccountService.AccountServiceBase -{ - public override async Task GetAccount(Empty request, ServerCallContext context) - { - var account = await GetAccountFromContext(context); - return ToAccountResponse(account); - } - - public override async Task UpdateAccount(UpdateAccountRequest request, ServerCallContext context) - { - var account = await GetAccountFromContext(context); - - // TODO: implement - - await db.SaveChangesAsync(); - - return ToAccountResponse(account); - } - - private async Task GetAccountFromContext(ServerCallContext context) - { - var authorizationHeader = context.RequestHeaders.FirstOrDefault(h => h.Key == "authorization"); - if (authorizationHeader == null) - { - throw new RpcException(new Grpc.Core.Status(StatusCode.Unauthenticated, "Missing authorization header.")); - } - - var token = authorizationHeader.Value.Replace("Bearer ", ""); - if (!auth.ValidateToken(token, out var sessionId)) - { - throw new RpcException(new Grpc.Core.Status(StatusCode.Unauthenticated, "Invalid token.")); - } - - var session = await db.AuthSessions.Include(s => s.Account).ThenInclude(a => a.Contacts) - .FirstOrDefaultAsync(s => s.Id == sessionId); - if (session == null) - { - throw new RpcException(new Grpc.Core.Status(StatusCode.Unauthenticated, "Session not found.")); - } - - return session.Account; - } - - private AccountResponse ToAccountResponse(Shared.Models.Account account) - { - // TODO: implement - return new AccountResponse - { - }; - } -} \ No newline at end of file diff --git a/DysonNetwork.Pass/Account/AccountService.cs b/DysonNetwork.Pass/Account/AccountService.cs index 8da9b4f..4bc367d 100644 --- a/DysonNetwork.Pass/Account/AccountService.cs +++ b/DysonNetwork.Pass/Account/AccountService.cs @@ -1,14 +1,16 @@ using System.Globalization; using DysonNetwork.Pass.Auth; +using DysonNetwork.Pass.Auth.OpenId; using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Models; -using DysonNetwork.Sphere.Auth.OpenId; +using DysonNetwork.Shared.Services; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Localization; using NodaTime; using OtpNet; using Microsoft.Extensions.Logging; using EFCore.BulkExtensions; +using MagicOnion.Server; namespace DysonNetwork.Pass.Account; @@ -21,7 +23,7 @@ public class AccountService( // IStringLocalizer localizer, ICacheService cache, ILogger logger -) +) : ServiceBase, IAccountService { public static void SetCultureInfo(Shared.Models.Account account) { diff --git a/DysonNetwork.Pass/Account/AccountUsernameService.cs b/DysonNetwork.Pass/Account/AccountUsernameService.cs index 25ffcaa..e464d67 100644 --- a/DysonNetwork.Pass/Account/AccountUsernameService.cs +++ b/DysonNetwork.Pass/Account/AccountUsernameService.cs @@ -1,4 +1,6 @@ using System.Text.RegularExpressions; +using DysonNetwork.Shared.Services; +using MagicOnion.Server; using Microsoft.EntityFrameworkCore; namespace DysonNetwork.Pass.Account; @@ -6,7 +8,7 @@ namespace DysonNetwork.Pass.Account; /// /// Service for handling username generation and validation /// -public class AccountUsernameService(AppDatabase db) +public class AccountUsernameService(AppDatabase db) : ServiceBase, IAccountUsernameService { private readonly Random _random = new(); diff --git a/DysonNetwork.Pass/Account/ActionLogService.cs b/DysonNetwork.Pass/Account/ActionLogService.cs index 2021797..6702ab6 100644 --- a/DysonNetwork.Pass/Account/ActionLogService.cs +++ b/DysonNetwork.Pass/Account/ActionLogService.cs @@ -1,13 +1,24 @@ using DysonNetwork.Shared.Models; +using DysonNetwork.Shared.Services; +using MagicOnion.Server; using Microsoft.AspNetCore.Http; namespace DysonNetwork.Pass.Account; -public class ActionLogService( - // GeoIpService geo, - // FlushBufferService fbs -) +public class ActionLogService : ServiceBase, IActionLogService { + // private readonly GeoIpService _geo; + // private readonly FlushBufferService _fbs; + + public ActionLogService( + // GeoIpService geo, + // FlushBufferService fbs + ) + { + // _geo = geo; + // _fbs = fbs; + } + public void CreateActionLog(Guid accountId, string action, Dictionary meta) { var log = new ActionLog diff --git a/DysonNetwork.Pass/Account/MagicSpellService.cs b/DysonNetwork.Pass/Account/MagicSpellService.cs index d9e47ba..1e6d6ea 100644 --- a/DysonNetwork.Pass/Account/MagicSpellService.cs +++ b/DysonNetwork.Pass/Account/MagicSpellService.cs @@ -2,6 +2,8 @@ using System.Globalization; using System.Security.Cryptography; using System.Text.Json; using DysonNetwork.Shared.Models; +using DysonNetwork.Shared.Services; +using MagicOnion.Server; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Localization; using NodaTime; @@ -12,11 +14,9 @@ namespace DysonNetwork.Pass.Account; public class MagicSpellService( AppDatabase db, - // EmailService email, IConfiguration configuration, ILogger logger - // IStringLocalizer localizer -) +) : ServiceBase, IMagicSpellService { public async Task CreateMagicSpell( Shared.Models.Account account, @@ -59,6 +59,17 @@ public class MagicSpellService( return spell; } + public async Task GetMagicSpellAsync(string token) + { + var now = SystemClock.Instance.GetCurrentInstant(); + var spell = await db.MagicSpells + .Where(s => s.Spell == token) + .Where(s => s.ExpiresAt == null || s.ExpiresAt > now) + .FirstOrDefaultAsync(); + + return spell; + } + public async Task NotifyMagicSpell(MagicSpell spell, bool bypassVerify = false) { var contact = await db.AccountContacts @@ -144,8 +155,15 @@ public class MagicSpellService( } } - public async Task ApplyMagicSpell(MagicSpell spell) + public async Task ApplyMagicSpell(string token) { + var now = SystemClock.Instance.GetCurrentInstant(); + var spell = await db.MagicSpells + .Where(s => s.Spell == token) + .Where(s => s.ExpiresAt == null || s.ExpiresAt > now) + .FirstOrDefaultAsync(); + if (spell is null) throw new ArgumentException("Magic spell not found."); + switch (spell.Type) { case MagicSpellType.AuthPasswordReset: diff --git a/DysonNetwork.Pass/Account/NotificationService.cs b/DysonNetwork.Pass/Account/NotificationService.cs index b795deb..8ef7f6c 100644 --- a/DysonNetwork.Pass/Account/NotificationService.cs +++ b/DysonNetwork.Pass/Account/NotificationService.cs @@ -1,29 +1,29 @@ using System.Text; using System.Text.Json; using DysonNetwork.Shared.Models; -using EFCore.BulkExtensions; -using Microsoft.EntityFrameworkCore; using NodaTime; +using DysonNetwork.Shared.Services; +using EFCore.BulkExtensions; +using MagicOnion.Server; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; -using System.Net.Http; namespace DysonNetwork.Pass.Account; public class NotificationService( - AppDatabase db - // WebSocketService ws, - // IHttpClientFactory httpFactory, - // IConfiguration config -) + AppDatabase db, + IConfiguration config, + IHttpClientFactory httpFactory +) : ServiceBase, INotificationService { - // private readonly string _notifyTopic = config["Notifications:Topic"]!; - // private readonly Uri _notifyEndpoint = new(config["Notifications:Endpoint"]!); + private readonly string _notifyTopic = config["Notifications:Topic"]!; + private readonly Uri _notifyEndpoint = new(config["Notifications:Endpoint"]!); public async Task UnsubscribePushNotifications(string deviceId) { - // await db.NotificationPushSubscriptions - // .Where(s => s.DeviceId == deviceId) - // .ExecuteDeleteAsync(); + await db.NotificationPushSubscriptions + .Where(s => s.DeviceId == deviceId) + .ExecuteDeleteAsync(); } public async Task SubscribePushNotification( @@ -34,29 +34,29 @@ public class NotificationService( ) { var now = SystemClock.Instance.GetCurrentInstant(); - + // First check if a matching subscription exists - // var existingSubscription = await db.NotificationPushSubscriptions - // .Where(s => s.AccountId == account.Id) - // .Where(s => s.DeviceId == deviceId || s.DeviceToken == deviceToken) - // .FirstOrDefaultAsync(); + var existingSubscription = await db.NotificationPushSubscriptions + .Where(s => s.AccountId == account.Id) + .Where(s => s.DeviceId == deviceId || s.DeviceToken == deviceToken) + .FirstOrDefaultAsync(); - // if (existingSubscription is not null) - // { - // // Update the existing subscription directly in the database - // await db.NotificationPushSubscriptions - // .Where(s => s.Id == existingSubscription.Id) - // .ExecuteUpdateAsync(setters => setters - // .SetProperty(s => s.DeviceId, deviceId) - // .SetProperty(s => s.DeviceToken, deviceToken) - // .SetProperty(s => s.UpdatedAt, now)); + if (existingSubscription is not null) + { + // Update the existing subscription directly in the database + await db.NotificationPushSubscriptions + .Where(s => s.Id == existingSubscription.Id) + .ExecuteUpdateAsync(setters => setters + .SetProperty(s => s.DeviceId, deviceId) + .SetProperty(s => s.DeviceToken, deviceToken) + .SetProperty(s => s.UpdatedAt, now)); - // // Return the updated subscription - // existingSubscription.DeviceId = deviceId; - // existingSubscription.DeviceToken = deviceToken; - // existingSubscription.UpdatedAt = now; - // return existingSubscription; - // } + // Return the updated subscription + existingSubscription.DeviceId = deviceId; + existingSubscription.DeviceToken = deviceToken; + existingSubscription.UpdatedAt = now; + return existingSubscription; + } var subscription = new NotificationPushSubscription { @@ -102,11 +102,12 @@ public class NotificationService( if (save) { - // db.Add(notification); - // await db.SaveChangesAsync(); + db.Add(notification); + await db.SaveChangesAsync(); } - if (!isSilent) Console.WriteLine("Simulating notification delivery."); // _ = DeliveryNotification(notification); + if (!isSilent) + Console.WriteLine("Simulating notification delivery."); // _ = DeliveryNotification(notification); return notification; } @@ -120,11 +121,11 @@ public class NotificationService( // }); // Pushing the notification - // var subscribers = await db.NotificationPushSubscriptions - // .Where(s => s.AccountId == notification.AccountId) - // .ToListAsync(); + var subscribers = await db.NotificationPushSubscriptions + .Where(s => s.AccountId == notification.AccountId) + .ToListAsync(); - // await _PushNotification(notification, subscribers); + await _PushNotification(notification, subscribers); } public async Task MarkNotificationsViewed(ICollection notifications) @@ -174,12 +175,13 @@ public class NotificationService( // }); } - // var subscribers = await db.NotificationPushSubscriptions - // .ToListAsync(); - // await _PushNotification(notification, subscribers); + var subscribers = await db.NotificationPushSubscriptions + .ToListAsync(); + await _PushNotification(notification, subscribers); } - public async Task SendNotificationBatch(Notification notification, List accounts, bool save = false) + public async Task SendNotificationBatch(Notification notification, List accounts, + bool save = false) { if (save) { @@ -198,7 +200,7 @@ public class NotificationService( }; return newNotification; }).ToList(); - // await db.BulkInsertAsync(notifications); + await db.BulkInsertAsync(notifications); } foreach (var account in accounts) @@ -219,93 +221,93 @@ public class NotificationService( // await _PushNotification(notification, subscribers); } - // private List> _BuildNotificationPayload(Notification notification, - // IEnumerable subscriptions) - // { - // var subDict = subscriptions - // .GroupBy(x => x.Provider) - // .ToDictionary(x => x.Key, x => x.ToList()); + private List> _BuildNotificationPayload(Notification notification, + IEnumerable subscriptions) + { + var subDict = subscriptions + .GroupBy(x => x.Provider) + .ToDictionary(x => x.Key, x => x.ToList()); - // var notifications = subDict.Select(value => - // { - // var platformCode = value.Key switch - // { - // NotificationPushProvider.Apple => 1, - // NotificationPushProvider.Google => 2, - // _ => throw new InvalidOperationException($"Unknown push provider: {value.Key}") - // }; + var notifications = subDict.Select(value => + { + var platformCode = value.Key switch + { + NotificationPushProvider.Apple => 1, + NotificationPushProvider.Google => 2, + _ => throw new InvalidOperationException($"Unknown push provider: {value.Key}") + }; - // var tokens = value.Value.Select(x => x.DeviceToken).ToList(); - // return _BuildNotificationPayload(notification, platformCode, tokens); - // }).ToList(); + var tokens = value.Value.Select(x => x.DeviceToken).ToList(); + return _BuildNotificationPayload(notification, platformCode, tokens); + }).ToList(); - // return notifications.ToList(); - // } + return notifications.ToList(); + } - // private Dictionary _BuildNotificationPayload(Notification notification, int platformCode, - // IEnumerable deviceTokens) - // { - // var alertDict = new Dictionary(); - // var dict = new Dictionary - // { - // ["notif_id"] = notification.Id.ToString(), - // ["apns_id"] = notification.Id.ToString(), - // ["topic"] = _notifyTopic, - // ["tokens"] = deviceTokens, - // ["data"] = new Dictionary - // { - // ["type"] = notification.Topic, - // ["meta"] = notification.Meta ?? new Dictionary(), - // }, - // ["mutable_content"] = true, - // ["priority"] = notification.Priority >= 5 ? "high" : "normal", - // }; + private Dictionary _BuildNotificationPayload(Notification notification, int platformCode, + IEnumerable deviceTokens) + { + var alertDict = new Dictionary(); + var dict = new Dictionary + { + ["notif_id"] = notification.Id.ToString(), + ["apns_id"] = notification.Id.ToString(), + ["topic"] = _notifyTopic, + ["tokens"] = deviceTokens, + ["data"] = new Dictionary + { + ["type"] = notification.Topic, + ["meta"] = notification.Meta ?? new Dictionary(), + }, + ["mutable_content"] = true, + ["priority"] = notification.Priority >= 5 ? "high" : "normal", + }; - // if (!string.IsNullOrWhiteSpace(notification.Title)) - // { - // dict["title"] = notification.Title; - // alertDict["title"] = notification.Title; - // } + if (!string.IsNullOrWhiteSpace(notification.Title)) + { + dict["title"] = notification.Title; + alertDict["title"] = notification.Title; + } - // if (!string.IsNullOrWhiteSpace(notification.Content)) - // { - // dict["message"] = notification.Content; - // alertDict["body"] = notification.Content; - // } + if (!string.IsNullOrWhiteSpace(notification.Content)) + { + dict["message"] = notification.Content; + alertDict["body"] = notification.Content; + } - // if (!string.IsNullOrWhiteSpace(notification.Subtitle)) - // { - // dict["message"] = $"{notification.Subtitle}\n{dict["message"]}"; - // alertDict["subtitle"] = notification.Subtitle; - // } + if (!string.IsNullOrWhiteSpace(notification.Subtitle)) + { + dict["message"] = $"{notification.Subtitle}\n{dict["message"]}"; + alertDict["subtitle"] = notification.Subtitle; + } - // if (notification.Priority >= 5) - // dict["name"] = "default"; + if (notification.Priority >= 5) + dict["name"] = "default"; - // dict["platform"] = platformCode; - // dict["alert"] = alertDict; + dict["platform"] = platformCode; + dict["alert"] = alertDict; - // return dict; - // } + return dict; + } - // private async Task _PushNotification(Notification notification, - // IEnumerable subscriptions) - // { - // var subList = subscriptions.ToList(); - // if (subList.Count == 0) return; + private async Task _PushNotification(Notification notification, + IEnumerable subscriptions) + { + var subList = subscriptions.ToList(); + if (subList.Count == 0) return; - // var requestDict = new Dictionary - // { - // ["notifications"] = _BuildNotificationPayload(notification, subList) - // }; + var requestDict = new Dictionary + { + ["notifications"] = _BuildNotificationPayload(notification, subList) + }; - // var client = httpFactory.CreateClient(); - // client.BaseAddress = _notifyEndpoint; - // var request = await client.PostAsync("/push", new StringContent( - // JsonSerializer.Serialize(requestDict), - // Encoding.UTF8, - // "application/json" - // )); - // request.EnsureSuccessStatusCode(); - // } + var client = httpFactory.CreateClient(); + client.BaseAddress = _notifyEndpoint; + var request = await client.PostAsync("/push", new StringContent( + JsonSerializer.Serialize(requestDict), + Encoding.UTF8, + "application/json" + )); + request.EnsureSuccessStatusCode(); + } } \ No newline at end of file diff --git a/DysonNetwork.Pass/Account/RelationshipService.cs b/DysonNetwork.Pass/Account/RelationshipService.cs index 16037de..fd49033 100644 --- a/DysonNetwork.Pass/Account/RelationshipService.cs +++ b/DysonNetwork.Pass/Account/RelationshipService.cs @@ -1,13 +1,13 @@ +using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Models; +using DysonNetwork.Shared.Services; +using MagicOnion.Server; using Microsoft.EntityFrameworkCore; using NodaTime; namespace DysonNetwork.Pass.Account; -public class RelationshipService( - AppDatabase db - // ICacheService cache -) +public class RelationshipService(AppDatabase db, ICacheService cache) : ServiceBase, IRelationshipService { private const string UserFriendsCacheKeyPrefix = "accounts:friends:"; private const string UserBlockedCacheKeyPrefix = "accounts:blocked:"; @@ -150,7 +150,7 @@ public class RelationshipService( db.Update(relationship); await db.SaveChangesAsync(); - // await PurgeRelationshipCache(accountId, relatedId); + await PurgeRelationshipCache(accountId, relatedId); return relationship; } @@ -158,8 +158,7 @@ public class RelationshipService( public async Task> ListAccountFriends(Shared.Models.Account account) { var cacheKey = $"{UserFriendsCacheKeyPrefix}{account.Id}"; - // var friends = await cache.GetAsync>(cacheKey); - var friends = new List(); // Placeholder + var friends = await cache.GetAsync>(cacheKey); if (friends == null) { @@ -169,17 +168,16 @@ public class RelationshipService( .Select(r => r.AccountId) .ToListAsync(); - // await cache.SetAsync(cacheKey, friends, TimeSpan.FromHours(1)); + await cache.SetAsync(cacheKey, friends, TimeSpan.FromHours(1)); } - return friends ?? []; + return friends; } public async Task> ListAccountBlocked(Shared.Models.Account account) { var cacheKey = $"{UserBlockedCacheKeyPrefix}{account.Id}"; - // var blocked = await cache.GetAsync>(cacheKey); - var blocked = new List(); // Placeholder + var blocked = await cache.GetAsync>(cacheKey); if (blocked == null) { @@ -189,10 +187,10 @@ public class RelationshipService( .Select(r => r.AccountId) .ToListAsync(); - // await cache.SetAsync(cacheKey, blocked, TimeSpan.FromHours(1)); + await cache.SetAsync(cacheKey, blocked, TimeSpan.FromHours(1)); } - return blocked ?? []; + return blocked; } public async Task HasRelationshipWithStatus(Guid accountId, Guid relatedId, @@ -204,9 +202,9 @@ public class RelationshipService( private async Task PurgeRelationshipCache(Guid accountId, Guid relatedId) { - // await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{accountId}"); - // await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relatedId}"); - // await cache.RemoveAsync($"{UserBlockedCacheKeyPrefix}{accountId}"); - // await cache.RemoveAsync($"{UserBlockedCacheKeyPrefix}{relatedId}"); + await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{accountId}"); + await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relatedId}"); + await cache.RemoveAsync($"{UserBlockedCacheKeyPrefix}{accountId}"); + await cache.RemoveAsync($"{UserBlockedCacheKeyPrefix}{relatedId}"); } } \ No newline at end of file diff --git a/DysonNetwork.Pass/Auth/AuthGrpcService.cs b/DysonNetwork.Pass/Auth/AuthGrpcService.cs deleted file mode 100644 index f583321..0000000 --- a/DysonNetwork.Pass/Auth/AuthGrpcService.cs +++ /dev/null @@ -1,96 +0,0 @@ -using DysonNetwork.Shared.Protos.Auth; -using Grpc.Core; -using Google.Protobuf.WellKnownTypes; -using Microsoft.EntityFrameworkCore; -using NodaTime; -using System.Text.Json; -using DysonNetwork.Shared.Models; -using DysonNetwork.Pass.Account; -using Challenge = DysonNetwork.Shared.Models.Challenge; -using Session = DysonNetwork.Shared.Models.Session; - -namespace DysonNetwork.Pass.Auth; - -public class AuthGrpcService(AppDatabase db, AccountService accounts, AuthService auth) - : DysonNetwork.Shared.Protos.Auth.AuthService.AuthServiceBase -{ - public override async Task Login(LoginRequest request, ServerCallContext context) - { - var account = await accounts.LookupAccount(request.Username); - if (account == null) - { - throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "Account not found.")); - } - - var factor = await db.AccountAuthFactors.FirstOrDefaultAsync(f => f.AccountId == account.Id && f.Type == AccountAuthFactorType.Password); - if (factor == null || !factor.VerifyPassword(request.Password)) - { - throw new RpcException(new Grpc.Core.Status(StatusCode.Unauthenticated, "Invalid credentials.")); - } - - var session = new Session - { - LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow), - ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(30)), - Account = account, - Challenge = new Challenge() - }; - - db.AuthSessions.Add(session); - await db.SaveChangesAsync(); - - var token = auth.CreateToken(session); - - return new LoginResponse - { - AccessToken = token, - ExpiresIn = (long)(session.ExpiredAt.Value - session.LastGrantedAt.Value).TotalSeconds - }; - } - - public override async Task IntrospectToken(IntrospectTokenRequest request, ServerCallContext context) - { - if (auth.ValidateToken(request.Token, out var sessionId)) - { - var session = await db.AuthSessions - .Include(s => s.Account) - .Include(s => s.Challenge) - .FirstOrDefaultAsync(s => s.Id == sessionId); - - if (session != null) - { - return new IntrospectionResponse - { - Active = true, - Claims = JsonSerializer.Serialize(new { sub = session.AccountId }), - ClientId = session.AppId?.ToString() ?? "", - Username = session.Account.Name, - Scope = string.Join(" ", session.Challenge.Scopes), - Iat = Timestamp.FromDateTime(session.CreatedAt.ToDateTimeUtc()), - Exp = Timestamp.FromDateTime(session.ExpiredAt?.ToDateTimeUtc() ?? DateTime.MaxValue) - }; - } - } - - return new IntrospectionResponse { Active = false }; - } - - public override async Task Logout(Empty request, ServerCallContext context) - { - var authorizationHeader = context.RequestHeaders.FirstOrDefault(h => h.Key == "authorization"); - if (authorizationHeader != null) - { - var token = authorizationHeader.Value.Replace("Bearer ", ""); - if (auth.ValidateToken(token, out var sessionId)) - { - var session = await db.AuthSessions.FindAsync(sessionId); - if (session != null) - { - db.AuthSessions.Remove(session); - await db.SaveChangesAsync(); - } - } - } - return new Empty(); - } -} diff --git a/DysonNetwork.Pass/Auth/OpenId/AfdianOidcService.cs b/DysonNetwork.Pass/Auth/OpenId/AfdianOidcService.cs index 0534d29..764ef26 100644 --- a/DysonNetwork.Pass/Auth/OpenId/AfdianOidcService.cs +++ b/DysonNetwork.Pass/Auth/OpenId/AfdianOidcService.cs @@ -1,6 +1,6 @@ using System.Text.Json; using DysonNetwork.Shared.Cache; -using DysonNetwork.Sphere.Auth.OpenId; +using DysonNetwork.Shared.Models; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; diff --git a/DysonNetwork.Pass/Auth/OpenId/AppleMobileSignInRequest.cs b/DysonNetwork.Pass/Auth/OpenId/AppleMobileSignInRequest.cs index f5249cd..2f0f301 100644 --- a/DysonNetwork.Pass/Auth/OpenId/AppleMobileSignInRequest.cs +++ b/DysonNetwork.Pass/Auth/OpenId/AppleMobileSignInRequest.cs @@ -1,8 +1,6 @@ - using System.ComponentModel.DataAnnotations; -using System.Text.Json.Serialization; -namespace DysonNetwork.Sphere.Auth.OpenId; +namespace DysonNetwork.Pass.Auth.OpenId; public class AppleMobileConnectRequest { diff --git a/DysonNetwork.Pass/Auth/OpenId/AppleOidcService.cs b/DysonNetwork.Pass/Auth/OpenId/AppleOidcService.cs index 448f816..8866516 100644 --- a/DysonNetwork.Pass/Auth/OpenId/AppleOidcService.cs +++ b/DysonNetwork.Pass/Auth/OpenId/AppleOidcService.cs @@ -4,7 +4,7 @@ using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using DysonNetwork.Shared.Cache; -using DysonNetwork.Sphere.Auth.OpenId; +using DysonNetwork.Shared.Models; using Microsoft.Extensions.Configuration; using Microsoft.IdentityModel.Tokens; diff --git a/DysonNetwork.Pass/Auth/OpenId/ConnectionController.cs b/DysonNetwork.Pass/Auth/OpenId/ConnectionController.cs index 993255e..2ffbdd2 100644 --- a/DysonNetwork.Pass/Auth/OpenId/ConnectionController.cs +++ b/DysonNetwork.Pass/Auth/OpenId/ConnectionController.cs @@ -1,7 +1,6 @@ using DysonNetwork.Pass.Account; using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Models; -using DysonNetwork.Sphere.Auth.OpenId; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; diff --git a/DysonNetwork.Pass/Auth/OpenId/DiscordOidcService.cs b/DysonNetwork.Pass/Auth/OpenId/DiscordOidcService.cs index 4a7da6c..039711b 100644 --- a/DysonNetwork.Pass/Auth/OpenId/DiscordOidcService.cs +++ b/DysonNetwork.Pass/Auth/OpenId/DiscordOidcService.cs @@ -1,7 +1,7 @@ using System.Net.Http.Json; using System.Text.Json; using DysonNetwork.Shared.Cache; -using DysonNetwork.Sphere.Auth.OpenId; +using DysonNetwork.Shared.Models; using Microsoft.Extensions.Configuration; namespace DysonNetwork.Pass.Auth.OpenId; diff --git a/DysonNetwork.Pass/Auth/OpenId/GitHubOidcService.cs b/DysonNetwork.Pass/Auth/OpenId/GitHubOidcService.cs index 05b20b3..4c0cb68 100644 --- a/DysonNetwork.Pass/Auth/OpenId/GitHubOidcService.cs +++ b/DysonNetwork.Pass/Auth/OpenId/GitHubOidcService.cs @@ -1,7 +1,7 @@ using System.Net.Http.Json; using System.Text.Json; using DysonNetwork.Shared.Cache; -using DysonNetwork.Sphere.Auth.OpenId; +using DysonNetwork.Shared.Models; using Microsoft.Extensions.Configuration; namespace DysonNetwork.Pass.Auth.OpenId; diff --git a/DysonNetwork.Pass/Auth/OpenId/GoogleOidcService.cs b/DysonNetwork.Pass/Auth/OpenId/GoogleOidcService.cs index 4966940..ed20f4e 100644 --- a/DysonNetwork.Pass/Auth/OpenId/GoogleOidcService.cs +++ b/DysonNetwork.Pass/Auth/OpenId/GoogleOidcService.cs @@ -1,7 +1,7 @@ using System.IdentityModel.Tokens.Jwt; using System.Net.Http.Json; using DysonNetwork.Shared.Cache; -using DysonNetwork.Sphere.Auth.OpenId; +using DysonNetwork.Shared.Models; using Microsoft.Extensions.Configuration; using Microsoft.IdentityModel.Tokens; diff --git a/DysonNetwork.Pass/Auth/OpenId/MicrosoftOidcService.cs b/DysonNetwork.Pass/Auth/OpenId/MicrosoftOidcService.cs index abeb94f..418331d 100644 --- a/DysonNetwork.Pass/Auth/OpenId/MicrosoftOidcService.cs +++ b/DysonNetwork.Pass/Auth/OpenId/MicrosoftOidcService.cs @@ -1,7 +1,7 @@ using System.Net.Http.Json; using System.Text.Json; using DysonNetwork.Shared.Cache; -using DysonNetwork.Sphere.Auth.OpenId; +using DysonNetwork.Shared.Models; using Microsoft.Extensions.Configuration; namespace DysonNetwork.Pass.Auth.OpenId; diff --git a/DysonNetwork.Pass/Auth/OpenId/OidcController.cs b/DysonNetwork.Pass/Auth/OpenId/OidcController.cs index f079c5e..c9b97d2 100644 --- a/DysonNetwork.Pass/Auth/OpenId/OidcController.cs +++ b/DysonNetwork.Pass/Auth/OpenId/OidcController.cs @@ -1,7 +1,6 @@ using DysonNetwork.Pass.Account; using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Models; -using DysonNetwork.Sphere.Auth.OpenId; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; diff --git a/DysonNetwork.Pass/Auth/OpenId/OidcService.cs b/DysonNetwork.Pass/Auth/OpenId/OidcService.cs index a937a21..7161ee8 100644 --- a/DysonNetwork.Pass/Auth/OpenId/OidcService.cs +++ b/DysonNetwork.Pass/Auth/OpenId/OidcService.cs @@ -3,7 +3,6 @@ using System.Net.Http.Json; using System.Text.Json.Serialization; using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Models; -using DysonNetwork.Sphere.Auth.OpenId; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; diff --git a/DysonNetwork.Pass/Auth/OpenId/OidcState.cs b/DysonNetwork.Pass/Auth/OpenId/OidcState.cs index 608956e..4555a5e 100644 --- a/DysonNetwork.Pass/Auth/OpenId/OidcState.cs +++ b/DysonNetwork.Pass/Auth/OpenId/OidcState.cs @@ -1,7 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; -namespace DysonNetwork.Sphere.Auth.OpenId; +namespace DysonNetwork.Pass.Auth.OpenId; /// /// Represents the state parameter used in OpenID Connect flows. diff --git a/DysonNetwork.Pass/DysonNetwork.Pass.csproj b/DysonNetwork.Pass/DysonNetwork.Pass.csproj index 8faaeda..5335654 100644 --- a/DysonNetwork.Pass/DysonNetwork.Pass.csproj +++ b/DysonNetwork.Pass/DysonNetwork.Pass.csproj @@ -9,24 +9,27 @@ - + + all runtime; build; native; contentfiles; analyzers; buildtransitive + + - + - - - + + + diff --git a/DysonNetwork.Pass/Program.cs b/DysonNetwork.Pass/Program.cs index 6fecead..44450a3 100644 --- a/DysonNetwork.Pass/Program.cs +++ b/DysonNetwork.Pass/Program.cs @@ -1,6 +1,8 @@ using DysonNetwork.Pass; using DysonNetwork.Pass.Account; using DysonNetwork.Pass.Auth; +using DysonNetwork.Pass.Startup; +using DysonNetwork.Shared.Startup; using Microsoft.AspNetCore.Builder; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; @@ -8,9 +10,15 @@ using Microsoft.Extensions.DependencyInjection; var builder = WebApplication.CreateBuilder(args); -// Add services to the container. +builder.ConfigureAppKestrel(); +builder.Services.AddAppSwagger(); +builder.Services.AddAppAuthentication(); +builder.Services.AddAppRateLimiting(); +builder.Services.AddAppBusinessServices(builder.Configuration); +builder.Services.AddAppServices(builder.Configuration); + builder.Services.AddControllers(); -builder.Services.AddGrpc(); +builder.Services.AddMagicOnion(); builder.Services.AddDbContext(options => options.UseNpgsql(builder.Configuration.GetConnectionString("App"))); @@ -21,10 +29,10 @@ var app = builder.Build(); // Configure the HTTP request pipeline. app.UseAuthorization(); +app.ConfigureAppMiddleware(builder.Configuration); app.MapControllers(); -app.MapGrpcService(); -app.MapGrpcService(); +app.MapMagicOnionService(); // Run database migrations using (var scope = app.Services.CreateScope()) diff --git a/DysonNetwork.Pass/Startup/ServiceCollectionExtensions.cs b/DysonNetwork.Pass/Startup/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..6f7032c --- /dev/null +++ b/DysonNetwork.Pass/Startup/ServiceCollectionExtensions.cs @@ -0,0 +1,186 @@ +using System.Globalization; +using System.Text.Json; +using System.Threading.RateLimiting; +using DysonNetwork.Pass.Account; +using DysonNetwork.Pass.Auth; +using DysonNetwork.Pass.Auth.OidcProvider.Options; +using DysonNetwork.Pass.Auth.OidcProvider.Services; +using DysonNetwork.Pass.Auth.OpenId; +using DysonNetwork.Pass.Localization; +using DysonNetwork.Pass.Permission; +using DysonNetwork.Shared.Cache; +using DysonNetwork.Shared.Services; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi.Models; +using NodaTime; +using NodaTime.Serialization.SystemTextJson; +using StackExchange.Redis; + +namespace DysonNetwork.Pass.Startup; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration) + { + services.AddLocalization(options => options.ResourcesPath = "Resources"); + + services.AddDbContext(); + services.AddSingleton(_ => + { + var connection = configuration.GetConnectionString("FastRetrieve")!; + return ConnectionMultiplexer.Connect(connection); + }); + services.AddSingleton(SystemClock.Instance); + services.AddHttpContextAccessor(); + services.AddSingleton(); + + services.AddHttpClient(); + + // Register MagicOnion services + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // Register OIDC services + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddControllers().AddJsonOptions(options => + { + options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; + options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower; + + options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb); + }).AddDataAnnotationsLocalization(options => + { + options.DataAnnotationLocalizerProvider = (type, factory) => + factory.Create(typeof(SharedResource)); + }); + services.AddRazorPages(); + + services.Configure(options => + { + var supportedCultures = new[] + { + new CultureInfo("en-US"), + new CultureInfo("zh-Hans"), + }; + + options.SupportedCultures = supportedCultures; + options.SupportedUICultures = supportedCultures; + }); + + return services; + } + + public static IServiceCollection AddAppRateLimiting(this IServiceCollection services) + { + services.AddRateLimiter(o => o.AddFixedWindowLimiter(policyName: "fixed", opts => + { + opts.Window = TimeSpan.FromMinutes(1); + opts.PermitLimit = 120; + opts.QueueLimit = 2; + opts.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; + })); + + return services; + } + + public static IServiceCollection AddAppAuthentication(this IServiceCollection services) + { + services.AddCors(); + services.AddAuthorization(); + services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = AuthConstants.SchemeName; + options.DefaultChallengeScheme = AuthConstants.SchemeName; + }) + .AddScheme(AuthConstants.SchemeName, _ => { }); + + return services; + } + + public static IServiceCollection AddAppSwagger(this IServiceCollection services) + { + services.AddEndpointsApiExplorer(); + services.AddSwaggerGen(options => + { + options.SwaggerDoc("v1", new OpenApiInfo + { + Version = "v1", + Title = "Solar Network API", + Description = "An open-source social network", + TermsOfService = new Uri("https://solsynth.dev/terms"), + License = new OpenApiLicense + { + Name = "APGLv3", + Url = new Uri("https://www.gnu.org/licenses/agpl-3.0.html") + } + }); + options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + In = ParameterLocation.Header, + Description = "Please enter a valid token", + Name = "Authorization", + Type = SecuritySchemeType.Http, + BearerFormat = "JWT", + Scheme = "Bearer" + }); + options.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + } + }, + [] + } + }); + }); + services.AddOpenApi(); + + return services; + } + + public static IServiceCollection AddAppBusinessServices(this IServiceCollection services, + IConfiguration configuration) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.Configure(configuration.GetSection("OidcProvider")); + services.AddScoped(); + + return services; + } +} \ No newline at end of file diff --git a/DysonNetwork.Shared/DysonNetwork.Shared.csproj b/DysonNetwork.Shared/DysonNetwork.Shared.csproj index ffdabc6..114aaa0 100644 --- a/DysonNetwork.Shared/DysonNetwork.Shared.csproj +++ b/DysonNetwork.Shared/DysonNetwork.Shared.csproj @@ -12,13 +12,13 @@ - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - + + + + + @@ -27,4 +27,10 @@ + + + ..\..\..\..\..\..\opt\homebrew\Cellar\dotnet\9.0.6\libexec\shared\Microsoft.AspNetCore.App\9.0.6\Microsoft.AspNetCore.dll + + + diff --git a/DysonNetwork.Pass/Account/MagicSpell.cs b/DysonNetwork.Shared/Models/MagicSpell.cs similarity index 95% rename from DysonNetwork.Pass/Account/MagicSpell.cs rename to DysonNetwork.Shared/Models/MagicSpell.cs index 09d25c9..e4cb54c 100644 --- a/DysonNetwork.Pass/Account/MagicSpell.cs +++ b/DysonNetwork.Shared/Models/MagicSpell.cs @@ -4,7 +4,7 @@ using System.Text.Json.Serialization; using Microsoft.EntityFrameworkCore; using NodaTime; -namespace DysonNetwork.Pass.Account; +namespace DysonNetwork.Shared.Models; public enum MagicSpellType { diff --git a/DysonNetwork.Pass/Auth/OpenId/OidcUserInfo.cs b/DysonNetwork.Shared/Models/OidcUserInfo.cs similarity index 97% rename from DysonNetwork.Pass/Auth/OpenId/OidcUserInfo.cs rename to DysonNetwork.Shared/Models/OidcUserInfo.cs index fda81a1..76f4119 100644 --- a/DysonNetwork.Pass/Auth/OpenId/OidcUserInfo.cs +++ b/DysonNetwork.Shared/Models/OidcUserInfo.cs @@ -1,4 +1,4 @@ -namespace DysonNetwork.Sphere.Auth.OpenId; +namespace DysonNetwork.Shared.Models; /// /// Represents the user information from an OIDC provider diff --git a/DysonNetwork.Shared/Protos/account.proto b/DysonNetwork.Shared/Protos/account.proto deleted file mode 100644 index 909568c..0000000 --- a/DysonNetwork.Shared/Protos/account.proto +++ /dev/null @@ -1,51 +0,0 @@ -syntax = "proto3"; - -package dyson_network.sphere.account; - -import "google/protobuf/empty.proto"; -import "google/protobuf/timestamp.proto"; - -option csharp_namespace = "DysonNetwork.Shared.Protos.Account"; - -service AccountService { - rpc GetAccount(google.protobuf.Empty) returns (AccountResponse); - rpc UpdateAccount(UpdateAccountRequest) returns (AccountResponse); -} - -message AccountResponse { - string id = 1; - string name = 2; - string nick = 3; - string language = 4; - google.protobuf.Timestamp activated_at = 5; - bool is_superuser = 6; - Profile profile = 7; -} - -message Profile { - string first_name = 1; - string last_name = 2; - string bio = 3; - string gender = 4; - string pronouns = 5; - string time_zone = 6; - string location = 7; - google.protobuf.Timestamp birthday = 8; - google.protobuf.Timestamp last_seen_at = 9; - int32 experience = 10; - int32 level = 11; - double leveling_progress = 12; -} - -message UpdateAccountRequest { - optional string nick = 1; - optional string language = 2; - optional string first_name = 3; - optional string last_name = 4; - optional string bio = 5; - optional string gender = 6; - optional string pronouns = 7; - optional string time_zone = 8; - optional string location = 9; - optional google.protobuf.Timestamp birthday = 10; -} diff --git a/DysonNetwork.Shared/Protos/auth.proto b/DysonNetwork.Shared/Protos/auth.proto deleted file mode 100644 index 0390bf8..0000000 --- a/DysonNetwork.Shared/Protos/auth.proto +++ /dev/null @@ -1,69 +0,0 @@ -syntax = "proto3"; - -package dyson_network.sphere.auth; - -import "google/protobuf/empty.proto"; -import "google/protobuf/timestamp.proto"; - -option csharp_namespace = "DysonNetwork.Shared.Protos.Auth"; - -service AuthService { - rpc Login(LoginRequest) returns (LoginResponse); - rpc IntrospectToken(IntrospectTokenRequest) returns (IntrospectionResponse); - rpc Logout(google.protobuf.Empty) returns (google.protobuf.Empty); -} - -message LoginRequest { - string username = 1; - string password = 2; - optional string two_factor_code = 3; -} - -message LoginResponse { - string access_token = 1; - string refresh_token = 2; - int64 expires_in = 3; -} - -message IntrospectTokenRequest { - string token = 1; - optional string token_type_hint = 2; -} - -message IntrospectionResponse { - bool active = 1; - string claims = 2; - string client_id = 3; - string username = 4; - string scope = 5; - google.protobuf.Timestamp iat = 6; - google.protobuf.Timestamp exp = 7; -} - -message Session { - string id = 1; - string label = 2; - google.protobuf.Timestamp last_granted_at = 3; - google.protobuf.Timestamp expired_at = 4; - string account_id = 5; - string challenge_id = 6; - optional string app_id = 7; -} - -message Challenge { - string id = 1; - google.protobuf.Timestamp expired_at = 2; - int32 step_remain = 3; - int32 step_total = 4; - int32 failed_attempts = 5; - string platform = 6; - string type = 7; - repeated string blacklist_factors = 8; - repeated string audiences = 9; - repeated string scopes = 10; - string ip_address = 11; - string user_agent = 12; - optional string device_id = 13; - optional string nonce = 14; - string account_id = 15; -} diff --git a/DysonNetwork.Shared/Services/IAccountEventService.cs b/DysonNetwork.Shared/Services/IAccountEventService.cs new file mode 100644 index 0000000..c631786 --- /dev/null +++ b/DysonNetwork.Shared/Services/IAccountEventService.cs @@ -0,0 +1,28 @@ +using DysonNetwork.Shared.Models; +using MagicOnion; +using NodaTime; + +namespace DysonNetwork.Shared.Services; + +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); + + /// + /// Performs a daily check-in for a user + /// + Task CheckInDaily(Account user); + + /// + /// Gets the check-in streak for a user + /// + Task GetCheckInStreak(Account user); +} diff --git a/DysonNetwork.Shared/Services/IAccountService.cs b/DysonNetwork.Shared/Services/IAccountService.cs new file mode 100644 index 0000000..200120c --- /dev/null +++ b/DysonNetwork.Shared/Services/IAccountService.cs @@ -0,0 +1,62 @@ +using DysonNetwork.Shared.Models; +using MagicOnion; + +namespace DysonNetwork.Shared.Services; + +public interface IAccountService : IService +{ + /// + /// Removes all cached data for the specified account + /// + Task PurgeAccountCache(Account account); + + /// + /// Looks up an account by username or contact information + /// + /// Username or contact information to search for + /// The matching account if found, otherwise null + Task LookupAccount(string probe); + + /// + /// Looks up an account by external authentication provider connection + /// + /// The provider's unique identifier for the user + /// The name of the authentication provider + /// The matching account if found, otherwise null + Task LookupAccountByConnection(string identifier, string provider); + + /// + /// Gets the account level for the specified account ID + /// + /// The ID of the account + /// The account level if found, otherwise null + Task GetAccountLevel(Guid accountId); + + /// + /// Creates a new account with the specified details + /// + /// The account username + /// The display name/nickname + /// The primary email address + /// The account password (optional, can be set later) + /// The preferred language (defaults to en-US) + /// Whether the email is verified (defaults to false) + /// Whether the account is activated (defaults to false) + /// The newly created account + Task CreateAccount( + string name, + string nick, + string email, + string? password, + string language = "en-US", + bool isEmailVerified = false, + bool isActivated = false + ); + + /// + /// Creates a new account using OpenID Connect user information + /// + /// The OpenID Connect user information + /// The newly created account + Task CreateAccount(OidcUserInfo userInfo); +} \ No newline at end of file diff --git a/DysonNetwork.Shared/Services/IAccountUsernameService.cs b/DysonNetwork.Shared/Services/IAccountUsernameService.cs new file mode 100644 index 0000000..3f53308 --- /dev/null +++ b/DysonNetwork.Shared/Services/IAccountUsernameService.cs @@ -0,0 +1,23 @@ +using MagicOnion; + +namespace DysonNetwork.Shared.Services; + +public interface IAccountUsernameService : IService +{ + /// + /// Generates a unique username based on the provided base name + /// + /// The preferred username + /// A unique username + Task GenerateUniqueUsernameAsync(string baseName); + + /// + /// Checks if a username already exists + /// + Task IsUsernameExistsAsync(string username); + + /// + /// Sanitizes a username to remove invalid characters + /// + string SanitizeUsername(string username); +} diff --git a/DysonNetwork.Shared/Services/IActionLogService.cs b/DysonNetwork.Shared/Services/IActionLogService.cs new file mode 100644 index 0000000..01ea69d --- /dev/null +++ b/DysonNetwork.Shared/Services/IActionLogService.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using DysonNetwork.Shared.Models; +using MagicOnion; +using Microsoft.AspNetCore.Http; + +namespace DysonNetwork.Shared.Services; + +public interface IActionLogService : IService +{ + /// + /// Creates an action log entry + /// + void CreateActionLog(Guid accountId, string action, Dictionary meta); + + /// + /// Creates an action log entry from an HTTP request + /// + void CreateActionLogFromRequest( + string action, + Dictionary meta, + HttpRequest request, + Account? account = null + ); +} diff --git a/DysonNetwork.Shared/Services/IMagicSpellService.cs b/DysonNetwork.Shared/Services/IMagicSpellService.cs new file mode 100644 index 0000000..768f60e --- /dev/null +++ b/DysonNetwork.Shared/Services/IMagicSpellService.cs @@ -0,0 +1,30 @@ +using DysonNetwork.Shared.Models; +using MagicOnion; +using NodaTime; + +namespace DysonNetwork.Shared.Services; + +public interface IMagicSpellService : IService +{ + /// + /// Creates a new magic spell + /// + Task CreateMagicSpell( + Account account, + MagicSpellType type, + Dictionary meta, + Instant? expiredAt = null, + Instant? affectedAt = null, + bool preventRepeat = false + ); + + /// + /// Gets a magic spell by its token + /// + Task GetMagicSpellAsync(string token); + + /// + /// Consumes a magic spell + /// + Task ApplyMagicSpell(string token); +} diff --git a/DysonNetwork.Shared/Services/INotificationService.cs b/DysonNetwork.Shared/Services/INotificationService.cs new file mode 100644 index 0000000..9ce323d --- /dev/null +++ b/DysonNetwork.Shared/Services/INotificationService.cs @@ -0,0 +1,23 @@ +using DysonNetwork.Shared.Models; +using MagicOnion; + +namespace DysonNetwork.Shared.Services; + +public interface INotificationService : IService +{ + /// + /// Unsubscribes a device from push notifications + /// + /// The device ID to unsubscribe + Task UnsubscribePushNotifications(string deviceId); + + /// + /// Subscribes a device to push notifications + /// + Task SubscribePushNotification( + Account account, + NotificationPushProvider provider, + string deviceId, + string deviceToken + ); +} diff --git a/DysonNetwork.Shared/Services/IRelationshipService.cs b/DysonNetwork.Shared/Services/IRelationshipService.cs new file mode 100644 index 0000000..c2b1ed8 --- /dev/null +++ b/DysonNetwork.Shared/Services/IRelationshipService.cs @@ -0,0 +1,27 @@ +using DysonNetwork.Shared.Models; +using MagicOnion; + +namespace DysonNetwork.Shared.Services; + +public interface IRelationshipService : IService +{ + /// + /// Checks if a relationship exists between two accounts + /// + Task HasExistingRelationship(Guid accountId, Guid relatedId); + + /// + /// Gets a relationship between two accounts + /// + Task GetRelationship( + Guid accountId, + Guid relatedId, + RelationshipStatus? status = null, + bool ignoreExpired = false + ); + + /// + /// Creates a new relationship between two accounts + /// + Task CreateRelationship(Account sender, Account target, RelationshipStatus status); +} diff --git a/DysonNetwork.Shared/Startup/ApplicationConfiguration.cs b/DysonNetwork.Shared/Startup/ApplicationConfiguration.cs new file mode 100644 index 0000000..86fbf3c --- /dev/null +++ b/DysonNetwork.Shared/Startup/ApplicationConfiguration.cs @@ -0,0 +1,60 @@ +using System.Net; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.Extensions.Configuration; + +namespace DysonNetwork.Shared.Startup; + +public static class ApplicationConfiguration +{ + public static WebApplication ConfigureAppMiddleware(this WebApplication app, IConfiguration configuration) + { + app.MapOpenApi(); + + app.UseRequestLocalization(); + + ConfigureForwardedHeaders(app, configuration); + + app.UseCors(opts => + opts.SetIsOriginAllowed(_ => true) + .WithExposedHeaders("*") + .WithHeaders() + .AllowCredentials() + .AllowAnyHeader() + .AllowAnyMethod() + ); + + app.UseWebSockets(); + app.UseRateLimiter(); + app.UseHttpsRedirection(); + app.UseAuthorization(); + + app.MapControllers().RequireRateLimiting("fixed"); + app.MapStaticAssets().RequireRateLimiting("fixed"); + app.MapRazorPages().RequireRateLimiting("fixed"); + + return app; + } + + private static void ConfigureForwardedHeaders(WebApplication app, IConfiguration configuration) + { + var knownProxiesSection = configuration.GetSection("KnownProxies"); + var forwardedHeadersOptions = new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.All }; + + if (knownProxiesSection.Exists()) + { + var proxyAddresses = knownProxiesSection.Get(); + if (proxyAddresses != null) + foreach (var proxy in proxyAddresses) + if (IPAddress.TryParse(proxy, out var ipAddress)) + forwardedHeadersOptions.KnownProxies.Add(ipAddress); + } + else + { + forwardedHeadersOptions.KnownProxies.Add(IPAddress.Any); + forwardedHeadersOptions.KnownProxies.Add(IPAddress.IPv6Any); + } + + app.UseForwardedHeaders(forwardedHeadersOptions); + } +} diff --git a/DysonNetwork.Shared/Startup/KestrelConfiguration.cs b/DysonNetwork.Shared/Startup/KestrelConfiguration.cs new file mode 100644 index 0000000..546df1e --- /dev/null +++ b/DysonNetwork.Shared/Startup/KestrelConfiguration.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Hosting; + +namespace DysonNetwork.Shared.Startup; + +public static class KestrelConfiguration +{ + public static WebApplicationBuilder ConfigureAppKestrel(this WebApplicationBuilder builder) + { + builder.Host.UseContentRoot(Directory.GetCurrentDirectory()); + builder.WebHost.ConfigureKestrel(options => + { + options.Limits.MaxRequestBodySize = 50 * 1024 * 1024; + options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(2); + options.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(30); + }); + + return builder; + } +} diff --git a/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj b/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj index 85b3b48..aec2db8 100644 --- a/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj +++ b/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj @@ -25,9 +25,11 @@ + + - + all @@ -70,7 +72,7 @@ - + @@ -83,7 +85,6 @@ - @@ -164,10 +165,6 @@ <_ContentIncludedByDefault Remove="app\publish\package.json" /> - - - - diff --git a/DysonNetwork.Sphere/Startup/ApplicationConfiguration.cs b/DysonNetwork.Sphere/Startup/ApplicationConfiguration.cs index 08c7c67..d75207e 100644 --- a/DysonNetwork.Sphere/Startup/ApplicationConfiguration.cs +++ b/DysonNetwork.Sphere/Startup/ApplicationConfiguration.cs @@ -1,5 +1,4 @@ using System.Net; -using DysonNetwork.Sphere.Permission; using DysonNetwork.Sphere.Storage; using Microsoft.AspNetCore.HttpOverrides; using Prometheus; @@ -35,7 +34,6 @@ public static class ApplicationConfiguration app.UseRateLimiter(); app.UseHttpsRedirection(); app.UseAuthorization(); - app.UseMiddleware(); app.MapControllers().RequireRateLimiting("fixed"); app.MapStaticAssets().RequireRateLimiting("fixed"); diff --git a/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs b/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs index 4663bc4..65e50bb 100644 --- a/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs +++ b/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs @@ -1,15 +1,11 @@ using System.Globalization; -using DysonNetwork.Sphere.Account; using DysonNetwork.Sphere.Activity; -using DysonNetwork.Sphere.Auth; -using DysonNetwork.Sphere.Auth.OpenId; using DysonNetwork.Sphere.Chat; using DysonNetwork.Sphere.Chat.Realtime; using DysonNetwork.Sphere.Connection; using DysonNetwork.Sphere.Connection.Handlers; using DysonNetwork.Sphere.Email; using DysonNetwork.Sphere.Localization; -using DysonNetwork.Sphere.Permission; using DysonNetwork.Sphere.Post; using DysonNetwork.Sphere.Publisher; using DysonNetwork.Sphere.Realm; @@ -25,8 +21,6 @@ using StackExchange.Redis; using System.Text.Json; using System.Threading.RateLimiting; using DysonNetwork.Shared.Cache; -using DysonNetwork.Sphere.Auth.OidcProvider.Options; -using DysonNetwork.Sphere.Auth.OidcProvider.Services; using DysonNetwork.Sphere.Connection.WebReader; using DysonNetwork.Sphere.Developer; using DysonNetwork.Sphere.Discovery; @@ -54,20 +48,6 @@ public static class ServiceCollectionExtensions services.AddHttpClient(); - // Register OIDC services - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddControllers().AddJsonOptions(options => { options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; @@ -113,12 +93,12 @@ public static class ServiceCollectionExtensions { services.AddCors(); services.AddAuthorization(); - services.AddAuthentication(options => - { - options.DefaultAuthenticateScheme = AuthConstants.SchemeName; - options.DefaultChallengeScheme = AuthConstants.SchemeName; - }) - .AddScheme(AuthConstants.SchemeName, _ => { }); + // services.AddAuthentication(options => + // { + // options.DefaultAuthenticateScheme = AuthConstants.SchemeName; + // options.DefaultChallengeScheme = AuthConstants.SchemeName; + // }) + // .AddScheme(AuthConstants.SchemeName, _ => { }); return services; } @@ -200,22 +180,11 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddAppBusinessServices(this IServiceCollection services, IConfiguration configuration) { - services.AddScoped(); services.AddScoped(); services.Configure(configuration.GetSection("GeoIP")); services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -238,9 +207,6 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); - services.Configure(configuration.GetSection("OidcProvider")); - services.AddScoped(); - return services; } } \ No newline at end of file diff --git a/DysonNetwork.sln.DotSettings.user b/DysonNetwork.sln.DotSettings.user index b9c61aa..e8feba2 100644 --- a/DysonNetwork.sln.DotSettings.user +++ b/DysonNetwork.sln.DotSettings.user @@ -46,6 +46,7 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded