✨ Pusher service basis
This commit is contained in:
@ -4,6 +4,7 @@ using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using NodaTime.Serialization.Protobuf;
|
||||
using OtpNet;
|
||||
|
||||
namespace DysonNetwork.Pass.Account;
|
||||
@ -29,6 +30,30 @@ public class Account : ModelBase
|
||||
|
||||
[JsonIgnore] public ICollection<Relationship> OutgoingRelationships { 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
|
||||
@ -88,6 +113,36 @@ public class AccountProfile : ModelBase
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
[JsonIgnore] public Account Account { get; set; } = null!;
|
||||
|
||||
public Shared.Proto.AccountProfile ToProtoValue()
|
||||
{
|
||||
var proto = new Shared.Proto.AccountProfile
|
||||
{
|
||||
Id = Id.ToString(),
|
||||
FirstName = FirstName ?? string.Empty,
|
||||
MiddleName = MiddleName ?? string.Empty,
|
||||
LastName = LastName ?? string.Empty,
|
||||
Bio = Bio ?? string.Empty,
|
||||
Gender = Gender ?? string.Empty,
|
||||
Pronouns = Pronouns ?? string.Empty,
|
||||
TimeZone = TimeZone ?? string.Empty,
|
||||
Location = Location ?? string.Empty,
|
||||
Birthday = Birthday?.ToTimestamp(),
|
||||
LastSeenAt = LastSeenAt?.ToTimestamp(),
|
||||
Experience = Experience,
|
||||
Level = Level,
|
||||
LevelingProgress = LevelingProgress,
|
||||
PictureId = PictureId ?? string.Empty,
|
||||
BackgroundId = BackgroundId ?? string.Empty,
|
||||
Picture = Picture?.ToProtoValue(),
|
||||
Background = Background?.ToProtoValue(),
|
||||
AccountId = AccountId.ToString(),
|
||||
Verification = Verification?.ToProtoValue(),
|
||||
ActiveBadge = ActiveBadge?.ToProtoValue()
|
||||
};
|
||||
|
||||
return proto;
|
||||
}
|
||||
}
|
||||
|
||||
public class AccountContact : ModelBase
|
||||
@ -100,6 +155,27 @@ public class AccountContact : ModelBase
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
[JsonIgnore] public Account Account { get; set; } = null!;
|
||||
|
||||
public Shared.Proto.AccountContact ToProtoValue()
|
||||
{
|
||||
var proto = new Shared.Proto.AccountContact
|
||||
{
|
||||
Id = Id.ToString(),
|
||||
Type = Type switch
|
||||
{
|
||||
AccountContactType.Email => Shared.Proto.AccountContactType.Email,
|
||||
AccountContactType.PhoneNumber => Shared.Proto.AccountContactType.PhoneNumber,
|
||||
AccountContactType.Address => Shared.Proto.AccountContactType.Address,
|
||||
_ => Shared.Proto.AccountContactType.Unspecified
|
||||
},
|
||||
Content = Content,
|
||||
IsPrimary = IsPrimary,
|
||||
VerifiedAt = VerifiedAt?.ToTimestamp(),
|
||||
AccountId = AccountId.ToString()
|
||||
};
|
||||
|
||||
return proto;
|
||||
}
|
||||
}
|
||||
|
||||
public enum AccountContactType
|
||||
|
@ -20,71 +20,6 @@ public class AccountServiceGrpc(
|
||||
private readonly ILogger<AccountServiceGrpc>
|
||||
_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)
|
||||
{
|
||||
if (!Guid.TryParse(request.Id, out var accountId))
|
||||
@ -98,7 +33,7 @@ public class AccountServiceGrpc(
|
||||
if (account == null)
|
||||
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, $"Account {request.Id} not found"));
|
||||
|
||||
return ToProtoAccount(account);
|
||||
return account.ToProtoValue();
|
||||
}
|
||||
|
||||
public override async Task<Shared.Proto.Account> CreateAccount(CreateAccountRequest request,
|
||||
@ -125,7 +60,7 @@ public class AccountServiceGrpc(
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Created new account with ID {AccountId}", account.Id);
|
||||
return ToProtoAccount(account);
|
||||
return account.ToProtoValue();
|
||||
}
|
||||
|
||||
public override async Task<Shared.Proto.Account> UpdateAccount(UpdateAccountRequest request,
|
||||
@ -145,7 +80,7 @@ public class AccountServiceGrpc(
|
||||
if (request.IsSuperuser != null) account.IsSuperuser = request.IsSuperuser.Value;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return ToProtoAccount(account);
|
||||
return account.ToProtoValue();
|
||||
}
|
||||
|
||||
public override async Task<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;
|
||||
}
|
||||
|
||||
@ -223,7 +158,7 @@ public class AccountServiceGrpc(
|
||||
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound,
|
||||
$"Profile for account {request.AccountId} not found"));
|
||||
|
||||
return ToProtoProfile(profile);
|
||||
return profile.ToProtoValue();
|
||||
}
|
||||
|
||||
public override async Task<Shared.Proto.AccountProfile> UpdateProfile(UpdateProfileRequest request,
|
||||
@ -249,7 +184,7 @@ public class AccountServiceGrpc(
|
||||
// Update other fields similarly...
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return ToProtoProfile(profile);
|
||||
return profile.ToProtoValue();
|
||||
}
|
||||
|
||||
// Contact operations
|
||||
@ -271,10 +206,65 @@ public class AccountServiceGrpc(
|
||||
_db.AccountContacts.Add(contact);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return ToProtoContact(contact);
|
||||
return contact.ToProtoValue();
|
||||
}
|
||||
|
||||
// Implement other contact operations...
|
||||
public override async Task<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
|
||||
public override async Task<Shared.Proto.AccountBadge> AddBadge(AddBadgeRequest request, ServerCallContext context)
|
||||
@ -296,8 +286,59 @@ public class AccountServiceGrpc(
|
||||
_db.Badges.Add(badge);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return ToProtoBadge(badge);
|
||||
return badge.ToProtoValue();
|
||||
}
|
||||
|
||||
// Implement other badge operations...
|
||||
public override async Task<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.Text.Json.Serialization;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using NodaTime;
|
||||
using NodaTime.Serialization.Protobuf;
|
||||
|
||||
namespace DysonNetwork.Pass.Account;
|
||||
|
||||
@ -33,6 +36,23 @@ public class AccountBadge : ModelBase
|
||||
AccountId = AccountId
|
||||
};
|
||||
}
|
||||
|
||||
public Shared.Proto.AccountBadge ToProtoValue()
|
||||
{
|
||||
var proto = new Shared.Proto.AccountBadge
|
||||
{
|
||||
Id = Id.ToString(),
|
||||
Type = Type,
|
||||
Label = Label ?? string.Empty,
|
||||
Caption = Caption ?? string.Empty,
|
||||
ActivatedAt = ActivatedAt?.ToTimestamp(),
|
||||
ExpiredAt = ExpiredAt?.ToTimestamp(),
|
||||
AccountId = AccountId.ToString(),
|
||||
};
|
||||
proto.Meta.Add(GrpcTypeHelper.ConvertToValueMap(Meta));
|
||||
|
||||
return proto;
|
||||
}
|
||||
}
|
||||
|
||||
public class BadgeReferenceObject : ModelBase
|
||||
@ -45,4 +65,22 @@ public class BadgeReferenceObject : ModelBase
|
||||
public Instant? ActivatedAt { get; set; }
|
||||
public Instant? ExpiredAt { get; set; }
|
||||
public Guid AccountId { get; set; }
|
||||
|
||||
public Shared.Proto.BadgeReferenceObject ToProtoValue()
|
||||
{
|
||||
var proto = new Shared.Proto.BadgeReferenceObject
|
||||
{
|
||||
Id = Id.ToString(),
|
||||
Type = Type,
|
||||
Label = Label ?? string.Empty,
|
||||
Caption = Caption ?? string.Empty,
|
||||
ActivatedAt = ActivatedAt?.ToTimestamp(),
|
||||
ExpiredAt = ExpiredAt?.ToTimestamp(),
|
||||
AccountId = AccountId.ToString()
|
||||
};
|
||||
if (Meta is not null)
|
||||
proto.Meta.Add(GrpcTypeHelper.ConvertToValueMap(Meta!));
|
||||
|
||||
return proto;
|
||||
}
|
||||
}
|
@ -13,6 +13,29 @@ public class VerificationMark
|
||||
[MaxLength(1024)] public string? Title { get; set; }
|
||||
[MaxLength(8192)] public string? Description { get; set; }
|
||||
[MaxLength(1024)] public string? VerifiedBy { get; set; }
|
||||
|
||||
public Shared.Proto.VerificationMark ToProtoValue()
|
||||
{
|
||||
var proto = new Shared.Proto.VerificationMark
|
||||
{
|
||||
Type = Type switch
|
||||
{
|
||||
VerificationMarkType.Official => Shared.Proto.VerificationMarkType.Official,
|
||||
VerificationMarkType.Individual => Shared.Proto.VerificationMarkType.Individual,
|
||||
VerificationMarkType.Organization => Shared.Proto.VerificationMarkType.Organization,
|
||||
VerificationMarkType.Government => Shared.Proto.VerificationMarkType.Government,
|
||||
VerificationMarkType.Creator => Shared.Proto.VerificationMarkType.Creator,
|
||||
VerificationMarkType.Developer => Shared.Proto.VerificationMarkType.Developer,
|
||||
VerificationMarkType.Parody => Shared.Proto.VerificationMarkType.Parody,
|
||||
_ => Shared.Proto.VerificationMarkType.Unspecified
|
||||
},
|
||||
Title = Title ?? string.Empty,
|
||||
Description = Description ?? string.Empty,
|
||||
VerifiedBy = VerifiedBy ?? string.Empty
|
||||
};
|
||||
|
||||
return proto;
|
||||
}
|
||||
}
|
||||
|
||||
public enum VerificationMarkType
|
||||
@ -21,5 +44,7 @@ public enum VerificationMarkType
|
||||
Individual,
|
||||
Organization,
|
||||
Government,
|
||||
Creator
|
||||
Creator,
|
||||
Developer,
|
||||
Parody
|
||||
}
|
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.Shared.Data;
|
||||
using NodaTime;
|
||||
using NodaTime.Serialization.Protobuf;
|
||||
using Point = NetTopologySuite.Geometries.Point;
|
||||
|
||||
namespace DysonNetwork.Pass.Auth;
|
||||
@ -21,6 +22,18 @@ public class AuthSession : ModelBase
|
||||
public AuthChallenge Challenge { get; set; } = null!;
|
||||
public Guid? AppId { get; set; }
|
||||
// public CustomApp? App { get; set; }
|
||||
|
||||
public Shared.Proto.AuthSession ToProtoValue() => new()
|
||||
{
|
||||
Id = Id.ToString(),
|
||||
Label = Label,
|
||||
LastGrantedAt = LastGrantedAt?.ToTimestamp(),
|
||||
ExpiredAt = ExpiredAt?.ToTimestamp(),
|
||||
AccountId = AccountId.ToString(),
|
||||
ChallengeId = ChallengeId.ToString(),
|
||||
Challenge = Challenge.ToProtoValue(),
|
||||
AppId = AppId?.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
public enum ChallengeType
|
||||
@ -67,4 +80,23 @@ public class AuthChallenge : ModelBase
|
||||
if (StepRemain == 0 && BlacklistFactors.Count == 0) StepRemain = StepTotal;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Shared.Proto.AuthChallenge ToProtoValue() => new()
|
||||
{
|
||||
Id = Id.ToString(),
|
||||
ExpiredAt = ExpiredAt?.ToTimestamp(),
|
||||
StepRemain = StepRemain,
|
||||
StepTotal = StepTotal,
|
||||
FailedAttempts = FailedAttempts,
|
||||
Platform = (Shared.Proto.ChallengePlatform)Platform,
|
||||
Type = (Shared.Proto.ChallengeType)Type,
|
||||
BlacklistFactors = { BlacklistFactors.Select(x => x.ToString()) },
|
||||
Audiences = { Audiences },
|
||||
Scopes = { Scopes },
|
||||
IpAddress = IpAddress,
|
||||
UserAgent = UserAgent,
|
||||
DeviceId = DeviceId,
|
||||
Nonce = Nonce,
|
||||
AccountId = AccountId.ToString()
|
||||
};
|
||||
}
|
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 DysonNetwork.Shared.Proto;
|
||||
|
||||
namespace DysonNetwork.Pusher.Connection;
|
||||
|
||||
public interface IWebSocketPacketHandler
|
||||
{
|
||||
string PacketType { get; }
|
||||
Task HandleAsync(Account currentUser, string deviceId, WebSocketPacket packet, WebSocket socket, WebSocketService srv);
|
||||
|
||||
Task HandleAsync(
|
||||
Account currentUser,
|
||||
string deviceId,
|
||||
WebSocketPacket packet,
|
||||
WebSocket socket,
|
||||
WebSocketService srv
|
||||
);
|
||||
}
|
@ -1,8 +1,7 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.WebSockets;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Internal;
|
||||
using Swashbuckle.AspNetCore.Annotations;
|
||||
|
||||
namespace DysonNetwork.Pusher.Connection;
|
||||
@ -18,15 +17,15 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext>
|
||||
{
|
||||
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
|
||||
HttpContext.Items.TryGetValue("CurrentSession", out var currentSessionValue);
|
||||
if (currentUserValue is not Account.Account currentUser ||
|
||||
currentSessionValue is not Auth.Session currentSession)
|
||||
if (currentUserValue is not Account currentUser ||
|
||||
currentSessionValue is not AuthSession currentSession)
|
||||
{
|
||||
HttpContext.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
return;
|
||||
}
|
||||
|
||||
var accountId = currentUser.Id;
|
||||
var deviceId = currentSession.Challenge.DeviceId;
|
||||
var accountId = currentUser.Id!;
|
||||
var deviceId = currentSession.Challenge.DeviceId!;
|
||||
|
||||
if (string.IsNullOrEmpty(deviceId))
|
||||
{
|
||||
@ -69,7 +68,7 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext>
|
||||
|
||||
private async Task _ConnectionEventLoop(
|
||||
string deviceId,
|
||||
Account.Account currentUser,
|
||||
Account currentUser,
|
||||
WebSocket webSocket,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
|
@ -2,7 +2,9 @@ using System.Text.Json;
|
||||
using NodaTime;
|
||||
using NodaTime.Serialization.SystemTextJson;
|
||||
|
||||
public class WebSocketPacketType
|
||||
namespace DysonNetwork.Pusher.Connection;
|
||||
|
||||
public abstract class WebSocketPacketType
|
||||
{
|
||||
public const string Error = "error";
|
||||
public const string MessageNew = "messages.new";
|
||||
|
@ -1,5 +1,6 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.WebSockets;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
|
||||
namespace DysonNetwork.Pusher.Connection;
|
||||
|
||||
@ -13,7 +14,7 @@ public class WebSocketService
|
||||
}
|
||||
|
||||
private static readonly ConcurrentDictionary<
|
||||
(Guid AccountId, string DeviceId),
|
||||
(string AccountId, string DeviceId),
|
||||
(WebSocket Socket, CancellationTokenSource Cts)
|
||||
> ActiveConnections = new();
|
||||
|
||||
@ -29,21 +30,23 @@ public class WebSocketService
|
||||
ActiveSubscriptions.TryRemove(deviceId, out _);
|
||||
}
|
||||
|
||||
public bool IsUserSubscribedToChatRoom(Guid accountId, string chatRoomId)
|
||||
public bool IsUserSubscribedToChatRoom(string accountId, string chatRoomId)
|
||||
{
|
||||
var userDeviceIds = ActiveConnections.Keys.Where(k => k.AccountId == accountId).Select(k => k.DeviceId);
|
||||
foreach (var deviceId in userDeviceIds)
|
||||
{
|
||||
if (ActiveSubscriptions.TryGetValue(deviceId, out var subscribedChatRoomId) && subscribedChatRoomId == chatRoomId)
|
||||
if (ActiveSubscriptions.TryGetValue(deviceId, out var subscribedChatRoomId) &&
|
||||
subscribedChatRoomId == chatRoomId)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryAdd(
|
||||
(Guid AccountId, string DeviceId) key,
|
||||
(string AccountId, string DeviceId) key,
|
||||
WebSocket socket,
|
||||
CancellationTokenSource cts
|
||||
)
|
||||
@ -54,7 +57,7 @@ public class WebSocketService
|
||||
return ActiveConnections.TryAdd(key, (socket, cts));
|
||||
}
|
||||
|
||||
public void Disconnect((Guid AccountId, string DeviceId) key, string? reason = null)
|
||||
public void Disconnect((string AccountId, string DeviceId) key, string? reason = null)
|
||||
{
|
||||
if (!ActiveConnections.TryGetValue(key, out var data)) return;
|
||||
data.Socket.CloseAsync(
|
||||
@ -67,12 +70,12 @@ public class WebSocketService
|
||||
UnsubscribeFromChatRoom(key.DeviceId);
|
||||
}
|
||||
|
||||
public bool GetAccountIsConnected(Guid accountId)
|
||||
public bool GetAccountIsConnected(string accountId)
|
||||
{
|
||||
return ActiveConnections.Any(c => c.Key.AccountId == accountId);
|
||||
}
|
||||
|
||||
public void SendPacketToAccount(Guid userId, WebSocketPacket packet)
|
||||
public void SendPacketToAccount(string userId, WebSocketPacket packet)
|
||||
{
|
||||
var connections = ActiveConnections.Where(c => c.Key.AccountId == userId);
|
||||
var packetBytes = packet.ToBytes();
|
||||
@ -106,8 +109,12 @@ public class WebSocketService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task HandlePacket(Account.Account currentUser, string deviceId, WebSocketPacket packet,
|
||||
WebSocket socket)
|
||||
public async Task HandlePacket(
|
||||
Account currentUser,
|
||||
string deviceId,
|
||||
WebSocketPacket packet,
|
||||
WebSocket socket
|
||||
)
|
||||
{
|
||||
if (_handlerMap.TryGetValue(packet.Type, out var handler))
|
||||
{
|
||||
|
@ -8,8 +8,19 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<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="MailKit" Version="4.13.0" />
|
||||
<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>
|
||||
@ -18,4 +29,8 @@
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</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="NetTopologySuite" Version="2.6.0"/>
|
||||
<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.Protobuf" Version="2.0.2"/>
|
||||
<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/empty.proto";
|
||||
import "google/protobuf/field_mask.proto";
|
||||
import "google/protobuf/struct.proto";
|
||||
|
||||
import 'file.proto';
|
||||
|
||||
@ -83,7 +84,7 @@ message AccountAuthFactor {
|
||||
string id = 1;
|
||||
AccountAuthFactorType type = 2;
|
||||
google.protobuf.StringValue secret = 3; // Omitted from JSON serialization in original
|
||||
map<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;
|
||||
google.protobuf.Timestamp enabled_at = 6;
|
||||
google.protobuf.Timestamp expired_at = 7;
|
||||
@ -107,7 +108,7 @@ message AccountBadge {
|
||||
string type = 2; // Type/category of the badge
|
||||
google.protobuf.StringValue label = 3; // Display name of the badge
|
||||
google.protobuf.StringValue caption = 4; // Optional description of the badge
|
||||
map<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 expired_at = 7; // Optional expiration time
|
||||
string account_id = 8; // ID of the account this badge belongs to
|
||||
@ -118,7 +119,7 @@ message AccountConnection {
|
||||
string id = 1;
|
||||
string provider = 2;
|
||||
string provided_identifier = 3;
|
||||
map<string, string> meta = 4;
|
||||
map<string, google.protobuf.Value> meta = 4;
|
||||
google.protobuf.StringValue access_token = 5; // Omitted from JSON serialization
|
||||
google.protobuf.StringValue refresh_token = 6; // Omitted from JSON serialization
|
||||
google.protobuf.Timestamp last_used_at = 7;
|
||||
@ -127,19 +128,30 @@ message AccountConnection {
|
||||
|
||||
// VerificationMark represents verification status
|
||||
message VerificationMark {
|
||||
bool verified = 1;
|
||||
string method = 2;
|
||||
google.protobuf.Timestamp verified_at = 3;
|
||||
VerificationMarkType type = 1;
|
||||
string title = 2;
|
||||
string description = 3;
|
||||
string verified_by = 4;
|
||||
}
|
||||
|
||||
enum VerificationMarkType {
|
||||
VERIFICATION_MARK_TYPE_UNSPECIFIED = 0;
|
||||
OFFICIAL = 1;
|
||||
INDIVIDUAL = 2;
|
||||
ORGANIZATION = 3;
|
||||
GOVERNMENT = 4;
|
||||
CREATOR = 5;
|
||||
DEVELOPER = 6;
|
||||
PARODY = 7;
|
||||
}
|
||||
|
||||
// BadgeReferenceObject represents a reference to a badge with minimal information
|
||||
message BadgeReferenceObject {
|
||||
string id = 1; // Unique identifier for the badge
|
||||
string type = 2; // Type/category of the badge
|
||||
google.protobuf.StringValue label = 3; // Display name of the badge
|
||||
google.protobuf.StringValue caption = 4; // Optional description of the badge
|
||||
map<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 expired_at = 7; // Optional expiration time
|
||||
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="HtmlAgilityPack" Version="1.12.1" />
|
||||
<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="MaxMind.GeoIP2" Version="5.3.0" />
|
||||
<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">
|
||||
<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_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>
|
||||
@ -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_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_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_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>
|
||||
|
Reference in New Issue
Block a user