Compare commits
	
		
			3 Commits
		
	
	
		
			33f56c4ef5
			...
			e66abe2e0c
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| e66abe2e0c | |||
| 4a7f2e18b3 | |||
| e1b47bc7d1 | 
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -1,6 +1,7 @@ | |||||||
| bin/ | bin/ | ||||||
| obj/ | obj/ | ||||||
| /packages/ | /packages/ | ||||||
|  | /Certificates/ | ||||||
| riderModule.iml | riderModule.iml | ||||||
| /_ReSharper.Caches/ | /_ReSharper.Caches/ | ||||||
| .idea | .idea | ||||||
|   | |||||||
| @@ -4,6 +4,7 @@ using System.Text.Json.Serialization; | |||||||
| using DysonNetwork.Shared.Data; | using DysonNetwork.Shared.Data; | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using NodaTime; | using NodaTime; | ||||||
|  | using NodaTime.Serialization.Protobuf; | ||||||
| using OtpNet; | using OtpNet; | ||||||
|  |  | ||||||
| namespace DysonNetwork.Pass.Account; | namespace DysonNetwork.Pass.Account; | ||||||
| @@ -29,6 +30,30 @@ public class Account : ModelBase | |||||||
|  |  | ||||||
|     [JsonIgnore] public ICollection<Relationship> OutgoingRelationships { get; set; } = new List<Relationship>(); |     [JsonIgnore] public ICollection<Relationship> OutgoingRelationships { get; set; } = new List<Relationship>(); | ||||||
|     [JsonIgnore] public ICollection<Relationship> IncomingRelationships { get; set; } = new List<Relationship>(); |     [JsonIgnore] public ICollection<Relationship> IncomingRelationships { get; set; } = new List<Relationship>(); | ||||||
|  |  | ||||||
|  |     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 | public abstract class Leveling | ||||||
| @@ -88,6 +113,36 @@ public class AccountProfile : ModelBase | |||||||
|  |  | ||||||
|     public Guid AccountId { get; set; } |     public Guid AccountId { get; set; } | ||||||
|     [JsonIgnore] public Account Account { get; set; } = null!; |     [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 | public class AccountContact : ModelBase | ||||||
| @@ -100,6 +155,27 @@ public class AccountContact : ModelBase | |||||||
|  |  | ||||||
|     public Guid AccountId { get; set; } |     public Guid AccountId { get; set; } | ||||||
|     [JsonIgnore] public Account Account { get; set; } = null!; |     [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 | public enum AccountContactType | ||||||
|   | |||||||
| @@ -19,72 +19,7 @@ public class AccountServiceGrpc( | |||||||
|  |  | ||||||
|     private readonly ILogger<AccountServiceGrpc> |     private readonly ILogger<AccountServiceGrpc> | ||||||
|         _logger = logger ?? throw new ArgumentNullException(nameof(logger)); |         _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<Shared.Proto.Account> GetAccount(GetAccountRequest request, ServerCallContext context) |     public override async Task<Shared.Proto.Account> GetAccount(GetAccountRequest request, ServerCallContext context) | ||||||
|     { |     { | ||||||
|         if (!Guid.TryParse(request.Id, out var accountId)) |         if (!Guid.TryParse(request.Id, out var accountId)) | ||||||
| @@ -98,7 +33,7 @@ public class AccountServiceGrpc( | |||||||
|         if (account == null) |         if (account == null) | ||||||
|             throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, $"Account {request.Id} not found")); |             throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, $"Account {request.Id} not found")); | ||||||
|  |  | ||||||
|         return ToProtoAccount(account); |         return account.ToProtoValue(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public override async Task<Shared.Proto.Account> CreateAccount(CreateAccountRequest request, |     public override async Task<Shared.Proto.Account> CreateAccount(CreateAccountRequest request, | ||||||
| @@ -125,7 +60,7 @@ public class AccountServiceGrpc( | |||||||
|         await _db.SaveChangesAsync(); |         await _db.SaveChangesAsync(); | ||||||
|  |  | ||||||
|         _logger.LogInformation("Created new account with ID {AccountId}", account.Id); |         _logger.LogInformation("Created new account with ID {AccountId}", account.Id); | ||||||
|         return ToProtoAccount(account); |         return account.ToProtoValue(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public override async Task<Shared.Proto.Account> UpdateAccount(UpdateAccountRequest request, |     public override async Task<Shared.Proto.Account> UpdateAccount(UpdateAccountRequest request, | ||||||
| @@ -145,7 +80,7 @@ public class AccountServiceGrpc( | |||||||
|         if (request.IsSuperuser != null) account.IsSuperuser = request.IsSuperuser.Value; |         if (request.IsSuperuser != null) account.IsSuperuser = request.IsSuperuser.Value; | ||||||
|  |  | ||||||
|         await _db.SaveChangesAsync(); |         await _db.SaveChangesAsync(); | ||||||
|         return ToProtoAccount(account); |         return account.ToProtoValue(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public override async Task<Empty> DeleteAccount(DeleteAccountRequest request, ServerCallContext context) |     public override async Task<Empty> 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; |         return response; | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -223,7 +158,7 @@ public class AccountServiceGrpc( | |||||||
|             throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, |             throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, | ||||||
|                 $"Profile for account {request.AccountId} not found")); |                 $"Profile for account {request.AccountId} not found")); | ||||||
|  |  | ||||||
|         return ToProtoProfile(profile); |         return profile.ToProtoValue(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public override async Task<Shared.Proto.AccountProfile> UpdateProfile(UpdateProfileRequest request, |     public override async Task<Shared.Proto.AccountProfile> UpdateProfile(UpdateProfileRequest request, | ||||||
| @@ -249,7 +184,7 @@ public class AccountServiceGrpc( | |||||||
|         // Update other fields similarly... |         // Update other fields similarly... | ||||||
|  |  | ||||||
|         await _db.SaveChangesAsync(); |         await _db.SaveChangesAsync(); | ||||||
|         return ToProtoProfile(profile); |         return profile.ToProtoValue(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
| // Contact operations | // Contact operations | ||||||
| @@ -271,10 +206,65 @@ public class AccountServiceGrpc( | |||||||
|         _db.AccountContacts.Add(contact); |         _db.AccountContacts.Add(contact); | ||||||
|         await _db.SaveChangesAsync(); |         await _db.SaveChangesAsync(); | ||||||
|  |  | ||||||
|         return ToProtoContact(contact); |         return contact.ToProtoValue(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
| // Implement other contact operations... |     public override async Task<Empty> 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<ListContactsResponse> 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<Shared.Proto.AccountContact> 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 | // Badge operations | ||||||
|     public override async Task<Shared.Proto.AccountBadge> AddBadge(AddBadgeRequest request, ServerCallContext context) |     public override async Task<Shared.Proto.AccountBadge> AddBadge(AddBadgeRequest request, ServerCallContext context) | ||||||
| @@ -296,8 +286,59 @@ public class AccountServiceGrpc( | |||||||
|         _db.Badges.Add(badge); |         _db.Badges.Add(badge); | ||||||
|         await _db.SaveChangesAsync(); |         await _db.SaveChangesAsync(); | ||||||
|  |  | ||||||
|         return ToProtoBadge(badge); |         return badge.ToProtoValue(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
| // Implement other badge operations... |     public override async Task<Empty> 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<ListBadgesResponse> 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<Shared.Proto.AccountProfile> 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(); | ||||||
|  |     } | ||||||
| } | } | ||||||
| @@ -1,5 +1,5 @@ | |||||||
| using DysonNetwork.Shared.Cache; | using DysonNetwork.Shared.Cache; | ||||||
| using DysonNetwork.Shared.Geo; | using DysonNetwork.Shared.GeoIp; | ||||||
|  |  | ||||||
| namespace DysonNetwork.Pass.Account; | namespace DysonNetwork.Pass.Account; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -2,7 +2,10 @@ using System.ComponentModel.DataAnnotations; | |||||||
| using System.ComponentModel.DataAnnotations.Schema; | using System.ComponentModel.DataAnnotations.Schema; | ||||||
| using System.Text.Json.Serialization; | using System.Text.Json.Serialization; | ||||||
| using DysonNetwork.Shared.Data; | using DysonNetwork.Shared.Data; | ||||||
|  | using DysonNetwork.Shared.Proto; | ||||||
|  | using Google.Protobuf.WellKnownTypes; | ||||||
| using NodaTime; | using NodaTime; | ||||||
|  | using NodaTime.Serialization.Protobuf; | ||||||
|  |  | ||||||
| namespace DysonNetwork.Pass.Account; | namespace DysonNetwork.Pass.Account; | ||||||
|  |  | ||||||
| @@ -33,6 +36,23 @@ public class AccountBadge : ModelBase | |||||||
|             AccountId = AccountId |             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 | public class BadgeReferenceObject : ModelBase | ||||||
| @@ -45,4 +65,22 @@ public class BadgeReferenceObject : ModelBase | |||||||
|     public Instant? ActivatedAt { get; set; } |     public Instant? ActivatedAt { get; set; } | ||||||
|     public Instant? ExpiredAt { get; set; } |     public Instant? ExpiredAt { get; set; } | ||||||
|     public Guid AccountId { 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; | ||||||
|  |     } | ||||||
| } | } | ||||||
| @@ -13,6 +13,29 @@ public class VerificationMark | |||||||
|     [MaxLength(1024)] public string? Title { get; set; } |     [MaxLength(1024)] public string? Title { get; set; } | ||||||
|     [MaxLength(8192)] public string? Description { get; set; } |     [MaxLength(8192)] public string? Description { get; set; } | ||||||
|     [MaxLength(1024)] public string? VerifiedBy { 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 | public enum VerificationMarkType | ||||||
| @@ -21,5 +44,7 @@ public enum VerificationMarkType | |||||||
|     Individual, |     Individual, | ||||||
|     Organization, |     Organization, | ||||||
|     Government, |     Government, | ||||||
|     Creator |     Creator, | ||||||
|  |     Developer, | ||||||
|  |     Parody | ||||||
| } | } | ||||||
| @@ -2,6 +2,7 @@ using System.Linq.Expressions; | |||||||
| using System.Reflection; | using System.Reflection; | ||||||
| using DysonNetwork.Pass.Account; | using DysonNetwork.Pass.Account; | ||||||
| using DysonNetwork.Pass.Auth; | using DysonNetwork.Pass.Auth; | ||||||
|  | using DysonNetwork.Pass.Developer; | ||||||
| using DysonNetwork.Pass.Permission; | using DysonNetwork.Pass.Permission; | ||||||
| using DysonNetwork.Pass.Wallet; | using DysonNetwork.Pass.Wallet; | ||||||
| using DysonNetwork.Shared.Data; | using DysonNetwork.Shared.Data; | ||||||
| @@ -46,6 +47,9 @@ public class AppDatabase( | |||||||
|     public DbSet<Transaction> PaymentTransactions { get; set; } |     public DbSet<Transaction> PaymentTransactions { get; set; } | ||||||
|     public DbSet<Subscription> WalletSubscriptions { get; set; } |     public DbSet<Subscription> WalletSubscriptions { get; set; } | ||||||
|     public DbSet<Coupon> WalletCoupons { get; set; } |     public DbSet<Coupon> WalletCoupons { get; set; } | ||||||
|  |      | ||||||
|  |     public DbSet<CustomApp> CustomApps { get; set; } | ||||||
|  |     public DbSet<CustomAppSecret> CustomAppSecrets { get; set; } | ||||||
|  |  | ||||||
|     protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) |     protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) | ||||||
|     { |     { | ||||||
|   | |||||||
| @@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Mvc; | |||||||
| using NodaTime; | using NodaTime; | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using DysonNetwork.Pass.Account; | using DysonNetwork.Pass.Account; | ||||||
| using DysonNetwork.Shared.Geo; | using DysonNetwork.Shared.GeoIp; | ||||||
|  |  | ||||||
| namespace DysonNetwork.Pass.Auth; | namespace DysonNetwork.Pass.Auth; | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										27
									
								
								DysonNetwork.Pass/Auth/AuthServiceGrpc.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								DysonNetwork.Pass/Auth/AuthServiceGrpc.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -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.AuthServiceBase | ||||||
|  | { | ||||||
|  |     public override async Task<Shared.Proto.AuthSession> 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(); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -5,6 +5,7 @@ using System.Text; | |||||||
| using DysonNetwork.Pass.Auth.OidcProvider.Models; | using DysonNetwork.Pass.Auth.OidcProvider.Models; | ||||||
| using DysonNetwork.Pass.Auth.OidcProvider.Options; | using DysonNetwork.Pass.Auth.OidcProvider.Options; | ||||||
| using DysonNetwork.Pass.Auth.OidcProvider.Responses; | using DysonNetwork.Pass.Auth.OidcProvider.Responses; | ||||||
|  | using DysonNetwork.Pass.Developer; | ||||||
| using DysonNetwork.Shared.Cache; | using DysonNetwork.Shared.Cache; | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using Microsoft.Extensions.Options; | using Microsoft.Extensions.Options; | ||||||
|   | |||||||
| @@ -2,8 +2,10 @@ using System.ComponentModel.DataAnnotations; | |||||||
| using System.ComponentModel.DataAnnotations.Schema; | using System.ComponentModel.DataAnnotations.Schema; | ||||||
| using System.Text.Json.Serialization; | using System.Text.Json.Serialization; | ||||||
| using DysonNetwork.Pass; | using DysonNetwork.Pass; | ||||||
|  | using DysonNetwork.Pass.Developer; | ||||||
| using DysonNetwork.Shared.Data; | using DysonNetwork.Shared.Data; | ||||||
| using NodaTime; | using NodaTime; | ||||||
|  | using NodaTime.Serialization.Protobuf; | ||||||
| using Point = NetTopologySuite.Geometries.Point; | using Point = NetTopologySuite.Geometries.Point; | ||||||
|  |  | ||||||
| namespace DysonNetwork.Pass.Auth; | namespace DysonNetwork.Pass.Auth; | ||||||
| @@ -20,7 +22,19 @@ public class AuthSession : ModelBase | |||||||
|     public Guid ChallengeId { get; set; } |     public Guid ChallengeId { get; set; } | ||||||
|     public AuthChallenge Challenge { get; set; } = null!; |     public AuthChallenge Challenge { get; set; } = null!; | ||||||
|     public Guid? AppId { get; set; } |     public Guid? AppId { get; set; } | ||||||
|     // public CustomApp? App { 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 | public enum ChallengeType | ||||||
| @@ -67,4 +81,23 @@ public class AuthChallenge : ModelBase | |||||||
|         if (StepRemain == 0 && BlacklistFactors.Count == 0) StepRemain = StepTotal; |         if (StepRemain == 0 && BlacklistFactors.Count == 0) StepRemain = StepTotal; | ||||||
|         return this; |         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() | ||||||
|  |     }; | ||||||
| } | } | ||||||
							
								
								
									
										68
									
								
								DysonNetwork.Pass/Developer/CustomApp.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								DysonNetwork.Pass/Developer/CustomApp.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,68 @@ | |||||||
|  | using System.ComponentModel.DataAnnotations; | ||||||
|  | using System.ComponentModel.DataAnnotations.Schema; | ||||||
|  | using System.Text.Json.Serialization; | ||||||
|  | using DysonNetwork.Pass.Account; | ||||||
|  | using DysonNetwork.Shared.Data; | ||||||
|  | using NodaTime; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Pass.Developer; | ||||||
|  |  | ||||||
|  | public enum CustomAppStatus | ||||||
|  | { | ||||||
|  |     Developing, | ||||||
|  |     Staging, | ||||||
|  |     Production, | ||||||
|  |     Suspended | ||||||
|  | } | ||||||
|  |  | ||||||
|  | public class CustomApp : ModelBase, IIdentifiedResource | ||||||
|  | { | ||||||
|  |     public Guid Id { get; set; } = Guid.NewGuid(); | ||||||
|  |     [MaxLength(1024)] public string Slug { get; set; } = null!; | ||||||
|  |     [MaxLength(1024)] public string Name { get; set; } = null!; | ||||||
|  |     [MaxLength(4096)] public string? Description { get; set; } | ||||||
|  |     public CustomAppStatus Status { get; set; } = CustomAppStatus.Developing; | ||||||
|  |  | ||||||
|  |     [Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; } | ||||||
|  |     [Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; } | ||||||
|  |  | ||||||
|  |     [Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; } | ||||||
|  |     [Column(TypeName = "jsonb")] public CustomAppOauthConfig? OauthConfig { get; set; } | ||||||
|  |     [Column(TypeName = "jsonb")] public CustomAppLinks? Links { get; set; } | ||||||
|  |  | ||||||
|  |     [JsonIgnore] public ICollection<CustomAppSecret> Secrets { get; set; } = new List<CustomAppSecret>(); | ||||||
|  |  | ||||||
|  |     // TODO: Publisher | ||||||
|  |  | ||||||
|  |     [NotMapped] public string ResourceIdentifier => "custom-app/" + Id; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | public class CustomAppLinks | ||||||
|  | { | ||||||
|  |     [MaxLength(8192)] public string? HomePage { get; set; } | ||||||
|  |     [MaxLength(8192)] public string? PrivacyPolicy { get; set; } | ||||||
|  |     [MaxLength(8192)] public string? TermsOfService { get; set; } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | public class CustomAppOauthConfig | ||||||
|  | { | ||||||
|  |     [MaxLength(1024)] public string? ClientUri { get; set; } | ||||||
|  |     [MaxLength(4096)] public string[] RedirectUris { get; set; } = []; | ||||||
|  |     [MaxLength(4096)] public string[]? PostLogoutRedirectUris { get; set; } | ||||||
|  |     [MaxLength(256)] public string[]? AllowedScopes { get; set; } = ["openid", "profile", "email"]; | ||||||
|  |     [MaxLength(256)] public string[] AllowedGrantTypes { get; set; } = ["authorization_code", "refresh_token"]; | ||||||
|  |     public bool RequirePkce { get; set; } = true; | ||||||
|  |     public bool AllowOfflineAccess { get; set; } = false; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | public class CustomAppSecret : ModelBase | ||||||
|  | { | ||||||
|  |     public Guid Id { get; set; } = Guid.NewGuid(); | ||||||
|  |     [MaxLength(1024)] public string Secret { get; set; } = null!; | ||||||
|  |     [MaxLength(4096)] public string? Description { get; set; } = null!; | ||||||
|  |     public Instant? ExpiredAt { get; set; } | ||||||
|  |     public bool IsOidc { get; set; } = false; // Indicates if this secret is for OIDC/OAuth | ||||||
|  |  | ||||||
|  |     public Guid AppId { get; set; } | ||||||
|  |     public CustomApp App { get; set; } = null!; | ||||||
|  | } | ||||||
| @@ -1,92 +1,56 @@ | |||||||
| using MailKit.Net.Smtp; | using dotnet_etcd; | ||||||
|  | using DysonNetwork.Shared.Proto; | ||||||
| using Microsoft.AspNetCore.Components; | using Microsoft.AspNetCore.Components; | ||||||
| using MimeKit; |  | ||||||
|  |  | ||||||
| namespace DysonNetwork.Pass.Email; | namespace DysonNetwork.Pass.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 | public class EmailService | ||||||
| { | { | ||||||
|     private readonly EmailServiceConfiguration _configuration; |     private readonly PusherService.PusherServiceClient _client; | ||||||
|     private readonly RazorViewRenderer _viewRenderer; |     private readonly RazorViewRenderer _viewRenderer; | ||||||
|     private readonly ILogger<EmailService> _logger; |     private readonly ILogger<EmailService> _logger; | ||||||
|  |  | ||||||
|     public EmailService(IConfiguration configuration, RazorViewRenderer viewRenderer, ILogger<EmailService> logger) |     public EmailService( | ||||||
|  |         EtcdClient etcd, | ||||||
|  |         RazorViewRenderer viewRenderer, | ||||||
|  |         IConfiguration configuration, | ||||||
|  |         ILogger<EmailService> logger, | ||||||
|  |         PusherService.PusherServiceClient client | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         var cfg = configuration.GetSection("Email").Get<EmailServiceConfiguration>(); |         _client = GrpcClientHelper.CreatePusherServiceClient( | ||||||
|         _configuration = cfg ?? throw new ArgumentException("Email service was not configured."); |             etcd, | ||||||
|  |             configuration["Service:CertPath"]!, | ||||||
|  |             configuration["Service:KeyPath"]! | ||||||
|  |         ).GetAwaiter().GetResult(); | ||||||
|         _viewRenderer = viewRenderer; |         _viewRenderer = viewRenderer; | ||||||
|         _logger = logger; |         _logger = logger; | ||||||
|  |         _client = client; | ||||||
|     } |     } | ||||||
|  |      | ||||||
|     public async Task SendEmailAsync(string? recipientName, string recipientEmail, string subject, string textBody) |     public async Task SendEmailAsync( | ||||||
|  |         string? recipientName, | ||||||
|  |         string recipientEmail, | ||||||
|  |         string subject, | ||||||
|  |         string htmlBody | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         await SendEmailAsync(recipientName, recipientEmail, subject, textBody, null); |         subject = $"[Solarpass] {subject}"; | ||||||
|  |  | ||||||
|  |         await _client.SendEmailAsync( | ||||||
|  |             new SendEmailRequest() | ||||||
|  |             { | ||||||
|  |                 Email = new EmailMessage() | ||||||
|  |                 { | ||||||
|  |                     ToName = recipientName, | ||||||
|  |                     ToAddress = recipientEmail, | ||||||
|  |                     Subject = subject, | ||||||
|  |                     Body = htmlBody | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         ); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     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, "<style[^>]*>.*?</style>", "",  |  | ||||||
|             System.Text.RegularExpressions.RegexOptions.Singleline); |  | ||||||
|      |  | ||||||
|         // Replace header tags with text + newlines |  | ||||||
|         html = System.Text.RegularExpressions.Regex.Replace(html, "<h[1-6][^>]*>(.*?)</h[1-6]>", "$1\n\n", |  | ||||||
|             System.Text.RegularExpressions.RegexOptions.IgnoreCase); |  | ||||||
|      |  | ||||||
|         // Replace line breaks |  | ||||||
|         html = html.Replace("<br>", "\n").Replace("<br/>", "\n").Replace("<br />", "\n"); |  | ||||||
|      |  | ||||||
|         // Remove all remaining HTML tags |  | ||||||
|         html = System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", ""); |  | ||||||
|      |  | ||||||
|         // Decode HTML entities |  | ||||||
|         html = System.Net.WebUtility.HtmlDecode(html); |  | ||||||
|      |  | ||||||
|         // Remove excess whitespace |  | ||||||
|         html = System.Text.RegularExpressions.Regex.Replace(html, @"\s+", " ").Trim(); |  | ||||||
|      |  | ||||||
|         return html; |  | ||||||
|     } |  | ||||||
|      |  | ||||||
|     public async Task SendTemplatedEmailAsync<TComponent, TModel>(string? recipientName, string recipientEmail, |     public async Task SendTemplatedEmailAsync<TComponent, TModel>(string? recipientName, string recipientEmail, | ||||||
|         string subject, TModel model) |         string subject, TModel model) | ||||||
|         where TComponent : IComponent |         where TComponent : IComponent | ||||||
| @@ -94,8 +58,7 @@ public class EmailService | |||||||
|         try |         try | ||||||
|         { |         { | ||||||
|             var htmlBody = await _viewRenderer.RenderComponentToStringAsync<TComponent, TModel>(model); |             var htmlBody = await _viewRenderer.RenderComponentToStringAsync<TComponent, TModel>(model); | ||||||
|             var fallbackTextBody = _ConvertHtmlToPlainText(htmlBody); |             await SendEmailAsync(recipientName, recipientEmail, subject, htmlBody); | ||||||
|             await SendEmailAsync(recipientName, recipientEmail, subject, fallbackTextBody, htmlBody); |  | ||||||
|         } |         } | ||||||
|         catch (Exception err) |         catch (Exception err) | ||||||
|         { |         { | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| using DysonNetwork.Pass; | using DysonNetwork.Pass; | ||||||
| using DysonNetwork.Pass.Account; | using DysonNetwork.Pass.Account; | ||||||
| using DysonNetwork.Pass.Startup; | using DysonNetwork.Pass.Startup; | ||||||
|  | using DysonNetwork.Shared.Registry; | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
|  |  | ||||||
| var builder = WebApplication.CreateBuilder(args); | var builder = WebApplication.CreateBuilder(args); | ||||||
| @@ -12,6 +13,7 @@ builder.ConfigureAppKestrel(); | |||||||
| builder.Services.AddAppMetrics(); | builder.Services.AddAppMetrics(); | ||||||
|  |  | ||||||
| // Add application services | // Add application services | ||||||
|  | builder.Services.AddEtcdService(builder.Configuration); | ||||||
| builder.Services.AddAppServices(builder.Configuration); | builder.Services.AddAppServices(builder.Configuration); | ||||||
| builder.Services.AddAppRateLimiting(); | builder.Services.AddAppRateLimiting(); | ||||||
| builder.Services.AddAppAuthentication(); | builder.Services.AddAppAuthentication(); | ||||||
| @@ -26,6 +28,8 @@ builder.Services.AddAppBusinessServices(builder.Configuration); | |||||||
| // Add scheduled jobs | // Add scheduled jobs | ||||||
| builder.Services.AddAppScheduledJobs(); | builder.Services.AddAppScheduledJobs(); | ||||||
|  |  | ||||||
|  | builder.Services.AddHostedService<ServiceRegistrationHostedService>(); | ||||||
|  |  | ||||||
| var app = builder.Build(); | var app = builder.Build(); | ||||||
|  |  | ||||||
| // Run database migrations | // Run database migrations | ||||||
|   | |||||||
| @@ -1,5 +1,6 @@ | |||||||
| using System.Net; | using System.Net; | ||||||
| using DysonNetwork.Pass.Account; | using DysonNetwork.Pass.Account; | ||||||
|  | using DysonNetwork.Pass.Auth; | ||||||
| using DysonNetwork.Pass.Permission; | using DysonNetwork.Pass.Permission; | ||||||
| using Microsoft.AspNetCore.HttpOverrides; | using Microsoft.AspNetCore.HttpOverrides; | ||||||
| using Prometheus; | using Prometheus; | ||||||
| @@ -68,6 +69,7 @@ public static class ApplicationConfiguration | |||||||
|     public static WebApplication ConfigureGrpcServices(this WebApplication app) |     public static WebApplication ConfigureGrpcServices(this WebApplication app) | ||||||
|     { |     { | ||||||
|         app.MapGrpcService<AccountServiceGrpc>(); |         app.MapGrpcService<AccountServiceGrpc>(); | ||||||
|  |         app.MapGrpcService<AuthServiceGrpc>(); | ||||||
|          |          | ||||||
|         return app; |         return app; | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -18,7 +18,7 @@ using DysonNetwork.Pass.Auth.OidcProvider.Services; | |||||||
| using DysonNetwork.Pass.Handlers; | using DysonNetwork.Pass.Handlers; | ||||||
| using DysonNetwork.Pass.Wallet.PaymentHandlers; | using DysonNetwork.Pass.Wallet.PaymentHandlers; | ||||||
| using DysonNetwork.Shared.Cache; | using DysonNetwork.Shared.Cache; | ||||||
| using DysonNetwork.Shared.Geo; | using DysonNetwork.Shared.GeoIp; | ||||||
|  |  | ||||||
| namespace DysonNetwork.Pass.Startup; | namespace DysonNetwork.Pass.Startup; | ||||||
|  |  | ||||||
| @@ -53,6 +53,7 @@ public static class ServiceCollectionExtensions | |||||||
|  |  | ||||||
|         // Register gRPC services |         // Register gRPC services | ||||||
|         services.AddScoped<AccountServiceGrpc>(); |         services.AddScoped<AccountServiceGrpc>(); | ||||||
|  |         services.AddScoped<AuthServiceGrpc>(); | ||||||
|  |  | ||||||
|         // Register OIDC services |         // Register OIDC services | ||||||
|         services.AddScoped<OidcService, GoogleOidcService>(); |         services.AddScoped<OidcService, GoogleOidcService>(); | ||||||
|   | |||||||
| @@ -0,0 +1,56 @@ | |||||||
|  | using DysonNetwork.Shared.Registry; | ||||||
|  | using Microsoft.Extensions.Configuration; | ||||||
|  | using Microsoft.Extensions.Hosting; | ||||||
|  | using Microsoft.Extensions.Logging; | ||||||
|  | using System.Threading; | ||||||
|  | using System.Threading.Tasks; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Pass.Startup; | ||||||
|  |  | ||||||
|  | public class ServiceRegistrationHostedService : IHostedService | ||||||
|  | { | ||||||
|  |     private readonly ServiceRegistry _serviceRegistry; | ||||||
|  |     private readonly IConfiguration _configuration; | ||||||
|  |     private readonly ILogger<ServiceRegistrationHostedService> _logger; | ||||||
|  |  | ||||||
|  |     public ServiceRegistrationHostedService( | ||||||
|  |         ServiceRegistry serviceRegistry, | ||||||
|  |         IConfiguration configuration, | ||||||
|  |         ILogger<ServiceRegistrationHostedService> logger) | ||||||
|  |     { | ||||||
|  |         _serviceRegistry = serviceRegistry; | ||||||
|  |         _configuration = configuration; | ||||||
|  |         _logger = logger; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async Task StartAsync(CancellationToken cancellationToken) | ||||||
|  |     { | ||||||
|  |         var serviceName = "DysonNetwork.Pass"; // Preset service name | ||||||
|  |         var serviceUrl = _configuration["Service:Url"]; | ||||||
|  |  | ||||||
|  |         if (string.IsNullOrEmpty(serviceName) || string.IsNullOrEmpty(serviceUrl)) | ||||||
|  |         { | ||||||
|  |             _logger.LogWarning("Service name or URL not configured. Skipping Etcd registration."); | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         _logger.LogInformation("Registering service {ServiceName} at {ServiceUrl} with Etcd.", serviceName, serviceUrl); | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             await _serviceRegistry.RegisterService(serviceName, serviceUrl); | ||||||
|  |             _logger.LogInformation("Service {ServiceName} registered successfully.", serviceName); | ||||||
|  |         } | ||||||
|  |         catch (Exception ex) | ||||||
|  |         { | ||||||
|  |             _logger.LogError(ex, "Failed to register service {ServiceName} with Etcd.", serviceName); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public Task StopAsync(CancellationToken cancellationToken) | ||||||
|  |     { | ||||||
|  |         // The lease will expire automatically if the service stops. | ||||||
|  |         // For explicit unregistration, you would implement it here. | ||||||
|  |         _logger.LogInformation("Service registration hosted service is stopping."); | ||||||
|  |         return Task.CompletedTask; | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,12 +0,0 @@ | |||||||
| namespace DysonNetwork.Pass; |  | ||||||
|  |  | ||||||
| public class WeatherForecast |  | ||||||
| { |  | ||||||
|     public DateOnly Date { get; set; } |  | ||||||
|  |  | ||||||
|     public int TemperatureC { get; set; } |  | ||||||
|  |  | ||||||
|     public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); |  | ||||||
|  |  | ||||||
|     public string? Summary { get; set; } |  | ||||||
| } |  | ||||||
							
								
								
									
										178
									
								
								DysonNetwork.Pusher/AppDatabase.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										178
									
								
								DysonNetwork.Pusher/AppDatabase.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -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<AppDatabase> options, | ||||||
|  |     IConfiguration configuration | ||||||
|  | ) : DbContext(options) | ||||||
|  | { | ||||||
|  |     public DbSet<Notification.Notification> Notifications { get; set; } = null!; | ||||||
|  |     public DbSet<PushSubscription> 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<TEntity>(ModelBuilder modelBuilder) | ||||||
|  |         where TEntity : ModelBase | ||||||
|  |     { | ||||||
|  |         modelBuilder.Entity<TEntity>().HasQueryFilter(e => e.DeletedAt == null); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) | ||||||
|  |     { | ||||||
|  |         var now = SystemClock.Instance.GetCurrentInstant(); | ||||||
|  |  | ||||||
|  |         foreach (var entry in ChangeTracker.Entries<ModelBase>()) | ||||||
|  |         { | ||||||
|  |             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<AppDatabaseRecyclingJob> 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<AppDatabase> | ||||||
|  | { | ||||||
|  |     public AppDatabase CreateDbContext(string[] args) | ||||||
|  |     { | ||||||
|  |         var configuration = new ConfigurationBuilder() | ||||||
|  |             .SetBasePath(Directory.GetCurrentDirectory()) | ||||||
|  |             .AddJsonFile("appsettings.json") | ||||||
|  |             .Build(); | ||||||
|  |  | ||||||
|  |         var optionsBuilder = new DbContextOptionsBuilder<AppDatabase>(); | ||||||
|  |         return new AppDatabase(optionsBuilder.Options, configuration); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | public static class OptionalQueryExtensions | ||||||
|  | { | ||||||
|  |     public static IQueryable<T> If<T>( | ||||||
|  |         this IQueryable<T> source, | ||||||
|  |         bool condition, | ||||||
|  |         Func<IQueryable<T>, IQueryable<T>> transform | ||||||
|  |     ) | ||||||
|  |     { | ||||||
|  |         return condition ? transform(source) : source; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public static IQueryable<T> If<T, TP>( | ||||||
|  |         this IIncludableQueryable<T, TP> source, | ||||||
|  |         bool condition, | ||||||
|  |         Func<IIncludableQueryable<T, TP>, IQueryable<T>> transform | ||||||
|  |     ) | ||||||
|  |         where T : class | ||||||
|  |     { | ||||||
|  |         return condition ? transform(source) : source; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public static IQueryable<T> If<T, TP>( | ||||||
|  |         this IIncludableQueryable<T, IEnumerable<TP>> source, | ||||||
|  |         bool condition, | ||||||
|  |         Func<IIncludableQueryable<T, IEnumerable<TP>>, IQueryable<T>> transform | ||||||
|  |     ) | ||||||
|  |         where T : class | ||||||
|  |     { | ||||||
|  |         return condition ? transform(source) : source; | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,9 +1,17 @@ | |||||||
| using System.Net.WebSockets; | using System.Net.WebSockets; | ||||||
|  | using DysonNetwork.Shared.Proto; | ||||||
|  |  | ||||||
| namespace DysonNetwork.Pusher.Connection; | namespace DysonNetwork.Pusher.Connection; | ||||||
|  |  | ||||||
| public interface IWebSocketPacketHandler | public interface IWebSocketPacketHandler | ||||||
| { | { | ||||||
|     string PacketType { get; } |     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 | ||||||
|  |     ); | ||||||
| } | } | ||||||
| @@ -1,8 +1,7 @@ | |||||||
| using System.Collections.Concurrent; |  | ||||||
| using System.Net.WebSockets; | using System.Net.WebSockets; | ||||||
|  | using DysonNetwork.Shared.Proto; | ||||||
| using Microsoft.AspNetCore.Authorization; | using Microsoft.AspNetCore.Authorization; | ||||||
| using Microsoft.AspNetCore.Mvc; | using Microsoft.AspNetCore.Mvc; | ||||||
| using Microsoft.EntityFrameworkCore.Metadata.Internal; |  | ||||||
| using Swashbuckle.AspNetCore.Annotations; | using Swashbuckle.AspNetCore.Annotations; | ||||||
|  |  | ||||||
| namespace DysonNetwork.Pusher.Connection; | namespace DysonNetwork.Pusher.Connection; | ||||||
| @@ -18,15 +17,15 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext> | |||||||
|     { |     { | ||||||
|         HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); |         HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); | ||||||
|         HttpContext.Items.TryGetValue("CurrentSession", out var currentSessionValue); |         HttpContext.Items.TryGetValue("CurrentSession", out var currentSessionValue); | ||||||
|         if (currentUserValue is not Account.Account currentUser || |         if (currentUserValue is not Account currentUser || | ||||||
|             currentSessionValue is not Auth.Session currentSession) |             currentSessionValue is not AuthSession currentSession) | ||||||
|         { |         { | ||||||
|             HttpContext.Response.StatusCode = StatusCodes.Status401Unauthorized; |             HttpContext.Response.StatusCode = StatusCodes.Status401Unauthorized; | ||||||
|             return; |             return; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         var accountId = currentUser.Id; |         var accountId = currentUser.Id!; | ||||||
|         var deviceId = currentSession.Challenge.DeviceId; |         var deviceId = currentSession.Challenge.DeviceId!; | ||||||
|  |  | ||||||
|         if (string.IsNullOrEmpty(deviceId)) |         if (string.IsNullOrEmpty(deviceId)) | ||||||
|         { |         { | ||||||
| @@ -69,7 +68,7 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext> | |||||||
|  |  | ||||||
|     private async Task _ConnectionEventLoop( |     private async Task _ConnectionEventLoop( | ||||||
|         string deviceId, |         string deviceId, | ||||||
|         Account.Account currentUser, |         Account currentUser, | ||||||
|         WebSocket webSocket, |         WebSocket webSocket, | ||||||
|         CancellationToken cancellationToken |         CancellationToken cancellationToken | ||||||
|     ) |     ) | ||||||
|   | |||||||
| @@ -2,7 +2,9 @@ using System.Text.Json; | |||||||
| using NodaTime; | using NodaTime; | ||||||
| using NodaTime.Serialization.SystemTextJson; | using NodaTime.Serialization.SystemTextJson; | ||||||
|  |  | ||||||
| public class WebSocketPacketType | namespace DysonNetwork.Pusher.Connection; | ||||||
|  |  | ||||||
|  | public abstract class WebSocketPacketType | ||||||
| { | { | ||||||
|     public const string Error = "error"; |     public const string Error = "error"; | ||||||
|     public const string MessageNew = "messages.new"; |     public const string MessageNew = "messages.new"; | ||||||
| @@ -31,7 +33,7 @@ public class WebSocketPacket | |||||||
|             DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower, |             DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower, | ||||||
|         }; |         }; | ||||||
|         return JsonSerializer.Deserialize<WebSocketPacket>(json, jsonOpts) ?? |         return JsonSerializer.Deserialize<WebSocketPacket>(json, jsonOpts) ?? | ||||||
|             throw new JsonException("Failed to deserialize WebSocketPacket"); |                throw new JsonException("Failed to deserialize WebSocketPacket"); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /// <summary> |     /// <summary> | ||||||
|   | |||||||
| @@ -1,5 +1,6 @@ | |||||||
| using System.Collections.Concurrent; | using System.Collections.Concurrent; | ||||||
| using System.Net.WebSockets; | using System.Net.WebSockets; | ||||||
|  | using DysonNetwork.Shared.Proto; | ||||||
|  |  | ||||||
| namespace DysonNetwork.Pusher.Connection; | namespace DysonNetwork.Pusher.Connection; | ||||||
|  |  | ||||||
| @@ -13,7 +14,7 @@ public class WebSocketService | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     private static readonly ConcurrentDictionary< |     private static readonly ConcurrentDictionary< | ||||||
|         (Guid AccountId, string DeviceId), |         (string AccountId, string DeviceId), | ||||||
|         (WebSocket Socket, CancellationTokenSource Cts) |         (WebSocket Socket, CancellationTokenSource Cts) | ||||||
|     > ActiveConnections = new(); |     > ActiveConnections = new(); | ||||||
|  |  | ||||||
| @@ -29,21 +30,23 @@ public class WebSocketService | |||||||
|         ActiveSubscriptions.TryRemove(deviceId, out _); |         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); |         var userDeviceIds = ActiveConnections.Keys.Where(k => k.AccountId == accountId).Select(k => k.DeviceId); | ||||||
|         foreach (var deviceId in userDeviceIds) |         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 true; | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         return false; |         return false; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public bool TryAdd( |     public bool TryAdd( | ||||||
|         (Guid AccountId, string DeviceId) key, |         (string AccountId, string DeviceId) key, | ||||||
|         WebSocket socket, |         WebSocket socket, | ||||||
|         CancellationTokenSource cts |         CancellationTokenSource cts | ||||||
|     ) |     ) | ||||||
| @@ -54,7 +57,7 @@ public class WebSocketService | |||||||
|         return ActiveConnections.TryAdd(key, (socket, cts)); |         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; |         if (!ActiveConnections.TryGetValue(key, out var data)) return; | ||||||
|         data.Socket.CloseAsync( |         data.Socket.CloseAsync( | ||||||
| @@ -67,12 +70,12 @@ public class WebSocketService | |||||||
|         UnsubscribeFromChatRoom(key.DeviceId); |         UnsubscribeFromChatRoom(key.DeviceId); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public bool GetAccountIsConnected(Guid accountId) |     public bool GetAccountIsConnected(string accountId) | ||||||
|     { |     { | ||||||
|         return ActiveConnections.Any(c => c.Key.AccountId == 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 connections = ActiveConnections.Where(c => c.Key.AccountId == userId); | ||||||
|         var packetBytes = packet.ToBytes(); |         var packetBytes = packet.ToBytes(); | ||||||
| @@ -106,8 +109,12 @@ public class WebSocketService | |||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task HandlePacket(Account.Account currentUser, string deviceId, WebSocketPacket packet, |     public async Task HandlePacket( | ||||||
|         WebSocket socket) |         Account currentUser, | ||||||
|  |         string deviceId, | ||||||
|  |         WebSocketPacket packet, | ||||||
|  |         WebSocket socket | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         if (_handlerMap.TryGetValue(packet.Type, out var handler)) |         if (_handlerMap.TryGetValue(packet.Type, out var handler)) | ||||||
|         { |         { | ||||||
|   | |||||||
| @@ -8,8 +8,20 @@ | |||||||
|     </PropertyGroup> |     </PropertyGroup> | ||||||
|  |  | ||||||
|     <ItemGroup> |     <ItemGroup> | ||||||
|  |         <PackageReference Include="EFCore.BulkExtensions" Version="9.0.1" /> | ||||||
|  |         <PackageReference Include="EFCore.BulkExtensions.PostgreSql" Version="9.0.1" /> | ||||||
|  |         <PackageReference Include="EFCore.NamingConventions" Version="9.0.0" /> | ||||||
|         <PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" /> |         <PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" /> | ||||||
|  |         <PackageReference Include="MailKit" Version="4.13.0" /> | ||||||
|         <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7"/> |         <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7"/> | ||||||
|  |         <PackageReference Include="NodaTime" Version="3.2.2" /> | ||||||
|  |         <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" /> | ||||||
|  |         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" /> | ||||||
|  |         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" /> | ||||||
|  |         <PackageReference Include="Quartz" Version="3.14.0" /> | ||||||
|  |         <PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0" /> | ||||||
|  |         <PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.3" /> | ||||||
|  |         <PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.3" /> | ||||||
|     </ItemGroup> |     </ItemGroup> | ||||||
|  |  | ||||||
|     <ItemGroup> |     <ItemGroup> | ||||||
| @@ -18,4 +30,8 @@ | |||||||
|       </Content> |       </Content> | ||||||
|     </ItemGroup> |     </ItemGroup> | ||||||
|  |  | ||||||
|  |     <ItemGroup> | ||||||
|  |       <ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" /> | ||||||
|  |     </ItemGroup> | ||||||
|  |  | ||||||
| </Project> | </Project> | ||||||
|   | |||||||
							
								
								
									
										86
									
								
								DysonNetwork.Pusher/Email/EmailService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										86
									
								
								DysonNetwork.Pusher/Email/EmailService.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -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<EmailService> _logger; | ||||||
|  |  | ||||||
|  |     public EmailService(IConfiguration configuration, ILogger<EmailService> logger) | ||||||
|  |     { | ||||||
|  |         var cfg = configuration.GetSection("Email").Get<EmailServiceConfiguration>(); | ||||||
|  |         _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, "<style[^>]*>.*?</style>", "",  | ||||||
|  |             System.Text.RegularExpressions.RegexOptions.Singleline); | ||||||
|  |      | ||||||
|  |         // Replace header tags with text + newlines | ||||||
|  |         html = System.Text.RegularExpressions.Regex.Replace(html, "<h[1-6][^>]*>(.*?)</h[1-6]>", "$1\n\n", | ||||||
|  |             System.Text.RegularExpressions.RegexOptions.IgnoreCase); | ||||||
|  |      | ||||||
|  |         // Replace line breaks | ||||||
|  |         html = html.Replace("<br>", "\n").Replace("<br/>", "\n").Replace("<br />", "\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; | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										24
									
								
								DysonNetwork.Pusher/Notification/Notification.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								DysonNetwork.Pusher/Notification/Notification.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -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<string, object>? 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!; | ||||||
|  | } | ||||||
|  |  | ||||||
							
								
								
									
										246
									
								
								DysonNetwork.Pusher/Notification/PushService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										246
									
								
								DysonNetwork.Pusher/Notification/PushService.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,246 @@ | |||||||
|  | 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<PushSubscription> 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<string, object>? 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<string, object>(); | ||||||
|  |         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); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     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<Notification> 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<Guid> 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, | ||||||
|  |                     AccountId = x | ||||||
|  |                 }; | ||||||
|  |                 return newNotification; | ||||||
|  |             }).ToList(); | ||||||
|  |             await db.BulkInsertAsync(notifications); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var subscribers = await db.PushSubscriptions | ||||||
|  |             .Where(s => accounts.Contains(s.AccountId)) | ||||||
|  |             .ToListAsync(); | ||||||
|  |         await _PushNotification(notification, subscribers); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private List<Dictionary<string, object>> _BuildNotificationPayload(Notification notification, | ||||||
|  |         IEnumerable<PushSubscription> 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<string, object> _BuildNotificationPayload(Pusher.Notification.Notification notification, | ||||||
|  |         int platformCode, | ||||||
|  |         IEnumerable<string> deviceTokens) | ||||||
|  |     { | ||||||
|  |         var alertDict = new Dictionary<string, object>(); | ||||||
|  |         var dict = new Dictionary<string, object> | ||||||
|  |         { | ||||||
|  |             ["notif_id"] = notification.Id.ToString(), | ||||||
|  |             ["apns_id"] = notification.Id.ToString(), | ||||||
|  |             ["topic"] = _notifyTopic, | ||||||
|  |             ["tokens"] = deviceTokens, | ||||||
|  |             ["data"] = new Dictionary<string, object> | ||||||
|  |             { | ||||||
|  |                 ["type"] = notification.Topic, | ||||||
|  |                 ["meta"] = notification.Meta ?? new Dictionary<string, object>(), | ||||||
|  |             }, | ||||||
|  |             ["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<PushSubscription> subscriptions | ||||||
|  |     ) | ||||||
|  |     { | ||||||
|  |         var subList = subscriptions.ToList(); | ||||||
|  |         if (subList.Count == 0) return; | ||||||
|  |  | ||||||
|  |         var requestDict = new Dictionary<string, object> | ||||||
|  |         { | ||||||
|  |             ["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(); | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										25
									
								
								DysonNetwork.Pusher/Notification/PushSubscription.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								DysonNetwork.Pusher/Notification/PushSubscription.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -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; } | ||||||
|  | } | ||||||
| @@ -1,23 +1,45 @@ | |||||||
|  | using DysonNetwork.Pass.Startup; | ||||||
|  | using DysonNetwork.Pusher; | ||||||
|  | using DysonNetwork.Pusher.Startup; | ||||||
|  | using Microsoft.EntityFrameworkCore; | ||||||
|  |  | ||||||
| var builder = WebApplication.CreateBuilder(args); | var builder = WebApplication.CreateBuilder(args); | ||||||
|  |  | ||||||
| // Add services to the container. | // Configure Kestrel and server options | ||||||
|  | builder.ConfigureAppKestrel(); | ||||||
|  |  | ||||||
| builder.Services.AddControllers(); | // Add application services | ||||||
| // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi | builder.Services.AddAppServices(builder.Configuration); | ||||||
| builder.Services.AddOpenApi(); | builder.Services.AddAppRateLimiting(); | ||||||
|  | builder.Services.AddAppAuthentication(); | ||||||
|  | builder.Services.AddAppSwagger(); | ||||||
|  |  | ||||||
|  | // Add flush handlers and websocket handlers | ||||||
|  | builder.Services.AddAppFlushHandlers(); | ||||||
|  |  | ||||||
|  | // Add business services | ||||||
|  | builder.Services.AddAppBusinessServices(); | ||||||
|  |  | ||||||
|  | // Add scheduled jobs | ||||||
|  | builder.Services.AddAppScheduledJobs(); | ||||||
|  |  | ||||||
|  | builder.Services.AddHostedService<ServiceRegistrationHostedService>(); | ||||||
|  |  | ||||||
| var app = builder.Build(); | var app = builder.Build(); | ||||||
|  |  | ||||||
| // Configure the HTTP request pipeline. | // Run database migrations | ||||||
| if (app.Environment.IsDevelopment()) | using (var scope = app.Services.CreateScope()) | ||||||
| { | { | ||||||
|     app.MapOpenApi(); |     var db = scope.ServiceProvider.GetRequiredService<AppDatabase>(); | ||||||
|  |     await db.Database.MigrateAsync(); | ||||||
| } | } | ||||||
|  |  | ||||||
| app.UseHttpsRedirection(); | // Configure application middleware pipeline | ||||||
|  | app.ConfigureAppMiddleware(builder.Configuration); | ||||||
|  |  | ||||||
| app.UseAuthorization(); | app.UseMiddleware<DysonNetwork.Shared.Middleware.AuthMiddleware>(); | ||||||
|  |  | ||||||
| app.MapControllers(); | // Configure gRPC | ||||||
|  | app.ConfigureGrpcServices(); | ||||||
|  |  | ||||||
| app.Run(); | app.Run(); | ||||||
							
								
								
									
										162
									
								
								DysonNetwork.Pusher/Services/PusherServiceGrpc.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										162
									
								
								DysonNetwork.Pusher/Services/PusherServiceGrpc.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,162 @@ | |||||||
|  | using DysonNetwork.Pusher.Connection; | ||||||
|  | using DysonNetwork.Pusher.Email; | ||||||
|  | using DysonNetwork.Pusher.Notification; | ||||||
|  | using DysonNetwork.Shared.Proto; | ||||||
|  | using Google.Protobuf.WellKnownTypes; | ||||||
|  | using Grpc.Core; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Pusher.Services; | ||||||
|  |  | ||||||
|  | public class PusherServiceGrpc( | ||||||
|  |     EmailService emailService, | ||||||
|  |     WebSocketService webSocketService, | ||||||
|  |     PushService pushService | ||||||
|  | ) : PusherService.PusherServiceBase | ||||||
|  | { | ||||||
|  |     public override async Task<Empty> SendEmail(SendEmailRequest request, ServerCallContext context) | ||||||
|  |     { | ||||||
|  |         await emailService.SendEmailAsync( | ||||||
|  |             request.Email.ToName, | ||||||
|  |             request.Email.ToAddress, | ||||||
|  |             request.Email.Subject, | ||||||
|  |             request.Email.Body | ||||||
|  |         ); | ||||||
|  |         return new Empty(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public override Task<Empty> PushWebSocketPacket(PushWebSocketPacketRequest request, ServerCallContext context) | ||||||
|  |     { | ||||||
|  |         var packet = new Connection.WebSocketPacket | ||||||
|  |         { | ||||||
|  |             Type = request.Packet.Type, | ||||||
|  |             Data = request.Packet.Data, | ||||||
|  |             ErrorMessage = request.Packet.ErrorMessage | ||||||
|  |         }; | ||||||
|  |         webSocketService.SendPacketToAccount(request.UserId, packet); | ||||||
|  |         return Task.FromResult(new Empty()); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public override Task<Empty> PushWebSocketPacketToUsers(PushWebSocketPacketToUsersRequest request, | ||||||
|  |         ServerCallContext context) | ||||||
|  |     { | ||||||
|  |         var packet = new Connection.WebSocketPacket | ||||||
|  |         { | ||||||
|  |             Type = request.Packet.Type, | ||||||
|  |             Data = request.Packet.Data, | ||||||
|  |             ErrorMessage = request.Packet.ErrorMessage | ||||||
|  |         }; | ||||||
|  |         foreach (var userId in request.UserIds) | ||||||
|  |             webSocketService.SendPacketToAccount(userId, packet); | ||||||
|  |  | ||||||
|  |         return Task.FromResult(new Empty()); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public override Task<Empty> PushWebSocketPacketToDevice(PushWebSocketPacketToDeviceRequest request, | ||||||
|  |         ServerCallContext context) | ||||||
|  |     { | ||||||
|  |         var packet = new Connection.WebSocketPacket | ||||||
|  |         { | ||||||
|  |             Type = request.Packet.Type, | ||||||
|  |             Data = request.Packet.Data, | ||||||
|  |             ErrorMessage = request.Packet.ErrorMessage | ||||||
|  |         }; | ||||||
|  |         webSocketService.SendPacketToDevice(request.DeviceId, packet); | ||||||
|  |         return Task.FromResult(new Empty()); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public override Task<Empty> PushWebSocketPacketToDevices(PushWebSocketPacketToDevicesRequest request, | ||||||
|  |         ServerCallContext context) | ||||||
|  |     { | ||||||
|  |         var packet = new Connection.WebSocketPacket | ||||||
|  |         { | ||||||
|  |             Type = request.Packet.Type, | ||||||
|  |             Data = request.Packet.Data, | ||||||
|  |             ErrorMessage = request.Packet.ErrorMessage | ||||||
|  |         }; | ||||||
|  |         foreach (var deviceId in request.DeviceIds) | ||||||
|  |             webSocketService.SendPacketToDevice(deviceId, packet); | ||||||
|  |  | ||||||
|  |         return Task.FromResult(new Empty()); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public override async Task<Empty> SendPushNotification(SendPushNotificationRequest request, | ||||||
|  |         ServerCallContext context) | ||||||
|  |     { | ||||||
|  |         // This is a placeholder implementation. In a real-world scenario, you would | ||||||
|  |         // need to retrieve the account from the database based on the device token. | ||||||
|  |         var account = new Account(); | ||||||
|  |         await pushService.SendNotification( | ||||||
|  |             account, | ||||||
|  |             request.Notification.Topic, | ||||||
|  |             request.Notification.Title, | ||||||
|  |             request.Notification.Subtitle, | ||||||
|  |             request.Notification.Body, | ||||||
|  |             GrpcTypeHelper.ConvertFromValueMap(request.Notification.Meta), | ||||||
|  |             request.Notification.ActionUri, | ||||||
|  |             request.Notification.IsSilent, | ||||||
|  |             request.Notification.IsSavable | ||||||
|  |         ); | ||||||
|  |         return new Empty(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public override async Task<Empty> SendPushNotificationToDevices(SendPushNotificationToDevicesRequest request, | ||||||
|  |         ServerCallContext context) | ||||||
|  |     { | ||||||
|  |         // This is a placeholder implementation. In a real-world scenario, you would | ||||||
|  |         // need to retrieve the accounts from the database based on the device tokens. | ||||||
|  |         var account = new Account(); | ||||||
|  |         foreach (var deviceId in request.DeviceIds) | ||||||
|  |         { | ||||||
|  |             await pushService.SendNotification( | ||||||
|  |                 account, | ||||||
|  |                 request.Notification.Topic, | ||||||
|  |                 request.Notification.Title, | ||||||
|  |                 request.Notification.Subtitle, | ||||||
|  |                 request.Notification.Body, | ||||||
|  |                 GrpcTypeHelper.ConvertFromValueMap(request.Notification.Meta), | ||||||
|  |                 request.Notification.ActionUri, | ||||||
|  |                 request.Notification.IsSilent, | ||||||
|  |                 request.Notification.IsSavable | ||||||
|  |             ); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return new Empty(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public override async Task<Empty> SendPushNotificationToUser(SendPushNotificationToUserRequest request, | ||||||
|  |         ServerCallContext context) | ||||||
|  |     { | ||||||
|  |         // This is a placeholder implementation. In a real-world scenario, you would | ||||||
|  |         // need to retrieve the account from the database based on the user ID. | ||||||
|  |         var account = new Account(); | ||||||
|  |         await pushService.SendNotification( | ||||||
|  |             account, | ||||||
|  |             request.Notification.Topic, | ||||||
|  |             request.Notification.Title, | ||||||
|  |             request.Notification.Subtitle, | ||||||
|  |             request.Notification.Body, | ||||||
|  |             GrpcTypeHelper.ConvertFromValueMap(request.Notification.Meta), | ||||||
|  |             request.Notification.ActionUri, | ||||||
|  |             request.Notification.IsSilent, | ||||||
|  |             request.Notification.IsSavable | ||||||
|  |         ); | ||||||
|  |         return new Empty(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public override async Task<Empty> SendPushNotificationToUsers(SendPushNotificationToUsersRequest request, | ||||||
|  |         ServerCallContext context) | ||||||
|  |     { | ||||||
|  |         var notification = new Notification.Notification | ||||||
|  |         { | ||||||
|  |             Topic = request.Notification.Topic, | ||||||
|  |             Title = request.Notification.Title, | ||||||
|  |             Subtitle = request.Notification.Subtitle, Content = request.Notification.Body, | ||||||
|  |             Meta = GrpcTypeHelper.ConvertFromValueMap(request.Notification.Meta), | ||||||
|  |         }; | ||||||
|  |         if (request.Notification.ActionUri is not null) | ||||||
|  |             notification.Meta["action_uri"] = request.Notification.ActionUri; | ||||||
|  |         var accounts = request.UserIds.Select(Guid.Parse).ToList(); | ||||||
|  |         await pushService.SendNotificationBatch(notification, accounts, request.Notification.IsSavable); | ||||||
|  |         return new Empty(); | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										70
									
								
								DysonNetwork.Pusher/Startup/ApplicationConfiguration.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								DysonNetwork.Pusher/Startup/ApplicationConfiguration.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,70 @@ | |||||||
|  | using System.Net; | ||||||
|  | using DysonNetwork.Pusher.Services; | ||||||
|  | using Microsoft.AspNetCore.HttpOverrides; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Pusher.Startup; | ||||||
|  |  | ||||||
|  | public static class ApplicationConfiguration | ||||||
|  | { | ||||||
|  |     public static WebApplication ConfigureAppMiddleware(this WebApplication app, IConfiguration configuration) | ||||||
|  |     { | ||||||
|  |         app.MapOpenApi(); | ||||||
|  |  | ||||||
|  |         app.UseSwagger(); | ||||||
|  |         app.UseSwaggerUI(); | ||||||
|  |          | ||||||
|  |         app.UseRequestLocalization(); | ||||||
|  |  | ||||||
|  |         ConfigureForwardedHeaders(app, configuration); | ||||||
|  |  | ||||||
|  |         app.UseCors(opts => | ||||||
|  |             opts.SetIsOriginAllowed(_ => true) | ||||||
|  |                 .WithExposedHeaders("*") | ||||||
|  |                 .WithHeaders() | ||||||
|  |                 .AllowCredentials() | ||||||
|  |                 .AllowAnyHeader() | ||||||
|  |                 .AllowAnyMethod() | ||||||
|  |         ); | ||||||
|  |  | ||||||
|  |         app.UseWebSockets(); | ||||||
|  |         app.UseRateLimiter(); | ||||||
|  |         app.UseHttpsRedirection(); | ||||||
|  |         app.UseAuthentication(); | ||||||
|  |         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<string[]>(); | ||||||
|  |             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); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public static WebApplication ConfigureGrpcServices(this WebApplication app) | ||||||
|  |     { | ||||||
|  |         app.MapGrpcService<PusherServiceGrpc>(); | ||||||
|  |          | ||||||
|  |         return app; | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										17
									
								
								DysonNetwork.Pusher/Startup/KestrelConfiguration.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								DysonNetwork.Pusher/Startup/KestrelConfiguration.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | |||||||
|  | namespace DysonNetwork.Pass.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; | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										22
									
								
								DysonNetwork.Pusher/Startup/ScheduledJobsConfiguration.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								DysonNetwork.Pusher/Startup/ScheduledJobsConfiguration.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,22 @@ | |||||||
|  | using Quartz; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Pusher.Startup; | ||||||
|  |  | ||||||
|  | public static class ScheduledJobsConfiguration | ||||||
|  | { | ||||||
|  |     public static IServiceCollection AddAppScheduledJobs(this IServiceCollection services) | ||||||
|  |     { | ||||||
|  |         services.AddQuartz(q => | ||||||
|  |         { | ||||||
|  |             var appDatabaseRecyclingJob = new JobKey("AppDatabaseRecycling"); | ||||||
|  |             q.AddJob<AppDatabaseRecyclingJob>(opts => opts.WithIdentity(appDatabaseRecyclingJob)); | ||||||
|  |             q.AddTrigger(opts => opts | ||||||
|  |                 .ForJob(appDatabaseRecyclingJob) | ||||||
|  |                 .WithIdentity("AppDatabaseRecyclingTrigger") | ||||||
|  |                 .WithCronSchedule("0 0 0 * * ?")); | ||||||
|  |         }); | ||||||
|  |         services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true); | ||||||
|  |  | ||||||
|  |         return services; | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										152
									
								
								DysonNetwork.Pusher/Startup/ServiceCollectionExtensions.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										152
									
								
								DysonNetwork.Pusher/Startup/ServiceCollectionExtensions.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,152 @@ | |||||||
|  | using System.Text.Json; | ||||||
|  | using System.Threading.RateLimiting; | ||||||
|  | using dotnet_etcd.interfaces; | ||||||
|  | using DysonNetwork.Pusher.Email; | ||||||
|  | using DysonNetwork.Pusher.Notification; | ||||||
|  | using DysonNetwork.Pusher.Services; | ||||||
|  | using DysonNetwork.Shared.Cache; | ||||||
|  | using DysonNetwork.Shared.Proto; | ||||||
|  | using Microsoft.AspNetCore.RateLimiting; | ||||||
|  | using Microsoft.OpenApi.Models; | ||||||
|  | using NodaTime; | ||||||
|  | using NodaTime.Serialization.SystemTextJson; | ||||||
|  | using StackExchange.Redis; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Pusher.Startup; | ||||||
|  |  | ||||||
|  | public static class ServiceCollectionExtensions | ||||||
|  | { | ||||||
|  |     public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration) | ||||||
|  |     { | ||||||
|  |         services.AddDbContext<AppDatabase>(); | ||||||
|  |         services.AddSingleton<IConnectionMultiplexer>(_ => | ||||||
|  |         { | ||||||
|  |             var connection = configuration.GetConnectionString("FastRetrieve")!; | ||||||
|  |             return ConnectionMultiplexer.Connect(connection); | ||||||
|  |         }); | ||||||
|  |         services.AddSingleton<IClock>(SystemClock.Instance); | ||||||
|  |         services.AddHttpContextAccessor(); | ||||||
|  |         services.AddSingleton<ICacheService, CacheServiceRedis>(); | ||||||
|  |  | ||||||
|  |         services.AddHttpClient(); | ||||||
|  |  | ||||||
|  |         // Register gRPC services | ||||||
|  |         services.AddGrpc(options => | ||||||
|  |         { | ||||||
|  |             options.EnableDetailedErrors = true; // Will be adjusted in Program.cs | ||||||
|  |             options.MaxReceiveMessageSize = 16 * 1024 * 1024; // 16MB | ||||||
|  |             options.MaxSendMessageSize = 16 * 1024 * 1024; // 16MB | ||||||
|  |         }); | ||||||
|  |          | ||||||
|  |         // Register gRPC reflection for service discovery | ||||||
|  |         services.AddGrpc(); | ||||||
|  |  | ||||||
|  |         // Register gRPC services | ||||||
|  |         services.AddScoped<PusherServiceGrpc>(); | ||||||
|  |  | ||||||
|  |         // Register AuthService.AuthServiceClient for AuthMiddleware | ||||||
|  |         services.AddSingleton(sp => | ||||||
|  |         { | ||||||
|  |             var etcdClient = sp.GetRequiredService<IEtcdClient>(); | ||||||
|  |             var configuration = sp.GetRequiredService<IConfiguration>(); | ||||||
|  |             var clientCertPath = configuration["ClientCert:Path"]; | ||||||
|  |             var clientKeyPath = configuration["ClientKey:Path"]; | ||||||
|  |             var clientCertPassword = configuration["ClientCert:Password"]; | ||||||
|  |  | ||||||
|  |             return GrpcClientHelper.CreateAuthServiceClient(etcdClient, clientCertPath, clientKeyPath, clientCertPassword); | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         // Register OIDC services | ||||||
|  |         services.AddControllers().AddJsonOptions(options => | ||||||
|  |         { | ||||||
|  |             options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; | ||||||
|  |             options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower; | ||||||
|  |  | ||||||
|  |             options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb); | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         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(); | ||||||
|  |  | ||||||
|  |         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 AddAppFlushHandlers(this IServiceCollection services) | ||||||
|  |     { | ||||||
|  |         services.AddSingleton<FlushBufferService>(); | ||||||
|  |  | ||||||
|  |         return services; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public static IServiceCollection AddAppBusinessServices(this IServiceCollection services) | ||||||
|  |     { | ||||||
|  |         services.AddScoped<EmailService>(); | ||||||
|  |         services.AddScoped<PushService>(); | ||||||
|  |  | ||||||
|  |         return services; | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,56 @@ | |||||||
|  | using DysonNetwork.Shared.Registry; | ||||||
|  | using Microsoft.Extensions.Configuration; | ||||||
|  | using Microsoft.Extensions.Hosting; | ||||||
|  | using Microsoft.Extensions.Logging; | ||||||
|  | using System.Threading; | ||||||
|  | using System.Threading.Tasks; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Pusher.Startup; | ||||||
|  |  | ||||||
|  | public class ServiceRegistrationHostedService : IHostedService | ||||||
|  | { | ||||||
|  |     private readonly ServiceRegistry _serviceRegistry; | ||||||
|  |     private readonly IConfiguration _configuration; | ||||||
|  |     private readonly ILogger<ServiceRegistrationHostedService> _logger; | ||||||
|  |  | ||||||
|  |     public ServiceRegistrationHostedService( | ||||||
|  |         ServiceRegistry serviceRegistry, | ||||||
|  |         IConfiguration configuration, | ||||||
|  |         ILogger<ServiceRegistrationHostedService> logger) | ||||||
|  |     { | ||||||
|  |         _serviceRegistry = serviceRegistry; | ||||||
|  |         _configuration = configuration; | ||||||
|  |         _logger = logger; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async Task StartAsync(CancellationToken cancellationToken) | ||||||
|  |     { | ||||||
|  |         var serviceName = "DysonNetwork.Pusher"; // Preset service name | ||||||
|  |         var serviceUrl = _configuration["Service:Url"]; | ||||||
|  |  | ||||||
|  |         if (string.IsNullOrEmpty(serviceUrl)) | ||||||
|  |         { | ||||||
|  |             _logger.LogWarning("Service URL not configured. Skipping Etcd registration."); | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         _logger.LogInformation("Registering service {ServiceName} at {ServiceUrl} with Etcd.", serviceName, serviceUrl); | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             await _serviceRegistry.RegisterService(serviceName, serviceUrl); | ||||||
|  |             _logger.LogInformation("Service {ServiceName} registered successfully.", serviceName); | ||||||
|  |         } | ||||||
|  |         catch (Exception ex) | ||||||
|  |         { | ||||||
|  |             _logger.LogError(ex, "Failed to register service {ServiceName} with Etcd.", serviceName); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public Task StopAsync(CancellationToken cancellationToken) | ||||||
|  |     { | ||||||
|  |         // The lease will expire automatically if the service stops. | ||||||
|  |         // For explicit unregistration, you would implement it here. | ||||||
|  |         _logger.LogInformation("Service registration hosted service is stopping."); | ||||||
|  |         return Task.CompletedTask; | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										22
									
								
								DysonNetwork.Shared/Auth/AuthConstants.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								DysonNetwork.Shared/Auth/AuthConstants.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,22 @@ | |||||||
|  | namespace DysonNetwork.Shared.Auth; | ||||||
|  |  | ||||||
|  | public static class AuthConstants | ||||||
|  | { | ||||||
|  |     public const string SchemeName = "DysonToken"; | ||||||
|  |     public const string TokenQueryParamName = "tk"; | ||||||
|  |     public const string CookieTokenName = "AuthToken"; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | public enum TokenType | ||||||
|  | { | ||||||
|  |     AuthKey, | ||||||
|  |     ApiKey, | ||||||
|  |     OidcKey, | ||||||
|  |     Unknown | ||||||
|  | } | ||||||
|  |  | ||||||
|  | public class TokenInfo | ||||||
|  | { | ||||||
|  |     public string Token { get; set; } = string.Empty; | ||||||
|  |     public TokenType Type { get; set; } = TokenType.Unknown; | ||||||
|  | } | ||||||
| @@ -7,6 +7,7 @@ | |||||||
|     </PropertyGroup> |     </PropertyGroup> | ||||||
|  |  | ||||||
|     <ItemGroup> |     <ItemGroup> | ||||||
|  |         <PackageReference Include="dotnet-etcd" Version="8.0.1" /> | ||||||
|         <PackageReference Include="Google.Api.CommonProtos" Version="2.17.0"/> |         <PackageReference Include="Google.Api.CommonProtos" Version="2.17.0"/> | ||||||
|         <PackageReference Include="Google.Protobuf" Version="3.31.1" /> |         <PackageReference Include="Google.Protobuf" Version="3.31.1" /> | ||||||
|         <PackageReference Include="Google.Protobuf.Tools" Version="3.31.1" /> |         <PackageReference Include="Google.Protobuf.Tools" Version="3.31.1" /> | ||||||
| @@ -17,12 +18,16 @@ | |||||||
|             <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> |             <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> | ||||||
|         </PackageReference> |         </PackageReference> | ||||||
|         <PackageReference Include="MaxMind.GeoIP2" Version="5.3.0"/> |         <PackageReference Include="MaxMind.GeoIP2" Version="5.3.0"/> | ||||||
|  |         <PackageReference Include="Microsoft.AspNetCore.Http" Version="2.3.0" /> | ||||||
|  |         <PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.0" /> | ||||||
|         <PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.7"/> |         <PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.7"/> | ||||||
|         <PackageReference Include="NetTopologySuite" Version="2.6.0"/> |         <PackageReference Include="NetTopologySuite" Version="2.6.0"/> | ||||||
|         <PackageReference Include="Newtonsoft.Json" Version="13.0.3"/> |         <PackageReference Include="Newtonsoft.Json" Version="13.0.3"/> | ||||||
|  |         <PackageReference Include="NodaTime" Version="3.2.2" /> | ||||||
|         <PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0"/> |         <PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0"/> | ||||||
|         <PackageReference Include="NodaTime.Serialization.Protobuf" Version="2.0.2"/> |         <PackageReference Include="NodaTime.Serialization.Protobuf" Version="2.0.2"/> | ||||||
|         <PackageReference Include="StackExchange.Redis" Version="2.8.41"/> |         <PackageReference Include="StackExchange.Redis" Version="2.8.41"/> | ||||||
|  |         <PackageReference Include="System.Net.Http" Version="4.3.4" /> | ||||||
|     </ItemGroup> |     </ItemGroup> | ||||||
|  |  | ||||||
|     <ItemGroup> |     <ItemGroup> | ||||||
|   | |||||||
| @@ -3,7 +3,7 @@ using Microsoft.Extensions.Options; | |||||||
| using NetTopologySuite.Geometries; | using NetTopologySuite.Geometries; | ||||||
| using Point = NetTopologySuite.Geometries.Point; | using Point = NetTopologySuite.Geometries.Point; | ||||||
| 
 | 
 | ||||||
| namespace DysonNetwork.Shared.Geo; | namespace DysonNetwork.Shared.GeoIp; | ||||||
| 
 | 
 | ||||||
| public class GeoIpOptions | public class GeoIpOptions | ||||||
| { | { | ||||||
							
								
								
									
										107
									
								
								DysonNetwork.Shared/Middleware/AuthMiddleware.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										107
									
								
								DysonNetwork.Shared/Middleware/AuthMiddleware.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,107 @@ | |||||||
|  | using Grpc.Core; | ||||||
|  | using Microsoft.AspNetCore.Http; | ||||||
|  | using Microsoft.Extensions.Logging; | ||||||
|  | using DysonNetwork.Shared.Proto; | ||||||
|  | using System.Threading.Tasks; | ||||||
|  | using DysonNetwork.Shared.Auth; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Shared.Middleware; | ||||||
|  |  | ||||||
|  | public class AuthMiddleware | ||||||
|  | { | ||||||
|  |     private readonly RequestDelegate _next; | ||||||
|  |     private readonly ILogger<AuthMiddleware> _logger; | ||||||
|  |  | ||||||
|  |     public AuthMiddleware(RequestDelegate next, ILogger<AuthMiddleware> logger) | ||||||
|  |     { | ||||||
|  |         _next = next; | ||||||
|  |         _logger = logger; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async Task InvokeAsync(HttpContext context, AuthService.AuthServiceClient authServiceClient) | ||||||
|  |     { | ||||||
|  |         var tokenInfo = _ExtractToken(context.Request); | ||||||
|  |  | ||||||
|  |         if (tokenInfo == null || string.IsNullOrEmpty(tokenInfo.Token)) | ||||||
|  |         { | ||||||
|  |             await _next(context); | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             var authSession = await authServiceClient.AuthenticateAsync(new AuthenticateRequest { Token = tokenInfo.Token }); | ||||||
|  |             context.Items["AuthSession"] = authSession; | ||||||
|  |             context.Items["CurrentTokenType"] = tokenInfo.Type.ToString(); | ||||||
|  |             // Assuming AuthSession contains Account information or can be retrieved | ||||||
|  |             // context.Items["CurrentUser"] = authSession.Account; // You might need to fetch Account separately if not embedded | ||||||
|  |         } | ||||||
|  |         catch (RpcException ex) | ||||||
|  |         { | ||||||
|  |             _logger.LogWarning(ex, "Authentication failed for token: {Token}", tokenInfo.Token); | ||||||
|  |             // Optionally, you can return an unauthorized response here | ||||||
|  |             // context.Response.StatusCode = StatusCodes.Status401Unauthorized; | ||||||
|  |             // return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         await _next(context); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private TokenInfo? _ExtractToken(HttpRequest request) | ||||||
|  |     { | ||||||
|  |         // Check for token in query parameters | ||||||
|  |         if (request.Query.TryGetValue(AuthConstants.TokenQueryParamName, out var queryToken)) | ||||||
|  |         { | ||||||
|  |             return new TokenInfo | ||||||
|  |             { | ||||||
|  |                 Token = queryToken.ToString(), | ||||||
|  |                 Type = TokenType.AuthKey | ||||||
|  |             }; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Check for token in Authorization header | ||||||
|  |         var authHeader = request.Headers["Authorization"].ToString(); | ||||||
|  |         if (!string.IsNullOrEmpty(authHeader)) | ||||||
|  |         { | ||||||
|  |             if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) | ||||||
|  |             { | ||||||
|  |                 var token = authHeader["Bearer ".Length..].Trim(); | ||||||
|  |                 var parts = token.Split('.'); | ||||||
|  |                  | ||||||
|  |                 return new TokenInfo | ||||||
|  |                 { | ||||||
|  |                     Token = token, | ||||||
|  |                     Type = parts.Length == 3 ? TokenType.OidcKey : TokenType.AuthKey | ||||||
|  |                 }; | ||||||
|  |             } | ||||||
|  |             else if (authHeader.StartsWith("AtField ", StringComparison.OrdinalIgnoreCase)) | ||||||
|  |             { | ||||||
|  |                 return new TokenInfo | ||||||
|  |                 { | ||||||
|  |                     Token = authHeader["AtField ".Length..].Trim(), | ||||||
|  |                     Type = TokenType.AuthKey | ||||||
|  |                 }; | ||||||
|  |             } | ||||||
|  |             else if (authHeader.StartsWith("AkField ", StringComparison.OrdinalIgnoreCase)) | ||||||
|  |             { | ||||||
|  |                 return new TokenInfo | ||||||
|  |                 { | ||||||
|  |                     Token = authHeader["AkField ".Length..].Trim(), | ||||||
|  |                     Type = TokenType.ApiKey | ||||||
|  |                 }; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Check for token in cookies | ||||||
|  |         if (request.Cookies.TryGetValue(AuthConstants.CookieTokenName, out var cookieToken)) | ||||||
|  |         { | ||||||
|  |             return new TokenInfo | ||||||
|  |             { | ||||||
|  |                 Token = cookieToken, | ||||||
|  |                 Type = cookieToken.Count(c => c == '.') == 2 ? TokenType.OidcKey : TokenType.AuthKey | ||||||
|  |             }; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return null; | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										104
									
								
								DysonNetwork.Shared/Proto/GrpcClientHelper.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										104
									
								
								DysonNetwork.Shared/Proto/GrpcClientHelper.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,104 @@ | |||||||
|  | using Grpc.Net.Client; | ||||||
|  | using System.Security.Cryptography.X509Certificates; | ||||||
|  | using Grpc.Core; | ||||||
|  | using dotnet_etcd.interfaces; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Shared.Proto; | ||||||
|  |  | ||||||
|  | public static class GrpcClientHelper | ||||||
|  | { | ||||||
|  |     private static CallInvoker CreateCallInvoker( | ||||||
|  |         string url, | ||||||
|  |         string clientCertPath, | ||||||
|  |         string clientKeyPath, | ||||||
|  |         string? clientCertPassword = null | ||||||
|  |     ) | ||||||
|  |     { | ||||||
|  |         var handler = new HttpClientHandler(); | ||||||
|  |         handler.ClientCertificates.Add( | ||||||
|  |             clientCertPassword is null ? | ||||||
|  |             X509Certificate2.CreateFromPemFile(clientCertPath, clientKeyPath) : | ||||||
|  |             X509Certificate2.CreateFromEncryptedPemFile(clientCertPath, clientCertPassword, clientKeyPath) | ||||||
|  |         ); | ||||||
|  |         return GrpcChannel.ForAddress(url, new GrpcChannelOptions { HttpHandler = handler }).CreateCallInvoker(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static async Task<string> GetServiceUrlFromEtcd(IEtcdClient etcdClient, string serviceName) | ||||||
|  |     { | ||||||
|  |         var response = await etcdClient.GetAsync($"/services/{serviceName}"); | ||||||
|  |         if (response.Kvs.Count == 0) | ||||||
|  |         { | ||||||
|  |             throw new InvalidOperationException($"Service '{serviceName}' not found in Etcd."); | ||||||
|  |         } | ||||||
|  |         return response.Kvs[0].Value.ToStringUtf8(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public static AccountService.AccountServiceClient CreateAccountServiceClient( | ||||||
|  |         string url, | ||||||
|  |         string clientCertPath, | ||||||
|  |         string clientKeyPath, | ||||||
|  |         string? clientCertPassword = null | ||||||
|  |     ) | ||||||
|  |     { | ||||||
|  |         return new AccountService.AccountServiceClient(CreateCallInvoker(url, clientCertPath, clientKeyPath, | ||||||
|  |             clientCertPassword)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public static async Task<AccountService.AccountServiceClient> CreateAccountServiceClient( | ||||||
|  |         IEtcdClient etcdClient, | ||||||
|  |         string clientCertPath, | ||||||
|  |         string clientKeyPath, | ||||||
|  |         string? clientCertPassword = null | ||||||
|  |     ) | ||||||
|  |     { | ||||||
|  |         var url = await GetServiceUrlFromEtcd(etcdClient, "AccountService"); | ||||||
|  |         return new AccountService.AccountServiceClient(CreateCallInvoker(url, clientCertPath, clientKeyPath, | ||||||
|  |             clientCertPassword)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public static AuthService.AuthServiceClient CreateAuthServiceClient( | ||||||
|  |         string url, | ||||||
|  |         string clientCertPath, | ||||||
|  |         string clientKeyPath, | ||||||
|  |         string? clientCertPassword = null | ||||||
|  |     ) | ||||||
|  |     { | ||||||
|  |         return new AuthService.AuthServiceClient(CreateCallInvoker(url, clientCertPath, clientKeyPath, | ||||||
|  |             clientCertPassword)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public static async Task<AuthService.AuthServiceClient> CreateAuthServiceClient( | ||||||
|  |         IEtcdClient etcdClient, | ||||||
|  |         string clientCertPath, | ||||||
|  |         string clientKeyPath, | ||||||
|  |         string? clientCertPassword = null | ||||||
|  |     ) | ||||||
|  |     { | ||||||
|  |         var url = await GetServiceUrlFromEtcd(etcdClient, "AuthService"); | ||||||
|  |         return new AuthService.AuthServiceClient(CreateCallInvoker(url, clientCertPath, clientKeyPath, | ||||||
|  |             clientCertPassword)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public static PusherService.PusherServiceClient CreatePusherServiceClient( | ||||||
|  |         string url, | ||||||
|  |         string clientCertPath, | ||||||
|  |         string clientKeyPath, | ||||||
|  |         string? clientCertPassword = null | ||||||
|  |     ) | ||||||
|  |     { | ||||||
|  |         return new PusherService.PusherServiceClient(CreateCallInvoker(url, clientCertPath, clientKeyPath, | ||||||
|  |             clientCertPassword)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public static async Task<PusherService.PusherServiceClient> CreatePusherServiceClient( | ||||||
|  |         IEtcdClient etcdClient, | ||||||
|  |         string clientCertPath, | ||||||
|  |         string clientKeyPath, | ||||||
|  |         string? clientCertPassword = null | ||||||
|  |     ) | ||||||
|  |     { | ||||||
|  |         var url = await GetServiceUrlFromEtcd(etcdClient, "PusherService"); | ||||||
|  |         return new PusherService.PusherServiceClient(CreateCallInvoker(url, clientCertPath, clientKeyPath, | ||||||
|  |             clientCertPassword)); | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										89
									
								
								DysonNetwork.Shared/Proto/GrpcTypeHelper.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								DysonNetwork.Shared/Proto/GrpcTypeHelper.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -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<string, Value> ConvertToValueMap(Dictionary<string, object> source) | ||||||
|  |     { | ||||||
|  |         var result = new MapField<string, Value>(); | ||||||
|  |         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<string, object?> ConvertFromValueMap(MapField<string, Value> source) | ||||||
|  |     { | ||||||
|  |         var result = new Dictionary<string, object?>(); | ||||||
|  |         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)) | ||||||
|  |         }; | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -8,6 +8,7 @@ import "google/protobuf/timestamp.proto"; | |||||||
| import "google/protobuf/wrappers.proto"; | import "google/protobuf/wrappers.proto"; | ||||||
| import "google/protobuf/empty.proto"; | import "google/protobuf/empty.proto"; | ||||||
| import "google/protobuf/field_mask.proto"; | import "google/protobuf/field_mask.proto"; | ||||||
|  | import "google/protobuf/struct.proto"; | ||||||
|  |  | ||||||
| import 'file.proto'; | import 'file.proto'; | ||||||
|  |  | ||||||
| @@ -83,7 +84,7 @@ message AccountAuthFactor { | |||||||
|   string id = 1; |   string id = 1; | ||||||
|   AccountAuthFactorType type = 2; |   AccountAuthFactorType type = 2; | ||||||
|   google.protobuf.StringValue secret = 3;  // Omitted from JSON serialization in original |   google.protobuf.StringValue secret = 3;  // Omitted from JSON serialization in original | ||||||
|   map<string, string> config = 4;  // Omitted from JSON serialization in original |   map<string, google.protobuf.Value> config = 4;  // Omitted from JSON serialization in original | ||||||
|   int32 trustworthy = 5; |   int32 trustworthy = 5; | ||||||
|   google.protobuf.Timestamp enabled_at = 6; |   google.protobuf.Timestamp enabled_at = 6; | ||||||
|   google.protobuf.Timestamp expired_at = 7; |   google.protobuf.Timestamp expired_at = 7; | ||||||
| @@ -107,7 +108,7 @@ message AccountBadge { | |||||||
|   string type = 2;  // Type/category of the badge |   string type = 2;  // Type/category of the badge | ||||||
|   google.protobuf.StringValue label = 3;  // Display name of the badge |   google.protobuf.StringValue label = 3;  // Display name of the badge | ||||||
|   google.protobuf.StringValue caption = 4;  // Optional description of the badge |   google.protobuf.StringValue caption = 4;  // Optional description of the badge | ||||||
|   map<string, string> meta = 5;  // Additional metadata for the badge |   map<string, google.protobuf.Value> meta = 5;  // Additional metadata for the badge | ||||||
|   google.protobuf.Timestamp activated_at = 6;  // When the badge was activated |   google.protobuf.Timestamp activated_at = 6;  // When the badge was activated | ||||||
|   google.protobuf.Timestamp expired_at = 7;  // Optional expiration time |   google.protobuf.Timestamp expired_at = 7;  // Optional expiration time | ||||||
|   string account_id = 8;  // ID of the account this badge belongs to |   string account_id = 8;  // ID of the account this badge belongs to | ||||||
| @@ -118,7 +119,7 @@ message AccountConnection { | |||||||
|   string id = 1; |   string id = 1; | ||||||
|   string provider = 2; |   string provider = 2; | ||||||
|   string provided_identifier = 3; |   string provided_identifier = 3; | ||||||
|   map<string, string> meta = 4; |   map<string, google.protobuf.Value> meta = 4; | ||||||
|   google.protobuf.StringValue access_token = 5;  // Omitted from JSON serialization |   google.protobuf.StringValue access_token = 5;  // Omitted from JSON serialization | ||||||
|   google.protobuf.StringValue refresh_token = 6;  // Omitted from JSON serialization |   google.protobuf.StringValue refresh_token = 6;  // Omitted from JSON serialization | ||||||
|   google.protobuf.Timestamp last_used_at = 7; |   google.protobuf.Timestamp last_used_at = 7; | ||||||
| @@ -127,19 +128,30 @@ message AccountConnection { | |||||||
|  |  | ||||||
| // VerificationMark represents verification status | // VerificationMark represents verification status | ||||||
| message VerificationMark { | message VerificationMark { | ||||||
|   bool verified = 1; |   VerificationMarkType type = 1; | ||||||
|   string method = 2; |   string title = 2; | ||||||
|   google.protobuf.Timestamp verified_at = 3; |   string description = 3; | ||||||
|   string verified_by = 4; |   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 | // BadgeReferenceObject represents a reference to a badge with minimal information | ||||||
| message BadgeReferenceObject { | message BadgeReferenceObject { | ||||||
|   string id = 1;  // Unique identifier for the badge |   string id = 1;  // Unique identifier for the badge | ||||||
|   string type = 2;  // Type/category of the badge |   string type = 2;  // Type/category of the badge | ||||||
|   google.protobuf.StringValue label = 3;  // Display name of the badge |   google.protobuf.StringValue label = 3;  // Display name of the badge | ||||||
|   google.protobuf.StringValue caption = 4;  // Optional description of the badge |   google.protobuf.StringValue caption = 4;  // Optional description of the badge | ||||||
|   map<string, string> meta = 5;  // Additional metadata for the badge |   map<string, google.protobuf.Value> meta = 5;  // Additional metadata for the badge | ||||||
|   google.protobuf.Timestamp activated_at = 6;  // When the badge was activated |   google.protobuf.Timestamp activated_at = 6;  // When the badge was activated | ||||||
|   google.protobuf.Timestamp expired_at = 7;  // Optional expiration time |   google.protobuf.Timestamp expired_at = 7;  // Optional expiration time | ||||||
|   string account_id = 8;  // ID of the account this badge belongs to |   string account_id = 8;  // ID of the account this badge belongs to | ||||||
|   | |||||||
							
								
								
									
										68
									
								
								DysonNetwork.Shared/Proto/auth.proto
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								DysonNetwork.Shared/Proto/auth.proto
									
									
									
									
									
										Normal file
									
								
							| @@ -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; | ||||||
|  | } | ||||||
							
								
								
									
										110
									
								
								DysonNetwork.Shared/Proto/pusher.proto
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										110
									
								
								DysonNetwork.Shared/Proto/pusher.proto
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,110 @@ | |||||||
|  | syntax = "proto3"; | ||||||
|  |  | ||||||
|  | package proto; | ||||||
|  |  | ||||||
|  | option csharp_namespace = "DysonNetwork.Shared.Proto"; | ||||||
|  |  | ||||||
|  | import "google/protobuf/struct.proto"; | ||||||
|  | import "google/protobuf/empty.proto"; | ||||||
|  | import "google/protobuf/wrappers.proto"; | ||||||
|  |  | ||||||
|  | // PusherService provides methods to send various types of notifications. | ||||||
|  | service PusherService { | ||||||
|  |   // Sends an email. | ||||||
|  |   rpc SendEmail(SendEmailRequest) returns (google.protobuf.Empty) {} | ||||||
|  |  | ||||||
|  |   // Pushes a packet to a user via WebSocket. | ||||||
|  |   rpc PushWebSocketPacket(PushWebSocketPacketRequest) returns (google.protobuf.Empty) {} | ||||||
|  |  | ||||||
|  |   // Pushes a packet to a list of users via WebSocket. | ||||||
|  |   rpc PushWebSocketPacketToUsers(PushWebSocketPacketToUsersRequest) returns (google.protobuf.Empty) {} | ||||||
|  |  | ||||||
|  |   // Pushes a packet to a device via WebSocket. | ||||||
|  |   rpc PushWebSocketPacketToDevice(PushWebSocketPacketToDeviceRequest) returns (google.protobuf.Empty) {} | ||||||
|  |  | ||||||
|  |   // Pushes a packet to a list of devices via WebSocket. | ||||||
|  |   rpc PushWebSocketPacketToDevices(PushWebSocketPacketToDevicesRequest) returns (google.protobuf.Empty) {} | ||||||
|  |  | ||||||
|  |   // Sends a push notification to a device. | ||||||
|  |   rpc SendPushNotification(SendPushNotificationRequest) returns (google.protobuf.Empty) {} | ||||||
|  |  | ||||||
|  |   // Sends a push notification to a list of devices. | ||||||
|  |   rpc SendPushNotificationToDevices(SendPushNotificationToDevicesRequest) returns (google.protobuf.Empty) {} | ||||||
|  |  | ||||||
|  |   // Sends a push notification to a user. | ||||||
|  |   rpc SendPushNotificationToUser(SendPushNotificationToUserRequest) returns (google.protobuf.Empty) {} | ||||||
|  |  | ||||||
|  |   // Sends a push notification to a list of users. | ||||||
|  |   rpc SendPushNotificationToUsers(SendPushNotificationToUsersRequest) returns (google.protobuf.Empty) {} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Represents an email message. | ||||||
|  | message EmailMessage { | ||||||
|  |   string to_name = 1; | ||||||
|  |   string to_address = 2; | ||||||
|  |   string subject = 3; | ||||||
|  |   string body = 4; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | message SendEmailRequest { | ||||||
|  |   EmailMessage email = 1; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Represents a WebSocket packet. | ||||||
|  | message WebSocketPacket { | ||||||
|  |     string type = 1; | ||||||
|  |     google.protobuf.Value data = 2; | ||||||
|  |     google.protobuf.StringValue error_message = 3; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | message PushWebSocketPacketRequest { | ||||||
|  |   string user_id = 1; | ||||||
|  |   WebSocketPacket packet = 2; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | message PushWebSocketPacketToUsersRequest { | ||||||
|  |   repeated string user_ids = 1; | ||||||
|  |   WebSocketPacket packet = 2; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | message PushWebSocketPacketToDeviceRequest { | ||||||
|  |   string device_id = 1; | ||||||
|  |   WebSocketPacket packet = 2; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | message PushWebSocketPacketToDevicesRequest { | ||||||
|  |   repeated string device_ids = 1; | ||||||
|  |   WebSocketPacket packet = 2; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Represents a push notification. | ||||||
|  | message PushNotification { | ||||||
|  |     string topic = 1; | ||||||
|  |     string title = 2; | ||||||
|  |     string subtitle = 3; | ||||||
|  |     string body = 4; | ||||||
|  |     map<string, google.protobuf.Value> meta = 5; | ||||||
|  |     optional string action_uri = 6; | ||||||
|  |     bool is_silent = 7; | ||||||
|  |     bool is_savable = 8; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | message SendPushNotificationRequest { | ||||||
|  |     string device_id = 1; | ||||||
|  |     PushNotification notification = 2; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | message SendPushNotificationToDevicesRequest { | ||||||
|  |     repeated string device_ids = 1; | ||||||
|  |     PushNotification notification = 2; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | message SendPushNotificationToUserRequest { | ||||||
|  |     string user_id = 1; | ||||||
|  |     PushNotification notification = 2; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | message SendPushNotificationToUsersRequest { | ||||||
|  |     repeated string user_ids = 1; | ||||||
|  |     PushNotification notification = 2; | ||||||
|  | } | ||||||
							
								
								
									
										28
									
								
								DysonNetwork.Shared/Registry/ServiceRegistry.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								DysonNetwork.Shared/Registry/ServiceRegistry.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | |||||||
|  | using System.Text; | ||||||
|  | using dotnet_etcd.interfaces; | ||||||
|  | using Etcdserverpb; | ||||||
|  | using Google.Protobuf; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Shared.Registry; | ||||||
|  |  | ||||||
|  | public class ServiceRegistry(IEtcdClient etcd) | ||||||
|  | { | ||||||
|  |     public async Task RegisterService(string serviceName, string serviceUrl, long leaseTtlSeconds = 60) | ||||||
|  |     { | ||||||
|  |         var key = $"/services/{serviceName}"; | ||||||
|  |         var leaseResponse = await etcd.LeaseGrantAsync(new LeaseGrantRequest { TTL = leaseTtlSeconds }); | ||||||
|  |         await etcd.PutAsync(new PutRequest | ||||||
|  |         { | ||||||
|  |             Key = ByteString.CopyFrom(key, Encoding.UTF8), | ||||||
|  |             Value = ByteString.CopyFrom(serviceUrl, Encoding.UTF8), | ||||||
|  |             Lease = leaseResponse.ID | ||||||
|  |         }); | ||||||
|  |         await etcd.LeaseKeepAlive(leaseResponse.ID, CancellationToken.None); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public async Task UnregisterService(string serviceName) | ||||||
|  |     { | ||||||
|  |         var key = $"/services/{serviceName}"; | ||||||
|  |         await etcd.DeleteAsync(key); | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										23
									
								
								DysonNetwork.Shared/Registry/Startup.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								DysonNetwork.Shared/Registry/Startup.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | |||||||
|  | using dotnet_etcd.DependencyInjection; | ||||||
|  | using Microsoft.Extensions.Configuration; | ||||||
|  | using Microsoft.Extensions.DependencyInjection; | ||||||
|  |  | ||||||
|  | namespace DysonNetwork.Shared.Registry; | ||||||
|  |  | ||||||
|  | public static class EtcdStartup | ||||||
|  | { | ||||||
|  |     public static IServiceCollection AddEtcdService( | ||||||
|  |         this IServiceCollection services, | ||||||
|  |         IConfiguration configuration | ||||||
|  |     ) | ||||||
|  |     { | ||||||
|  |         services.AddEtcdClient(options => | ||||||
|  |         { | ||||||
|  |             options.ConnectionString = configuration.GetConnectionString("Etcd"); | ||||||
|  |             options.UseInsecureChannel = configuration.GetValue<bool>("Etcd:Insecure"); | ||||||
|  |         }); | ||||||
|  |         services.AddSingleton<ServiceRegistry>(); | ||||||
|  |  | ||||||
|  |         return services; | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -26,7 +26,7 @@ | |||||||
|         <PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" /> |         <PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" /> | ||||||
|         <PackageReference Include="HtmlAgilityPack" Version="1.12.1" /> |         <PackageReference Include="HtmlAgilityPack" Version="1.12.1" /> | ||||||
|         <PackageReference Include="Livekit.Server.Sdk.Dotnet" Version="1.0.8" /> |         <PackageReference Include="Livekit.Server.Sdk.Dotnet" Version="1.0.8" /> | ||||||
|         <PackageReference Include="MailKit" Version="4.11.0" /> |         <PackageReference Include="MailKit" Version="4.13.0" /> | ||||||
|         <PackageReference Include="Markdig" Version="0.41.3" /> |         <PackageReference Include="Markdig" Version="0.41.3" /> | ||||||
|         <PackageReference Include="MaxMind.GeoIP2" Version="5.3.0" /> |         <PackageReference Include="MaxMind.GeoIP2" Version="5.3.0" /> | ||||||
|         <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" /> |         <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" /> | ||||||
|   | |||||||
| @@ -10,7 +10,8 @@ | |||||||
|   "AllowedHosts": "*", |   "AllowedHosts": "*", | ||||||
|   "ConnectionStrings": { |   "ConnectionStrings": { | ||||||
|     "App": "Host=localhost;Port=5432;Database=dyson_network;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60", |     "App": "Host=localhost;Port=5432;Database=dyson_network;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60", | ||||||
|     "FastRetrieve": "localhost:6379" |     "FastRetrieve": "localhost:6379", | ||||||
|  |     "Etcd": "localhost:2379" | ||||||
|   }, |   }, | ||||||
|   "Authentication": { |   "Authentication": { | ||||||
|     "Schemes": { |     "Schemes": { | ||||||
| @@ -125,5 +126,8 @@ | |||||||
|   "KnownProxies": [ |   "KnownProxies": [ | ||||||
|     "127.0.0.1", |     "127.0.0.1", | ||||||
|     "::1" |     "::1" | ||||||
|   ] |   ], | ||||||
|  |   "Etcd": { | ||||||
|  |     "Insecure": true | ||||||
|  |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,5 +1,6 @@ | |||||||
| <wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation"> | <wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation"> | ||||||
| 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAccessToken_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003Fb370f448e9f5fca62da785172d83a214319335e27ac4d51840349c6dce15d68_003FAccessToken_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAccessToken_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003Fb370f448e9f5fca62da785172d83a214319335e27ac4d51840349c6dce15d68_003FAccessToken_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | ||||||
|  | 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAny_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F331aca3f6f414013b09964063341351379060_003F67_003F87f868e3_003FAny_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | ||||||
| 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AApnSender_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F6aadc2cf048f477d8636fb2def7b73648200_003Fc5_003F2a1973a9_003FApnSender_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AApnSender_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F6aadc2cf048f477d8636fb2def7b73648200_003Fc5_003F2a1973a9_003FApnSender_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | ||||||
| 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AApnSettings_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F6aadc2cf048f477d8636fb2def7b73648200_003F0f_003F51443844_003FApnSettings_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AApnSettings_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F6aadc2cf048f477d8636fb2def7b73648200_003F0f_003F51443844_003FApnSettings_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | ||||||
| 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAuthenticationMiddleware_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fe49de78932194d52a02b07486c6d023a24600_003F2f_003F7ab1cc57_003FAuthenticationMiddleware_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAuthenticationMiddleware_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fe49de78932194d52a02b07486c6d023a24600_003F2f_003F7ab1cc57_003FAuthenticationMiddleware_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | ||||||
| @@ -9,6 +10,8 @@ | |||||||
| 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ABodyBuilder_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003Fc5c8aba04a29d49c65d772c9ffcd93ac7eb38ccbb49a5f506518a0b9bdcaa75_003FBodyBuilder_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ABodyBuilder_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003Fc5c8aba04a29d49c65d772c9ffcd93ac7eb38ccbb49a5f506518a0b9bdcaa75_003FBodyBuilder_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | ||||||
| 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ABucketArgs_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fd515fb889657fcdcace3fed90735057b458ff9e0bb60bded7c8fe8b3a4673c_003FBucketArgs_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ABucketArgs_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fd515fb889657fcdcace3fed90735057b458ff9e0bb60bded7c8fe8b3a4673c_003FBucketArgs_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | ||||||
| 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ABucketArgs_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003Fd515fb889657fcdcace3fed90735057b458ff9e0bb60bded7c8fe8b3a4673c_003FBucketArgs_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ABucketArgs_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003Fd515fb889657fcdcace3fed90735057b458ff9e0bb60bded7c8fe8b3a4673c_003FBucketArgs_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | ||||||
|  | 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AByteString_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F331aca3f6f414013b09964063341351379060_003F2e_003F1935b2a7_003FByteString_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | ||||||
|  | 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACallInvoker_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003F85d5cf7bc00d9afe6109255f942125d252e7d3bf3d33f44c445162ab59e52b_003FCallInvoker_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | ||||||
| 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AChapterData_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ffef366b36a224d469ff150d30f9a866d23c00_003Fe6_003F64a6c0f7_003FChapterData_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AChapterData_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ffef366b36a224d469ff150d30f9a866d23c00_003Fe6_003F64a6c0f7_003FChapterData_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | ||||||
| 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AClaim_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fa7fdc52b6e574ae7b9822133be91162a15800_003Ff7_003Feebffd8d_003FClaim_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AClaim_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fa7fdc52b6e574ae7b9822133be91162a15800_003Ff7_003Feebffd8d_003FClaim_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | ||||||
| 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AConnectionMultiplexer_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003F2ed0e2f073b1d77b98dadb822da09ee8a9dfb91bf29bf2bbaecb8750d7e74cc9_003FConnectionMultiplexer_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AConnectionMultiplexer_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003F2ed0e2f073b1d77b98dadb822da09ee8a9dfb91bf29bf2bbaecb8750d7e74cc9_003FConnectionMultiplexer_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | ||||||
| @@ -38,11 +41,14 @@ | |||||||
| 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExifTag_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fef3339e864a448e2b1ec6fa7bbf4c6661fee00_003F5c_003F8ed75f18_003FExifTag_002Ecs_002Fz_003A2_002D1/@EntryIndexedValue">ForceIncluded</s:String> | 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExifTag_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fef3339e864a448e2b1ec6fa7bbf4c6661fee00_003F5c_003F8ed75f18_003FExifTag_002Ecs_002Fz_003A2_002D1/@EntryIndexedValue">ForceIncluded</s:String> | ||||||
| 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileResult_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F0b5acdd962e549369896cece0026e556214600_003F8c_003F9f6e3f4f_003FFileResult_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileResult_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F0b5acdd962e549369896cece0026e556214600_003F8c_003F9f6e3f4f_003FFileResult_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | ||||||
| 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AForwardedHeaders_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fcfe5737f9bb84738979cbfedd11822a8ea00_003F50_003F9a335f87_003FForwardedHeaders_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AForwardedHeaders_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fcfe5737f9bb84738979cbfedd11822a8ea00_003F50_003F9a335f87_003FForwardedHeaders_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | ||||||
|  | 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHttpRequestHeaders_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb904f9896c4049fabd596decf1be9c381dc400_003F32_003F906beb77_003FHttpRequestHeaders_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | ||||||
| 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHttpUtility_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F95cd5fa21c574d4087dec626d8227d77be00_003F08_003Fdd41228e_003FHttpUtility_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHttpUtility_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F95cd5fa21c574d4087dec626d8227d77be00_003F08_003Fdd41228e_003FHttpUtility_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | ||||||
| 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIConfiguration_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fbb55221b2bd14b31a20b0d8bdcc7ff457328_003F19_003F707d23be_003FIConfiguration_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIConfiguration_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fbb55221b2bd14b31a20b0d8bdcc7ff457328_003F19_003F707d23be_003FIConfiguration_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | ||||||
|  | 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIEtcdClient_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F866376757aa64634b820c41d3553727886400_003Fbb_003F0fd3f8d7_003FIEtcdClient_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | ||||||
| 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIHtmlString_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F95cd5fa21c574d4087dec626d8227d77be00_003Ff1_003F3a8957fa_003FIHtmlString_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIHtmlString_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F95cd5fa21c574d4087dec626d8227d77be00_003Ff1_003F3a8957fa_003FIHtmlString_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | ||||||
| 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AImageFile_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fa932cb9090ed48088111ae919dcdd9021ba00_003F71_003F0a804432_003FImageFile_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AImageFile_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fa932cb9090ed48088111ae919dcdd9021ba00_003F71_003F0a804432_003FImageFile_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | ||||||
| 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AImage_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fdaa8d9c408cd4b4286bbef7e35f1a42e31c00_003F9f_003Fc5bde8be_003FImage_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AImage_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fdaa8d9c408cd4b4286bbef7e35f1a42e31c00_003F9f_003Fc5bde8be_003FImage_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | ||||||
|  | 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIMessage_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F331aca3f6f414013b09964063341351379060_003Ff1_003F9fbeae46_003FIMessage_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | ||||||
| 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIndexAttribute_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fe38f14ac86274ebb9b366729231d1c1a8838_003F8b_003F2890293d_003FIndexAttribute_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIndexAttribute_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fe38f14ac86274ebb9b366729231d1c1a8838_003F8b_003F2890293d_003FIndexAttribute_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | ||||||
| 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIntentType_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F8bb08a178b5b43c5bac20a5a54159a5b2a800_003Fbf_003Ffcb84131_003FIntentType_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIntentType_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F8bb08a178b5b43c5bac20a5a54159a5b2a800_003Fbf_003Ffcb84131_003FIntentType_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | ||||||
| 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AInternalServerError_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F01d30b32e2ff422cb80129ca2a441c4242600_003Fb5_003Fc55acdd2_003FInternalServerError_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | 	<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AInternalServerError_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F01d30b32e2ff422cb80129ca2a441c4242600_003Fb5_003Fc55acdd2_003FInternalServerError_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user