✨ Pusher service basis
This commit is contained in:
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
@ -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
|
||||||
}
|
}
|
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
|
||||||
|
{
|
||||||
|
public 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();
|
||||||
|
}
|
||||||
|
}
|
@ -4,6 +4,7 @@ using System.Text.Json.Serialization;
|
|||||||
using DysonNetwork.Pass;
|
using DysonNetwork.Pass;
|
||||||
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;
|
||||||
@ -21,6 +22,18 @@ public class AuthSession : ModelBase
|
|||||||
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 +80,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()
|
||||||
|
};
|
||||||
}
|
}
|
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,19 @@
|
|||||||
</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="Swashbuckle.AspNetCore" Version="9.0.3" />
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.3" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@ -18,4 +29,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!;
|
||||||
|
}
|
||||||
|
|
258
DysonNetwork.Pusher/Notification/PushService.cs
Normal file
258
DysonNetwork.Pusher/Notification/PushService.cs
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using DysonNetwork.Shared.Proto;
|
||||||
|
using EFCore.BulkExtensions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pusher.Notification;
|
||||||
|
|
||||||
|
public class PushService(IConfiguration config, AppDatabase db, IHttpClientFactory httpFactory)
|
||||||
|
{
|
||||||
|
private readonly string _notifyTopic = config["Notifications:Topic"]!;
|
||||||
|
private readonly Uri _notifyEndpoint = new(config["Notifications:Endpoint"]!);
|
||||||
|
|
||||||
|
public async Task UnsubscribePushNotifications(string deviceId)
|
||||||
|
{
|
||||||
|
await db.PushSubscriptions
|
||||||
|
.Where(s => s.DeviceId == deviceId)
|
||||||
|
.ExecuteDeleteAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<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<Pusher.Notification.Notification> 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);
|
||||||
|
|
||||||
|
return notification;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeliveryNotification(Pusher.Notification.Notification notification)
|
||||||
|
{
|
||||||
|
// Pushing the notification
|
||||||
|
var subscribers = await db.PushSubscriptions
|
||||||
|
.Where(s => s.AccountId == notification.AccountId)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
await _PushNotification(notification, subscribers);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task MarkNotificationsViewed(ICollection<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<Account> accounts, bool save = false)
|
||||||
|
{
|
||||||
|
if (save)
|
||||||
|
{
|
||||||
|
var notifications = accounts.Select(x =>
|
||||||
|
{
|
||||||
|
var newNotification = new Notification
|
||||||
|
{
|
||||||
|
Topic = notification.Topic,
|
||||||
|
Title = notification.Title,
|
||||||
|
Subtitle = notification.Subtitle,
|
||||||
|
Content = notification.Content,
|
||||||
|
Meta = notification.Meta,
|
||||||
|
Priority = notification.Priority,
|
||||||
|
Account = x,
|
||||||
|
AccountId = Guid.Parse(x.Id)
|
||||||
|
};
|
||||||
|
return newNotification;
|
||||||
|
}).ToList();
|
||||||
|
await db.BulkInsertAsync(notifications);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var account in accounts)
|
||||||
|
{
|
||||||
|
notification.Account = account;
|
||||||
|
notification.AccountId = Guid.Parse(account.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
var accountsId = accounts.Select(x => Guid.Parse(x.Id)).ToList();
|
||||||
|
var subscribers = await db.PushSubscriptions
|
||||||
|
.Where(s => accountsId.Contains(s.AccountId))
|
||||||
|
.ToListAsync();
|
||||||
|
await _PushNotification(notification, subscribers);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<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; }
|
||||||
|
}
|
@ -20,6 +20,7 @@
|
|||||||
<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"/>
|
||||||
|
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;
|
||||||
|
}
|
@ -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" />
|
||||||
|
@ -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>
|
||||||
@ -43,6 +44,7 @@
|
|||||||
<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