From e1b47bc7d158717a87a412172894ad50d2a52ee5 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sat, 12 Jul 2025 22:15:18 +0800 Subject: [PATCH] :sparkles: Pusher service basis --- DysonNetwork.Pass/Account/Account.cs | 76 ++++++ .../Account/AccountServiceGrpc.cs | 193 +++++++------ DysonNetwork.Pass/Account/Badge.cs | 38 +++ DysonNetwork.Pass/Account/VerificationMark.cs | 27 +- DysonNetwork.Pass/Auth/AuthServiceGrpc.cs | 27 ++ DysonNetwork.Pass/Auth/Session.cs | 32 +++ DysonNetwork.Pusher/AppDatabase.cs | 178 ++++++++++++ .../Connection/IWebSocketPacketHandler.cs | 10 +- .../Connection/WebSocketController.cs | 13 +- .../Connection/WebSocketPacket.cs | 6 +- .../Connection/WebSocketService.cs | 25 +- .../DysonNetwork.Pusher.csproj | 15 + DysonNetwork.Pusher/Email/EmailService.cs | 86 ++++++ .../Notification/Notification.cs | 24 ++ .../Notification/PushService.cs | 258 ++++++++++++++++++ .../Notification/PushSubscription.cs | 25 ++ .../DysonNetwork.Shared.csproj | 1 + DysonNetwork.Shared/Proto/GrpcTypeHelper.cs | 89 ++++++ DysonNetwork.Shared/Proto/account.proto | 26 +- DysonNetwork.Shared/Proto/auth.proto | 68 +++++ .../DysonNetwork.Sphere.csproj | 2 +- DysonNetwork.sln.DotSettings.user | 2 + 22 files changed, 1117 insertions(+), 104 deletions(-) create mode 100644 DysonNetwork.Pass/Auth/AuthServiceGrpc.cs create mode 100644 DysonNetwork.Pusher/AppDatabase.cs create mode 100644 DysonNetwork.Pusher/Email/EmailService.cs create mode 100644 DysonNetwork.Pusher/Notification/Notification.cs create mode 100644 DysonNetwork.Pusher/Notification/PushService.cs create mode 100644 DysonNetwork.Pusher/Notification/PushSubscription.cs create mode 100644 DysonNetwork.Shared/Proto/GrpcTypeHelper.cs create mode 100644 DysonNetwork.Shared/Proto/auth.proto diff --git a/DysonNetwork.Pass/Account/Account.cs b/DysonNetwork.Pass/Account/Account.cs index a4183aa..3078c7c 100644 --- a/DysonNetwork.Pass/Account/Account.cs +++ b/DysonNetwork.Pass/Account/Account.cs @@ -4,6 +4,7 @@ using System.Text.Json.Serialization; using DysonNetwork.Shared.Data; using Microsoft.EntityFrameworkCore; using NodaTime; +using NodaTime.Serialization.Protobuf; using OtpNet; namespace DysonNetwork.Pass.Account; @@ -29,6 +30,30 @@ public class Account : ModelBase [JsonIgnore] public ICollection OutgoingRelationships { get; set; } = new List(); [JsonIgnore] public ICollection IncomingRelationships { get; set; } = new List(); + + public Shared.Proto.Account ToProtoValue() + { + var proto = new Shared.Proto.Account + { + Id = Id.ToString(), + Name = Name, + Nick = Nick, + Language = Language, + ActivatedAt = ActivatedAt?.ToTimestamp(), + IsSuperuser = IsSuperuser, + Profile = Profile.ToProtoValue() + }; + + // Add contacts + foreach (var contact in Contacts) + proto.Contacts.Add(contact.ToProtoValue()); + + // Add badges + foreach (var badge in Badges) + proto.Badges.Add(badge.ToProtoValue()); + + return proto; + } } public abstract class Leveling @@ -88,6 +113,36 @@ public class AccountProfile : ModelBase public Guid AccountId { get; set; } [JsonIgnore] public Account Account { get; set; } = null!; + + public Shared.Proto.AccountProfile ToProtoValue() + { + var proto = new Shared.Proto.AccountProfile + { + Id = Id.ToString(), + FirstName = FirstName ?? string.Empty, + MiddleName = MiddleName ?? string.Empty, + LastName = LastName ?? string.Empty, + Bio = Bio ?? string.Empty, + Gender = Gender ?? string.Empty, + Pronouns = Pronouns ?? string.Empty, + TimeZone = TimeZone ?? string.Empty, + Location = Location ?? string.Empty, + Birthday = Birthday?.ToTimestamp(), + LastSeenAt = LastSeenAt?.ToTimestamp(), + Experience = Experience, + Level = Level, + LevelingProgress = LevelingProgress, + PictureId = PictureId ?? string.Empty, + BackgroundId = BackgroundId ?? string.Empty, + Picture = Picture?.ToProtoValue(), + Background = Background?.ToProtoValue(), + AccountId = AccountId.ToString(), + Verification = Verification?.ToProtoValue(), + ActiveBadge = ActiveBadge?.ToProtoValue() + }; + + return proto; + } } public class AccountContact : ModelBase @@ -100,6 +155,27 @@ public class AccountContact : ModelBase public Guid AccountId { get; set; } [JsonIgnore] public Account Account { get; set; } = null!; + + public Shared.Proto.AccountContact ToProtoValue() + { + var proto = new Shared.Proto.AccountContact + { + Id = Id.ToString(), + Type = Type switch + { + AccountContactType.Email => Shared.Proto.AccountContactType.Email, + AccountContactType.PhoneNumber => Shared.Proto.AccountContactType.PhoneNumber, + AccountContactType.Address => Shared.Proto.AccountContactType.Address, + _ => Shared.Proto.AccountContactType.Unspecified + }, + Content = Content, + IsPrimary = IsPrimary, + VerifiedAt = VerifiedAt?.ToTimestamp(), + AccountId = AccountId.ToString() + }; + + return proto; + } } public enum AccountContactType diff --git a/DysonNetwork.Pass/Account/AccountServiceGrpc.cs b/DysonNetwork.Pass/Account/AccountServiceGrpc.cs index 9ccbac7..7450de1 100644 --- a/DysonNetwork.Pass/Account/AccountServiceGrpc.cs +++ b/DysonNetwork.Pass/Account/AccountServiceGrpc.cs @@ -19,72 +19,7 @@ public class AccountServiceGrpc( private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - - // Helper methods for conversion between protobuf and domain models - private static Shared.Proto.Account ToProtoAccount(Account account) => new() - { - Id = account.Id.ToString(), - Name = account.Name, - Nick = account.Nick, - Language = account.Language, - ActivatedAt = account.ActivatedAt?.ToTimestamp(), - IsSuperuser = account.IsSuperuser, - Profile = ToProtoProfile(account.Profile) - // Note: Collections are not included by default to avoid large payloads - // They should be loaded on demand via specific methods - }; - - private static Shared.Proto.AccountProfile ToProtoProfile(AccountProfile profile) => new() - { - Id = profile.Id.ToString(), - FirstName = profile.FirstName, - MiddleName = profile.MiddleName, - LastName = profile.LastName, - Bio = profile.Bio, - Gender = profile.Gender, - Pronouns = profile.Pronouns, - TimeZone = profile.TimeZone, - Location = profile.Location, - Birthday = profile.Birthday?.ToTimestamp(), - LastSeenAt = profile.LastSeenAt?.ToTimestamp(), - Experience = profile.Experience, - Level = profile.Level, - LevelingProgress = profile.LevelingProgress, - AccountId = profile.AccountId.ToString(), - PictureId = profile.PictureId, - BackgroundId = profile.BackgroundId, - Picture = profile.Picture?.ToProtoValue(), - Background = profile.Background?.ToProtoValue() - }; - - private static Shared.Proto.AccountContact ToProtoContact(AccountContact contact) => new() - { - Id = contact.Id.ToString(), - Type = contact.Type switch - { - AccountContactType.Address => Shared.Proto.AccountContactType.Address, - AccountContactType.PhoneNumber => Shared.Proto.AccountContactType.PhoneNumber, - AccountContactType.Email => Shared.Proto.AccountContactType.Email, - _ => Shared.Proto.AccountContactType.Unspecified - }, - VerifiedAt = contact.VerifiedAt?.ToTimestamp(), - IsPrimary = contact.IsPrimary, - Content = contact.Content, - AccountId = contact.AccountId.ToString() - }; - - private static Shared.Proto.AccountBadge ToProtoBadge(AccountBadge badge) => new() - { - Id = badge.Id.ToString(), - Type = badge.Type, - Label = badge.Label, - Caption = badge.Caption, - ActivatedAt = badge.ActivatedAt?.ToTimestamp(), - ExpiredAt = badge.ExpiredAt?.ToTimestamp(), - AccountId = badge.AccountId.ToString() - }; - -// Implementation of gRPC service methods + public override async Task GetAccount(GetAccountRequest request, ServerCallContext context) { if (!Guid.TryParse(request.Id, out var accountId)) @@ -98,7 +33,7 @@ public class AccountServiceGrpc( if (account == null) throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, $"Account {request.Id} not found")); - return ToProtoAccount(account); + return account.ToProtoValue(); } public override async Task CreateAccount(CreateAccountRequest request, @@ -125,7 +60,7 @@ public class AccountServiceGrpc( await _db.SaveChangesAsync(); _logger.LogInformation("Created new account with ID {AccountId}", account.Id); - return ToProtoAccount(account); + return account.ToProtoValue(); } public override async Task UpdateAccount(UpdateAccountRequest request, @@ -145,7 +80,7 @@ public class AccountServiceGrpc( if (request.IsSuperuser != null) account.IsSuperuser = request.IsSuperuser.Value; await _db.SaveChangesAsync(); - return ToProtoAccount(account); + return account.ToProtoValue(); } public override async Task DeleteAccount(DeleteAccountRequest request, ServerCallContext context) @@ -202,7 +137,7 @@ public class AccountServiceGrpc( : "" }; - response.Accounts.AddRange(accounts.Select(ToProtoAccount)); + response.Accounts.AddRange(accounts.Select(x => x.ToProtoValue())); return response; } @@ -223,7 +158,7 @@ public class AccountServiceGrpc( throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, $"Profile for account {request.AccountId} not found")); - return ToProtoProfile(profile); + return profile.ToProtoValue(); } public override async Task UpdateProfile(UpdateProfileRequest request, @@ -249,7 +184,7 @@ public class AccountServiceGrpc( // Update other fields similarly... await _db.SaveChangesAsync(); - return ToProtoProfile(profile); + return profile.ToProtoValue(); } // Contact operations @@ -271,10 +206,65 @@ public class AccountServiceGrpc( _db.AccountContacts.Add(contact); await _db.SaveChangesAsync(); - return ToProtoContact(contact); + return contact.ToProtoValue(); } -// Implement other contact operations... + public override async Task RemoveContact(RemoveContactRequest request, ServerCallContext context) + { + if (!Guid.TryParse(request.AccountId, out var accountId)) + throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format")); + + if (!Guid.TryParse(request.Id, out var contactId)) + throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid contact ID format")); + + var contact = await _db.AccountContacts.FirstOrDefaultAsync(c => c.Id == contactId && c.AccountId == accountId); + if (contact == null) + throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "Contact not found.")); + + _db.AccountContacts.Remove(contact); + await _db.SaveChangesAsync(); + + return new Empty(); + } + + public override async Task ListContacts(ListContactsRequest request, ServerCallContext context) + { + if (!Guid.TryParse(request.AccountId, out var accountId)) + throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format")); + + var query = _db.AccountContacts.AsNoTracking().Where(c => c.AccountId == accountId); + + if (request.VerifiedOnly) + query = query.Where(c => c.VerifiedAt != null); + + var contacts = await query.ToListAsync(); + + var response = new ListContactsResponse(); + response.Contacts.AddRange(contacts.Select(c => c.ToProtoValue())); + + return response; + } + + public override async Task VerifyContact(VerifyContactRequest request, ServerCallContext context) + { + // This is a placeholder implementation. In a real-world scenario, you would + // have a more robust verification mechanism (e.g., sending a code to the + // user's email or phone). + if (!Guid.TryParse(request.AccountId, out var accountId)) + throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format")); + + if (!Guid.TryParse(request.Id, out var contactId)) + throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid contact ID format")); + + var contact = await _db.AccountContacts.FirstOrDefaultAsync(c => c.Id == contactId && c.AccountId == accountId); + if (contact == null) + throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "Contact not found.")); + + contact.VerifiedAt = _clock.GetCurrentInstant(); + await _db.SaveChangesAsync(); + + return contact.ToProtoValue(); + } // Badge operations public override async Task AddBadge(AddBadgeRequest request, ServerCallContext context) @@ -296,8 +286,59 @@ public class AccountServiceGrpc( _db.Badges.Add(badge); await _db.SaveChangesAsync(); - return ToProtoBadge(badge); + return badge.ToProtoValue(); } -// Implement other badge operations... + public override async Task RemoveBadge(RemoveBadgeRequest request, ServerCallContext context) + { + if (!Guid.TryParse(request.AccountId, out var accountId)) + throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format")); + + if (!Guid.TryParse(request.Id, out var badgeId)) + throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid badge ID format")); + + var badge = await _db.Badges.FirstOrDefaultAsync(b => b.Id == badgeId && b.AccountId == accountId); + if (badge == null) + throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "Badge not found.")); + + _db.Badges.Remove(badge); + await _db.SaveChangesAsync(); + + return new Empty(); + } + + public override async Task ListBadges(ListBadgesRequest request, ServerCallContext context) + { + if (!Guid.TryParse(request.AccountId, out var accountId)) + throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format")); + + var query = _db.Badges.AsNoTracking().Where(b => b.AccountId == accountId); + + if (request.ActiveOnly) + query = query.Where(b => b.ExpiredAt == null || b.ExpiredAt > _clock.GetCurrentInstant()); + + var badges = await query.ToListAsync(); + + var response = new ListBadgesResponse(); + response.Badges.AddRange(badges.Select(b => b.ToProtoValue())); + + return response; + } + + public override async Task SetActiveBadge(SetActiveBadgeRequest request, ServerCallContext context) + { + if (!Guid.TryParse(request.AccountId, out var accountId)) + throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format")); + + var profile = await _db.AccountProfiles.FirstOrDefaultAsync(p => p.AccountId == accountId); + if (profile == null) + throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "Profile not found.")); + + if (!string.IsNullOrEmpty(request.BadgeId) && !Guid.TryParse(request.BadgeId, out var badgeId)) + throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid badge ID format")); + + await _db.SaveChangesAsync(); + + return profile.ToProtoValue(); + } } \ No newline at end of file diff --git a/DysonNetwork.Pass/Account/Badge.cs b/DysonNetwork.Pass/Account/Badge.cs index b8cb8b1..8f61dcf 100644 --- a/DysonNetwork.Pass/Account/Badge.cs +++ b/DysonNetwork.Pass/Account/Badge.cs @@ -2,7 +2,10 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json.Serialization; using DysonNetwork.Shared.Data; +using DysonNetwork.Shared.Proto; +using Google.Protobuf.WellKnownTypes; using NodaTime; +using NodaTime.Serialization.Protobuf; namespace DysonNetwork.Pass.Account; @@ -33,6 +36,23 @@ public class AccountBadge : ModelBase AccountId = AccountId }; } + + public Shared.Proto.AccountBadge ToProtoValue() + { + var proto = new Shared.Proto.AccountBadge + { + Id = Id.ToString(), + Type = Type, + Label = Label ?? string.Empty, + Caption = Caption ?? string.Empty, + ActivatedAt = ActivatedAt?.ToTimestamp(), + ExpiredAt = ExpiredAt?.ToTimestamp(), + AccountId = AccountId.ToString(), + }; + proto.Meta.Add(GrpcTypeHelper.ConvertToValueMap(Meta)); + + return proto; + } } public class BadgeReferenceObject : ModelBase @@ -45,4 +65,22 @@ public class BadgeReferenceObject : ModelBase public Instant? ActivatedAt { get; set; } public Instant? ExpiredAt { get; set; } public Guid AccountId { get; set; } + + public Shared.Proto.BadgeReferenceObject ToProtoValue() + { + var proto = new Shared.Proto.BadgeReferenceObject + { + Id = Id.ToString(), + Type = Type, + Label = Label ?? string.Empty, + Caption = Caption ?? string.Empty, + ActivatedAt = ActivatedAt?.ToTimestamp(), + ExpiredAt = ExpiredAt?.ToTimestamp(), + AccountId = AccountId.ToString() + }; + if (Meta is not null) + proto.Meta.Add(GrpcTypeHelper.ConvertToValueMap(Meta!)); + + return proto; + } } \ No newline at end of file diff --git a/DysonNetwork.Pass/Account/VerificationMark.cs b/DysonNetwork.Pass/Account/VerificationMark.cs index fc6a419..dbd4159 100644 --- a/DysonNetwork.Pass/Account/VerificationMark.cs +++ b/DysonNetwork.Pass/Account/VerificationMark.cs @@ -13,6 +13,29 @@ public class VerificationMark [MaxLength(1024)] public string? Title { get; set; } [MaxLength(8192)] public string? Description { get; set; } [MaxLength(1024)] public string? VerifiedBy { get; set; } + + public Shared.Proto.VerificationMark ToProtoValue() + { + var proto = new Shared.Proto.VerificationMark + { + Type = Type switch + { + VerificationMarkType.Official => Shared.Proto.VerificationMarkType.Official, + VerificationMarkType.Individual => Shared.Proto.VerificationMarkType.Individual, + VerificationMarkType.Organization => Shared.Proto.VerificationMarkType.Organization, + VerificationMarkType.Government => Shared.Proto.VerificationMarkType.Government, + VerificationMarkType.Creator => Shared.Proto.VerificationMarkType.Creator, + VerificationMarkType.Developer => Shared.Proto.VerificationMarkType.Developer, + VerificationMarkType.Parody => Shared.Proto.VerificationMarkType.Parody, + _ => Shared.Proto.VerificationMarkType.Unspecified + }, + Title = Title ?? string.Empty, + Description = Description ?? string.Empty, + VerifiedBy = VerifiedBy ?? string.Empty + }; + + return proto; + } } public enum VerificationMarkType @@ -21,5 +44,7 @@ public enum VerificationMarkType Individual, Organization, Government, - Creator + Creator, + Developer, + Parody } \ No newline at end of file diff --git a/DysonNetwork.Pass/Auth/AuthServiceGrpc.cs b/DysonNetwork.Pass/Auth/AuthServiceGrpc.cs new file mode 100644 index 0000000..208bceb --- /dev/null +++ b/DysonNetwork.Pass/Auth/AuthServiceGrpc.cs @@ -0,0 +1,27 @@ +using DysonNetwork.Shared.Proto; +using Grpc.Core; +using Microsoft.EntityFrameworkCore; + +namespace DysonNetwork.Pass.Auth; + +public class AuthServiceGrpc(AuthService authService, AppDatabase db) : Shared.Proto.AuthService +{ + public async Task Authenticate(AuthenticateRequest request, ServerCallContext context) + { + if (!authService.ValidateToken(request.Token, out var sessionId)) + { + throw new RpcException(new Status(StatusCode.Unauthenticated, "Invalid token.")); + } + + var session = await db.AuthSessions + .AsNoTracking() + .FirstOrDefaultAsync(s => s.Id == sessionId); + + if (session == null) + { + throw new RpcException(new Status(StatusCode.NotFound, "Session not found.")); + } + + return session.ToProtoValue(); + } +} diff --git a/DysonNetwork.Pass/Auth/Session.cs b/DysonNetwork.Pass/Auth/Session.cs index bcd0ced..d5f935d 100644 --- a/DysonNetwork.Pass/Auth/Session.cs +++ b/DysonNetwork.Pass/Auth/Session.cs @@ -4,6 +4,7 @@ using System.Text.Json.Serialization; using DysonNetwork.Pass; using DysonNetwork.Shared.Data; using NodaTime; +using NodaTime.Serialization.Protobuf; using Point = NetTopologySuite.Geometries.Point; namespace DysonNetwork.Pass.Auth; @@ -21,6 +22,18 @@ public class AuthSession : ModelBase public AuthChallenge Challenge { get; set; } = null!; public Guid? AppId { get; set; } // public CustomApp? App { get; set; } + + public Shared.Proto.AuthSession ToProtoValue() => new() + { + Id = Id.ToString(), + Label = Label, + LastGrantedAt = LastGrantedAt?.ToTimestamp(), + ExpiredAt = ExpiredAt?.ToTimestamp(), + AccountId = AccountId.ToString(), + ChallengeId = ChallengeId.ToString(), + Challenge = Challenge.ToProtoValue(), + AppId = AppId?.ToString() + }; } public enum ChallengeType @@ -67,4 +80,23 @@ public class AuthChallenge : ModelBase if (StepRemain == 0 && BlacklistFactors.Count == 0) StepRemain = StepTotal; return this; } + + public Shared.Proto.AuthChallenge ToProtoValue() => new() + { + Id = Id.ToString(), + ExpiredAt = ExpiredAt?.ToTimestamp(), + StepRemain = StepRemain, + StepTotal = StepTotal, + FailedAttempts = FailedAttempts, + Platform = (Shared.Proto.ChallengePlatform)Platform, + Type = (Shared.Proto.ChallengeType)Type, + BlacklistFactors = { BlacklistFactors.Select(x => x.ToString()) }, + Audiences = { Audiences }, + Scopes = { Scopes }, + IpAddress = IpAddress, + UserAgent = UserAgent, + DeviceId = DeviceId, + Nonce = Nonce, + AccountId = AccountId.ToString() + }; } \ No newline at end of file diff --git a/DysonNetwork.Pusher/AppDatabase.cs b/DysonNetwork.Pusher/AppDatabase.cs new file mode 100644 index 0000000..7f247d5 --- /dev/null +++ b/DysonNetwork.Pusher/AppDatabase.cs @@ -0,0 +1,178 @@ +using System.Linq.Expressions; +using System.Reflection; +using DysonNetwork.Pusher.Notification; +using DysonNetwork.Shared.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Query; +using NodaTime; +using Quartz; + +namespace DysonNetwork.Pusher; + +public class AppDatabase( + DbContextOptions options, + IConfiguration configuration +) : DbContext(options) +{ + public DbSet Notifications { get; set; } = null!; + public DbSet PushSubscriptions { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseNpgsql( + configuration.GetConnectionString("App"), + opt => opt + .ConfigureDataSource(optSource => optSource.EnableDynamicJson()) + .UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery) + .UseNodaTime() + ).UseSnakeCaseNamingConvention(); + + base.OnConfiguring(optionsBuilder); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // Automatically apply soft-delete filter to all entities inheriting BaseModel + foreach (var entityType in modelBuilder.Model.GetEntityTypes()) + { + if (!typeof(ModelBase).IsAssignableFrom(entityType.ClrType)) continue; + var method = typeof(AppDatabase) + .GetMethod(nameof(SetSoftDeleteFilter), + BindingFlags.NonPublic | BindingFlags.Static)! + .MakeGenericMethod(entityType.ClrType); + + method.Invoke(null, [modelBuilder]); + } + } + + private static void SetSoftDeleteFilter(ModelBuilder modelBuilder) + where TEntity : ModelBase + { + modelBuilder.Entity().HasQueryFilter(e => e.DeletedAt == null); + } + + public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + var now = SystemClock.Instance.GetCurrentInstant(); + + foreach (var entry in ChangeTracker.Entries()) + { + switch (entry.State) + { + case EntityState.Added: + entry.Entity.CreatedAt = now; + entry.Entity.UpdatedAt = now; + break; + case EntityState.Modified: + entry.Entity.UpdatedAt = now; + break; + case EntityState.Deleted: + entry.State = EntityState.Modified; + entry.Entity.DeletedAt = now; + break; + case EntityState.Detached: + case EntityState.Unchanged: + default: + break; + } + } + + return await base.SaveChangesAsync(cancellationToken); + } +} + +public class AppDatabaseRecyclingJob(AppDatabase db, ILogger logger) : IJob +{ + public async Task Execute(IJobExecutionContext context) + { + var now = SystemClock.Instance.GetCurrentInstant(); + + logger.LogInformation("Deleting soft-deleted records..."); + + var threshold = now - Duration.FromDays(7); + + var entityTypes = db.Model.GetEntityTypes() + .Where(t => typeof(ModelBase).IsAssignableFrom(t.ClrType) && t.ClrType != typeof(ModelBase)) + .Select(t => t.ClrType); + + foreach (var entityType in entityTypes) + { + var set = (IQueryable)db.GetType().GetMethod(nameof(DbContext.Set), Type.EmptyTypes)! + .MakeGenericMethod(entityType).Invoke(db, null)!; + var parameter = Expression.Parameter(entityType, "e"); + var property = Expression.Property(parameter, nameof(ModelBase.DeletedAt)); + var condition = Expression.LessThan(property, Expression.Constant(threshold, typeof(Instant?))); + var notNull = Expression.NotEqual(property, Expression.Constant(null, typeof(Instant?))); + var finalCondition = Expression.AndAlso(notNull, condition); + var lambda = Expression.Lambda(finalCondition, parameter); + + var queryable = set.Provider.CreateQuery( + Expression.Call( + typeof(Queryable), + "Where", + [entityType], + set.Expression, + Expression.Quote(lambda) + ) + ); + + var toListAsync = typeof(EntityFrameworkQueryableExtensions) + .GetMethod(nameof(EntityFrameworkQueryableExtensions.ToListAsync))! + .MakeGenericMethod(entityType); + + var items = await (dynamic)toListAsync.Invoke(null, [queryable, CancellationToken.None])!; + db.RemoveRange(items); + } + + await db.SaveChangesAsync(); + } +} + +public class AppDatabaseFactory : IDesignTimeDbContextFactory +{ + public AppDatabase CreateDbContext(string[] args) + { + var configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json") + .Build(); + + var optionsBuilder = new DbContextOptionsBuilder(); + return new AppDatabase(optionsBuilder.Options, configuration); + } +} + +public static class OptionalQueryExtensions +{ + public static IQueryable If( + this IQueryable source, + bool condition, + Func, IQueryable> transform + ) + { + return condition ? transform(source) : source; + } + + public static IQueryable If( + this IIncludableQueryable source, + bool condition, + Func, IQueryable> transform + ) + where T : class + { + return condition ? transform(source) : source; + } + + public static IQueryable If( + this IIncludableQueryable> source, + bool condition, + Func>, IQueryable> transform + ) + where T : class + { + return condition ? transform(source) : source; + } +} \ No newline at end of file diff --git a/DysonNetwork.Pusher/Connection/IWebSocketPacketHandler.cs b/DysonNetwork.Pusher/Connection/IWebSocketPacketHandler.cs index 0d5c591..969285b 100644 --- a/DysonNetwork.Pusher/Connection/IWebSocketPacketHandler.cs +++ b/DysonNetwork.Pusher/Connection/IWebSocketPacketHandler.cs @@ -1,9 +1,17 @@ using System.Net.WebSockets; +using DysonNetwork.Shared.Proto; namespace DysonNetwork.Pusher.Connection; public interface IWebSocketPacketHandler { string PacketType { get; } - Task HandleAsync(Account currentUser, string deviceId, WebSocketPacket packet, WebSocket socket, WebSocketService srv); + + Task HandleAsync( + Account currentUser, + string deviceId, + WebSocketPacket packet, + WebSocket socket, + WebSocketService srv + ); } \ No newline at end of file diff --git a/DysonNetwork.Pusher/Connection/WebSocketController.cs b/DysonNetwork.Pusher/Connection/WebSocketController.cs index 8f81f04..a7135d8 100644 --- a/DysonNetwork.Pusher/Connection/WebSocketController.cs +++ b/DysonNetwork.Pusher/Connection/WebSocketController.cs @@ -1,8 +1,7 @@ -using System.Collections.Concurrent; using System.Net.WebSockets; +using DysonNetwork.Shared.Proto; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore.Metadata.Internal; using Swashbuckle.AspNetCore.Annotations; namespace DysonNetwork.Pusher.Connection; @@ -18,15 +17,15 @@ public class WebSocketController(WebSocketService ws, ILogger { HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); HttpContext.Items.TryGetValue("CurrentSession", out var currentSessionValue); - if (currentUserValue is not Account.Account currentUser || - currentSessionValue is not Auth.Session currentSession) + if (currentUserValue is not Account currentUser || + currentSessionValue is not AuthSession currentSession) { HttpContext.Response.StatusCode = StatusCodes.Status401Unauthorized; return; } - var accountId = currentUser.Id; - var deviceId = currentSession.Challenge.DeviceId; + var accountId = currentUser.Id!; + var deviceId = currentSession.Challenge.DeviceId!; if (string.IsNullOrEmpty(deviceId)) { @@ -69,7 +68,7 @@ public class WebSocketController(WebSocketService ws, ILogger private async Task _ConnectionEventLoop( string deviceId, - Account.Account currentUser, + Account currentUser, WebSocket webSocket, CancellationToken cancellationToken ) diff --git a/DysonNetwork.Pusher/Connection/WebSocketPacket.cs b/DysonNetwork.Pusher/Connection/WebSocketPacket.cs index 745a961..f20b961 100644 --- a/DysonNetwork.Pusher/Connection/WebSocketPacket.cs +++ b/DysonNetwork.Pusher/Connection/WebSocketPacket.cs @@ -2,7 +2,9 @@ using System.Text.Json; using NodaTime; using NodaTime.Serialization.SystemTextJson; -public class WebSocketPacketType +namespace DysonNetwork.Pusher.Connection; + +public abstract class WebSocketPacketType { public const string Error = "error"; public const string MessageNew = "messages.new"; @@ -31,7 +33,7 @@ public class WebSocketPacket DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower, }; return JsonSerializer.Deserialize(json, jsonOpts) ?? - throw new JsonException("Failed to deserialize WebSocketPacket"); + throw new JsonException("Failed to deserialize WebSocketPacket"); } /// diff --git a/DysonNetwork.Pusher/Connection/WebSocketService.cs b/DysonNetwork.Pusher/Connection/WebSocketService.cs index 948db1d..9022a3a 100644 --- a/DysonNetwork.Pusher/Connection/WebSocketService.cs +++ b/DysonNetwork.Pusher/Connection/WebSocketService.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Net.WebSockets; +using DysonNetwork.Shared.Proto; namespace DysonNetwork.Pusher.Connection; @@ -13,7 +14,7 @@ public class WebSocketService } private static readonly ConcurrentDictionary< - (Guid AccountId, string DeviceId), + (string AccountId, string DeviceId), (WebSocket Socket, CancellationTokenSource Cts) > ActiveConnections = new(); @@ -29,21 +30,23 @@ public class WebSocketService ActiveSubscriptions.TryRemove(deviceId, out _); } - public bool IsUserSubscribedToChatRoom(Guid accountId, string chatRoomId) + public bool IsUserSubscribedToChatRoom(string accountId, string chatRoomId) { var userDeviceIds = ActiveConnections.Keys.Where(k => k.AccountId == accountId).Select(k => k.DeviceId); foreach (var deviceId in userDeviceIds) { - if (ActiveSubscriptions.TryGetValue(deviceId, out var subscribedChatRoomId) && subscribedChatRoomId == chatRoomId) + if (ActiveSubscriptions.TryGetValue(deviceId, out var subscribedChatRoomId) && + subscribedChatRoomId == chatRoomId) { return true; } } + return false; } public bool TryAdd( - (Guid AccountId, string DeviceId) key, + (string AccountId, string DeviceId) key, WebSocket socket, CancellationTokenSource cts ) @@ -54,7 +57,7 @@ public class WebSocketService return ActiveConnections.TryAdd(key, (socket, cts)); } - public void Disconnect((Guid AccountId, string DeviceId) key, string? reason = null) + public void Disconnect((string AccountId, string DeviceId) key, string? reason = null) { if (!ActiveConnections.TryGetValue(key, out var data)) return; data.Socket.CloseAsync( @@ -67,12 +70,12 @@ public class WebSocketService UnsubscribeFromChatRoom(key.DeviceId); } - public bool GetAccountIsConnected(Guid accountId) + public bool GetAccountIsConnected(string accountId) { return ActiveConnections.Any(c => c.Key.AccountId == accountId); } - public void SendPacketToAccount(Guid userId, WebSocketPacket packet) + public void SendPacketToAccount(string userId, WebSocketPacket packet) { var connections = ActiveConnections.Where(c => c.Key.AccountId == userId); var packetBytes = packet.ToBytes(); @@ -106,8 +109,12 @@ public class WebSocketService } } - public async Task HandlePacket(Account.Account currentUser, string deviceId, WebSocketPacket packet, - WebSocket socket) + public async Task HandlePacket( + Account currentUser, + string deviceId, + WebSocketPacket packet, + WebSocket socket + ) { if (_handlerMap.TryGetValue(packet.Type, out var handler)) { diff --git a/DysonNetwork.Pusher/DysonNetwork.Pusher.csproj b/DysonNetwork.Pusher/DysonNetwork.Pusher.csproj index f3fcc72..f2639b7 100644 --- a/DysonNetwork.Pusher/DysonNetwork.Pusher.csproj +++ b/DysonNetwork.Pusher/DysonNetwork.Pusher.csproj @@ -8,8 +8,19 @@ + + + + + + + + + + + @@ -18,4 +29,8 @@ + + + + diff --git a/DysonNetwork.Pusher/Email/EmailService.cs b/DysonNetwork.Pusher/Email/EmailService.cs new file mode 100644 index 0000000..6ebe5d3 --- /dev/null +++ b/DysonNetwork.Pusher/Email/EmailService.cs @@ -0,0 +1,86 @@ +using MailKit.Net.Smtp; +using MimeKit; + +namespace DysonNetwork.Pusher.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 ILogger _logger; + + public EmailService(IConfiguration configuration, ILogger logger) + { + var cfg = configuration.GetSection("Email").Get(); + _configuration = cfg ?? throw new ArgumentException("Email service was not configured."); + _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; + } +} \ No newline at end of file diff --git a/DysonNetwork.Pusher/Notification/Notification.cs b/DysonNetwork.Pusher/Notification/Notification.cs new file mode 100644 index 0000000..3df46e5 --- /dev/null +++ b/DysonNetwork.Pusher/Notification/Notification.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; +using DysonNetwork.Shared.Data; +using DysonNetwork.Shared.Proto; +using NodaTime; + +namespace DysonNetwork.Pusher.Notification; + +public class Notification : ModelBase +{ + public Guid Id { get; set; } = Guid.NewGuid(); + [MaxLength(1024)] public string Topic { get; set; } = null!; + [MaxLength(1024)] public string? Title { get; set; } + [MaxLength(2048)] public string? Subtitle { get; set; } + [MaxLength(4096)] public string? Content { get; set; } + [Column(TypeName = "jsonb")] public Dictionary? Meta { get; set; } + public int Priority { get; set; } = 10; + public Instant? ViewedAt { get; set; } + + public Guid AccountId { get; set; } + [JsonIgnore] public Account Account { get; set; } = null!; +} + diff --git a/DysonNetwork.Pusher/Notification/PushService.cs b/DysonNetwork.Pusher/Notification/PushService.cs new file mode 100644 index 0000000..3d8b2d1 --- /dev/null +++ b/DysonNetwork.Pusher/Notification/PushService.cs @@ -0,0 +1,258 @@ +using System.Text; +using System.Text.Json; +using DysonNetwork.Shared.Proto; +using EFCore.BulkExtensions; +using Microsoft.EntityFrameworkCore; +using NodaTime; + +namespace DysonNetwork.Pusher.Notification; + +public class PushService(IConfiguration config, AppDatabase db, IHttpClientFactory httpFactory) +{ + private readonly string _notifyTopic = config["Notifications:Topic"]!; + private readonly Uri _notifyEndpoint = new(config["Notifications:Endpoint"]!); + + public async Task UnsubscribePushNotifications(string deviceId) + { + await db.PushSubscriptions + .Where(s => s.DeviceId == deviceId) + .ExecuteDeleteAsync(); + } + + public async Task SubscribePushNotification( + string deviceId, + string deviceToken, + PushProvider provider, + Account account + ) + { + var now = SystemClock.Instance.GetCurrentInstant(); + + // First check if a matching subscription exists + var accountId = Guid.Parse(account.Id!); + var existingSubscription = await db.PushSubscriptions + .Where(s => s.AccountId == accountId) + .Where(s => s.DeviceId == deviceId || s.DeviceToken == deviceToken) + .FirstOrDefaultAsync(); + + if (existingSubscription is not null) + { + // Update the existing subscription directly in the database + await db.PushSubscriptions + .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; + } + + var subscription = new PushSubscription + { + DeviceId = deviceId, + DeviceToken = deviceToken, + Provider = provider, + AccountId = accountId, + }; + + db.PushSubscriptions.Add(subscription); + await db.SaveChangesAsync(); + + return subscription; + } + + public async 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 + ) + { + if (title is null && subtitle is null && content is null) + throw new ArgumentException("Unable to send notification that completely empty."); + + meta ??= new Dictionary(); + if (actionUri is not null) meta["action_uri"] = actionUri; + + var accountId = Guid.Parse(account.Id!); + var notification = new Notification + { + Topic = topic, + Title = title, + Subtitle = subtitle, + Content = content, + Meta = meta, + AccountId = accountId, + }; + + if (save) + { + db.Add(notification); + await db.SaveChangesAsync(); + } + + if (!isSilent) _ = DeliveryNotification(notification); + + return notification; + } + + public async Task DeliveryNotification(Pusher.Notification.Notification notification) + { + // Pushing the notification + var subscribers = await db.PushSubscriptions + .Where(s => s.AccountId == notification.AccountId) + .ToListAsync(); + + await _PushNotification(notification, subscribers); + } + + public async Task MarkNotificationsViewed(ICollection notifications) + { + var now = SystemClock.Instance.GetCurrentInstant(); + var id = notifications.Where(n => n.ViewedAt == null).Select(n => n.Id).ToList(); + if (id.Count == 0) return; + + await db.Notifications + .Where(n => id.Contains(n.Id)) + .ExecuteUpdateAsync(s => s.SetProperty(n => n.ViewedAt, now) + ); + } + + public async Task SendNotificationBatch(Notification notification, List accounts, bool save = false) + { + if (save) + { + var notifications = accounts.Select(x => + { + var newNotification = new Notification + { + Topic = notification.Topic, + Title = notification.Title, + Subtitle = notification.Subtitle, + Content = notification.Content, + Meta = notification.Meta, + Priority = notification.Priority, + Account = x, + AccountId = Guid.Parse(x.Id) + }; + return newNotification; + }).ToList(); + await db.BulkInsertAsync(notifications); + } + + foreach (var account in accounts) + { + notification.Account = account; + notification.AccountId = Guid.Parse(account.Id); + } + + var accountsId = accounts.Select(x => Guid.Parse(x.Id)).ToList(); + var subscribers = await db.PushSubscriptions + .Where(s => accountsId.Contains(s.AccountId)) + .ToListAsync(); + 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()); + + var notifications = subDict.Select(value => + { + var platformCode = value.Key switch + { + PushProvider.Apple => 1, + PushProvider.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(); + + return notifications.ToList(); + } + + private Dictionary _BuildNotificationPayload(Pusher.Notification.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.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 (notification.Priority >= 5) + dict["name"] = "default"; + + dict["platform"] = platformCode; + dict["alert"] = alertDict; + + return dict; + } + + 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 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.Pusher/Notification/PushSubscription.cs b/DysonNetwork.Pusher/Notification/PushSubscription.cs new file mode 100644 index 0000000..1b50367 --- /dev/null +++ b/DysonNetwork.Pusher/Notification/PushSubscription.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; +using DysonNetwork.Shared.Data; +using Microsoft.EntityFrameworkCore; +using NodaTime; + +namespace DysonNetwork.Pusher.Notification; + +public enum PushProvider +{ + Apple, + Google +} + +[Index(nameof(AccountId), nameof(DeviceId), nameof(DeletedAt), IsUnique = true)] +public class PushSubscription : ModelBase +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid AccountId { get; set; } + [MaxLength(8192)] public string DeviceId { get; set; } = null!; + [MaxLength(8192)] public string DeviceToken { get; set; } = null!; + public PushProvider Provider { get; set; } + + public int CountDelivered { get; set; } + public Instant? LastUsedAt { get; set; } +} \ No newline at end of file diff --git a/DysonNetwork.Shared/DysonNetwork.Shared.csproj b/DysonNetwork.Shared/DysonNetwork.Shared.csproj index 364b03b..5d993e2 100644 --- a/DysonNetwork.Shared/DysonNetwork.Shared.csproj +++ b/DysonNetwork.Shared/DysonNetwork.Shared.csproj @@ -20,6 +20,7 @@ + diff --git a/DysonNetwork.Shared/Proto/GrpcTypeHelper.cs b/DysonNetwork.Shared/Proto/GrpcTypeHelper.cs new file mode 100644 index 0000000..1d2d7bd --- /dev/null +++ b/DysonNetwork.Shared/Proto/GrpcTypeHelper.cs @@ -0,0 +1,89 @@ +using Google.Protobuf.Collections; +using Google.Protobuf.WellKnownTypes; +using Newtonsoft.Json; + +namespace DysonNetwork.Shared.Proto; + +public abstract class GrpcTypeHelper +{ + private static readonly JsonSerializerSettings SerializerSettings = new() + { + PreserveReferencesHandling = PreserveReferencesHandling.All, + ReferenceLoopHandling = ReferenceLoopHandling.Ignore + }; + + public static MapField ConvertToValueMap(Dictionary source) + { + var result = new MapField(); + foreach (var kvp in source) + { + result[kvp.Key] = kvp.Value switch + { + string s => Value.ForString(s), + int i => Value.ForNumber(i), + long l => Value.ForNumber(l), + float f => Value.ForNumber(f), + double d => Value.ForNumber(d), + bool b => Value.ForBool(b), + null => Value.ForNull(), + _ => Value.ForString(JsonConvert.SerializeObject(kvp.Value, SerializerSettings)) // fallback to JSON string + }; + } + return result; + } + + public static Dictionary ConvertFromValueMap(MapField source) + { + var result = new Dictionary(); + foreach (var kvp in source) + { + var value = kvp.Value; + switch (value.KindCase) + { + case Value.KindOneofCase.StringValue: + try + { + // Try to parse as JSON object or primitive + result[kvp.Key] = JsonConvert.DeserializeObject(value.StringValue); + } + catch + { + // Fallback to raw string + result[kvp.Key] = value.StringValue; + } + break; + case Value.KindOneofCase.NumberValue: + result[kvp.Key] = value.NumberValue; + break; + case Value.KindOneofCase.BoolValue: + result[kvp.Key] = value.BoolValue; + break; + case Value.KindOneofCase.NullValue: + result[kvp.Key] = null; + break; + case Value.KindOneofCase.StructValue: + result[kvp.Key] = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(value.StructValue.Fields.ToDictionary(f => f.Key, f => ConvertField(f.Value)), SerializerSettings)); + break; + case Value.KindOneofCase.ListValue: + result[kvp.Key] = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(value.ListValue.Values.Select(ConvertField).ToList(), SerializerSettings)); + break; + default: + result[kvp.Key] = null; + break; + } + } + return result; + } + + private static object? ConvertField(Value value) + { + return value.KindCase switch + { + Value.KindOneofCase.StringValue => value.StringValue, + Value.KindOneofCase.NumberValue => value.NumberValue, + Value.KindOneofCase.BoolValue => value.BoolValue, + Value.KindOneofCase.NullValue => null, + _ => JsonConvert.DeserializeObject(JsonConvert.SerializeObject(value, SerializerSettings)) + }; + } +} \ No newline at end of file diff --git a/DysonNetwork.Shared/Proto/account.proto b/DysonNetwork.Shared/Proto/account.proto index 444cb49..142f52e 100644 --- a/DysonNetwork.Shared/Proto/account.proto +++ b/DysonNetwork.Shared/Proto/account.proto @@ -8,6 +8,7 @@ import "google/protobuf/timestamp.proto"; import "google/protobuf/wrappers.proto"; import "google/protobuf/empty.proto"; import "google/protobuf/field_mask.proto"; +import "google/protobuf/struct.proto"; import 'file.proto'; @@ -83,7 +84,7 @@ message AccountAuthFactor { string id = 1; AccountAuthFactorType type = 2; google.protobuf.StringValue secret = 3; // Omitted from JSON serialization in original - map config = 4; // Omitted from JSON serialization in original + map config = 4; // Omitted from JSON serialization in original int32 trustworthy = 5; google.protobuf.Timestamp enabled_at = 6; google.protobuf.Timestamp expired_at = 7; @@ -107,7 +108,7 @@ message AccountBadge { string type = 2; // Type/category of the badge google.protobuf.StringValue label = 3; // Display name of the badge google.protobuf.StringValue caption = 4; // Optional description of the badge - map meta = 5; // Additional metadata for the badge + map meta = 5; // Additional metadata for the badge google.protobuf.Timestamp activated_at = 6; // When the badge was activated google.protobuf.Timestamp expired_at = 7; // Optional expiration time string account_id = 8; // ID of the account this badge belongs to @@ -118,7 +119,7 @@ message AccountConnection { string id = 1; string provider = 2; string provided_identifier = 3; - map meta = 4; + map meta = 4; google.protobuf.StringValue access_token = 5; // Omitted from JSON serialization google.protobuf.StringValue refresh_token = 6; // Omitted from JSON serialization google.protobuf.Timestamp last_used_at = 7; @@ -127,19 +128,30 @@ message AccountConnection { // VerificationMark represents verification status message VerificationMark { - bool verified = 1; - string method = 2; - google.protobuf.Timestamp verified_at = 3; + VerificationMarkType type = 1; + string title = 2; + string description = 3; string verified_by = 4; } +enum VerificationMarkType { + VERIFICATION_MARK_TYPE_UNSPECIFIED = 0; + OFFICIAL = 1; + INDIVIDUAL = 2; + ORGANIZATION = 3; + GOVERNMENT = 4; + CREATOR = 5; + DEVELOPER = 6; + PARODY = 7; +} + // BadgeReferenceObject represents a reference to a badge with minimal information message BadgeReferenceObject { string id = 1; // Unique identifier for the badge string type = 2; // Type/category of the badge google.protobuf.StringValue label = 3; // Display name of the badge google.protobuf.StringValue caption = 4; // Optional description of the badge - map meta = 5; // Additional metadata for the badge + map meta = 5; // Additional metadata for the badge google.protobuf.Timestamp activated_at = 6; // When the badge was activated google.protobuf.Timestamp expired_at = 7; // Optional expiration time string account_id = 8; // ID of the account this badge belongs to diff --git a/DysonNetwork.Shared/Proto/auth.proto b/DysonNetwork.Shared/Proto/auth.proto new file mode 100644 index 0000000..e96f35e --- /dev/null +++ b/DysonNetwork.Shared/Proto/auth.proto @@ -0,0 +1,68 @@ +syntax = "proto3"; + +package proto; + +option csharp_namespace = "DysonNetwork.Shared.Proto"; + +import "google/protobuf/timestamp.proto"; +import "google/protobuf/wrappers.proto"; + +// Represents a user session +message AuthSession { + string id = 1; + google.protobuf.StringValue label = 2; + google.protobuf.Timestamp last_granted_at = 3; + google.protobuf.Timestamp expired_at = 4; + string account_id = 5; + string challenge_id = 6; + AuthChallenge challenge = 7; + google.protobuf.StringValue app_id = 8; +} + +// Represents an authentication challenge +message AuthChallenge { + string id = 1; + google.protobuf.Timestamp expired_at = 2; + int32 step_remain = 3; + int32 step_total = 4; + int32 failed_attempts = 5; + ChallengePlatform platform = 6; + ChallengeType type = 7; + repeated string blacklist_factors = 8; + repeated string audiences = 9; + repeated string scopes = 10; + google.protobuf.StringValue ip_address = 11; + google.protobuf.StringValue user_agent = 12; + google.protobuf.StringValue device_id = 13; + google.protobuf.StringValue nonce = 14; + // Point location is omitted as there is no direct proto equivalent. + string account_id = 15; +} + +// Enum for challenge types +enum ChallengeType { + CHALLENGE_TYPE_UNSPECIFIED = 0; + LOGIN = 1; + OAUTH = 2; + OIDC = 3; +} + +// Enum for challenge platforms +enum ChallengePlatform { + CHALLENGE_PLATFORM_UNSPECIFIED = 0; + UNIDENTIFIED = 1; + WEB = 2; + IOS = 3; + ANDROID = 4; + MACOS = 5; + WINDOWS = 6; + LINUX = 7; +} + +service AuthService { + rpc Authenticate(AuthenticateRequest) returns (AuthSession) {} +} + +message AuthenticateRequest { + string token = 1; +} diff --git a/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj b/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj index 7532645..1c05644 100644 --- a/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj +++ b/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj @@ -26,7 +26,7 @@ - + diff --git a/DysonNetwork.sln.DotSettings.user b/DysonNetwork.sln.DotSettings.user index 069ceae..41ec08d 100644 --- a/DysonNetwork.sln.DotSettings.user +++ b/DysonNetwork.sln.DotSettings.user @@ -1,5 +1,6 @@  ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded @@ -43,6 +44,7 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded