Compare commits
3 Commits
33f56c4ef5
...
e66abe2e0c
| Author | SHA1 | Date | |
|---|---|---|---|
| e66abe2e0c | |||
| 4a7f2e18b3 | |||
| e1b47bc7d1 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
bin/
|
||||
obj/
|
||||
/packages/
|
||||
/Certificates/
|
||||
riderModule.iml
|
||||
/_ReSharper.Caches/
|
||||
.idea
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,72 +19,7 @@ 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();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using DysonNetwork.Shared.Geo;
|
||||
using DysonNetwork.Shared.GeoIp;
|
||||
|
||||
namespace DysonNetwork.Pass.Account;
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System.Linq.Expressions;
|
||||
using System.Reflection;
|
||||
using DysonNetwork.Pass.Account;
|
||||
using DysonNetwork.Pass.Auth;
|
||||
using DysonNetwork.Pass.Developer;
|
||||
using DysonNetwork.Pass.Permission;
|
||||
using DysonNetwork.Pass.Wallet;
|
||||
using DysonNetwork.Shared.Data;
|
||||
@@ -46,6 +47,9 @@ public class AppDatabase(
|
||||
public DbSet<Transaction> PaymentTransactions { get; set; }
|
||||
public DbSet<Subscription> WalletSubscriptions { get; set; }
|
||||
public DbSet<Coupon> WalletCoupons { get; set; }
|
||||
|
||||
public DbSet<CustomApp> CustomApps { get; set; }
|
||||
public DbSet<CustomAppSecret> CustomAppSecrets { get; set; }
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
|
||||
@@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using NodaTime;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DysonNetwork.Pass.Account;
|
||||
using DysonNetwork.Shared.Geo;
|
||||
using DysonNetwork.Shared.GeoIp;
|
||||
|
||||
namespace DysonNetwork.Pass.Auth;
|
||||
|
||||
|
||||
27
DysonNetwork.Pass/Auth/AuthServiceGrpc.cs
Normal file
27
DysonNetwork.Pass/Auth/AuthServiceGrpc.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Grpc.Core;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DysonNetwork.Pass.Auth;
|
||||
|
||||
public class AuthServiceGrpc(AuthService authService, AppDatabase db) : Shared.Proto.AuthService.AuthServiceBase
|
||||
{
|
||||
public override async Task<Shared.Proto.AuthSession> Authenticate(AuthenticateRequest request, ServerCallContext context)
|
||||
{
|
||||
if (!authService.ValidateToken(request.Token, out var sessionId))
|
||||
{
|
||||
throw new RpcException(new Status(StatusCode.Unauthenticated, "Invalid token."));
|
||||
}
|
||||
|
||||
var session = await db.AuthSessions
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.Id == sessionId);
|
||||
|
||||
if (session == null)
|
||||
{
|
||||
throw new RpcException(new Status(StatusCode.NotFound, "Session not found."));
|
||||
}
|
||||
|
||||
return session.ToProtoValue();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using System.Text;
|
||||
using DysonNetwork.Pass.Auth.OidcProvider.Models;
|
||||
using DysonNetwork.Pass.Auth.OidcProvider.Options;
|
||||
using DysonNetwork.Pass.Auth.OidcProvider.Responses;
|
||||
using DysonNetwork.Pass.Developer;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
@@ -2,8 +2,10 @@ using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Pass;
|
||||
using DysonNetwork.Pass.Developer;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using NodaTime;
|
||||
using NodaTime.Serialization.Protobuf;
|
||||
using Point = NetTopologySuite.Geometries.Point;
|
||||
|
||||
namespace DysonNetwork.Pass.Auth;
|
||||
@@ -20,7 +22,19 @@ public class AuthSession : ModelBase
|
||||
public Guid ChallengeId { get; set; }
|
||||
public AuthChallenge Challenge { get; set; } = null!;
|
||||
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
|
||||
@@ -67,4 +81,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()
|
||||
};
|
||||
}
|
||||
68
DysonNetwork.Pass/Developer/CustomApp.cs
Normal file
68
DysonNetwork.Pass/Developer/CustomApp.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Pass.Account;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Pass.Developer;
|
||||
|
||||
public enum CustomAppStatus
|
||||
{
|
||||
Developing,
|
||||
Staging,
|
||||
Production,
|
||||
Suspended
|
||||
}
|
||||
|
||||
public class CustomApp : ModelBase, IIdentifiedResource
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
[MaxLength(1024)] public string Slug { get; set; } = null!;
|
||||
[MaxLength(1024)] public string Name { get; set; } = null!;
|
||||
[MaxLength(4096)] public string? Description { get; set; }
|
||||
public CustomAppStatus Status { get; set; } = CustomAppStatus.Developing;
|
||||
|
||||
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; }
|
||||
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; }
|
||||
|
||||
[Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; }
|
||||
[Column(TypeName = "jsonb")] public CustomAppOauthConfig? OauthConfig { get; set; }
|
||||
[Column(TypeName = "jsonb")] public CustomAppLinks? Links { get; set; }
|
||||
|
||||
[JsonIgnore] public ICollection<CustomAppSecret> Secrets { get; set; } = new List<CustomAppSecret>();
|
||||
|
||||
// TODO: Publisher
|
||||
|
||||
[NotMapped] public string ResourceIdentifier => "custom-app/" + Id;
|
||||
}
|
||||
|
||||
public class CustomAppLinks
|
||||
{
|
||||
[MaxLength(8192)] public string? HomePage { get; set; }
|
||||
[MaxLength(8192)] public string? PrivacyPolicy { get; set; }
|
||||
[MaxLength(8192)] public string? TermsOfService { get; set; }
|
||||
}
|
||||
|
||||
public class CustomAppOauthConfig
|
||||
{
|
||||
[MaxLength(1024)] public string? ClientUri { get; set; }
|
||||
[MaxLength(4096)] public string[] RedirectUris { get; set; } = [];
|
||||
[MaxLength(4096)] public string[]? PostLogoutRedirectUris { get; set; }
|
||||
[MaxLength(256)] public string[]? AllowedScopes { get; set; } = ["openid", "profile", "email"];
|
||||
[MaxLength(256)] public string[] AllowedGrantTypes { get; set; } = ["authorization_code", "refresh_token"];
|
||||
public bool RequirePkce { get; set; } = true;
|
||||
public bool AllowOfflineAccess { get; set; } = false;
|
||||
}
|
||||
|
||||
public class CustomAppSecret : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
[MaxLength(1024)] public string Secret { get; set; } = null!;
|
||||
[MaxLength(4096)] public string? Description { get; set; } = null!;
|
||||
public Instant? ExpiredAt { get; set; }
|
||||
public bool IsOidc { get; set; } = false; // Indicates if this secret is for OIDC/OAuth
|
||||
|
||||
public Guid AppId { get; set; }
|
||||
public CustomApp App { get; set; } = null!;
|
||||
}
|
||||
@@ -1,92 +1,56 @@
|
||||
using MailKit.Net.Smtp;
|
||||
using dotnet_etcd;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using MimeKit;
|
||||
|
||||
namespace DysonNetwork.Pass.Email;
|
||||
|
||||
public class EmailServiceConfiguration
|
||||
{
|
||||
public string Server { get; set; } = null!;
|
||||
public int Port { get; set; }
|
||||
public bool UseSsl { get; set; }
|
||||
public string Username { get; set; } = null!;
|
||||
public string Password { get; set; } = null!;
|
||||
public string FromAddress { get; set; } = null!;
|
||||
public string FromName { get; set; } = null!;
|
||||
public string SubjectPrefix { get; set; } = null!;
|
||||
}
|
||||
|
||||
public class EmailService
|
||||
{
|
||||
private readonly EmailServiceConfiguration _configuration;
|
||||
private readonly PusherService.PusherServiceClient _client;
|
||||
private readonly RazorViewRenderer _viewRenderer;
|
||||
private readonly ILogger<EmailService> _logger;
|
||||
|
||||
public EmailService(IConfiguration configuration, RazorViewRenderer viewRenderer, ILogger<EmailService> logger)
|
||||
public EmailService(
|
||||
EtcdClient etcd,
|
||||
RazorViewRenderer viewRenderer,
|
||||
IConfiguration configuration,
|
||||
ILogger<EmailService> logger,
|
||||
PusherService.PusherServiceClient client
|
||||
)
|
||||
{
|
||||
var cfg = configuration.GetSection("Email").Get<EmailServiceConfiguration>();
|
||||
_configuration = cfg ?? throw new ArgumentException("Email service was not configured.");
|
||||
_client = GrpcClientHelper.CreatePusherServiceClient(
|
||||
etcd,
|
||||
configuration["Service:CertPath"]!,
|
||||
configuration["Service:KeyPath"]!
|
||||
).GetAwaiter().GetResult();
|
||||
_viewRenderer = viewRenderer;
|
||||
_logger = logger;
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public async Task SendEmailAsync(string? recipientName, string recipientEmail, string subject, string textBody)
|
||||
|
||||
public async Task SendEmailAsync(
|
||||
string? recipientName,
|
||||
string recipientEmail,
|
||||
string subject,
|
||||
string htmlBody
|
||||
)
|
||||
{
|
||||
await SendEmailAsync(recipientName, recipientEmail, subject, textBody, null);
|
||||
subject = $"[Solarpass] {subject}";
|
||||
|
||||
await _client.SendEmailAsync(
|
||||
new SendEmailRequest()
|
||||
{
|
||||
Email = new EmailMessage()
|
||||
{
|
||||
ToName = recipientName,
|
||||
ToAddress = recipientEmail,
|
||||
Subject = subject,
|
||||
Body = htmlBody
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public async Task SendEmailAsync(string? recipientName, string recipientEmail, string subject, string textBody,
|
||||
string? htmlBody)
|
||||
{
|
||||
subject = $"[{_configuration.SubjectPrefix}] {subject}";
|
||||
|
||||
var emailMessage = new MimeMessage();
|
||||
emailMessage.From.Add(new MailboxAddress(_configuration.FromName, _configuration.FromAddress));
|
||||
emailMessage.To.Add(new MailboxAddress(recipientName, recipientEmail));
|
||||
emailMessage.Subject = subject;
|
||||
|
||||
var bodyBuilder = new BodyBuilder
|
||||
{
|
||||
TextBody = textBody
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(htmlBody))
|
||||
bodyBuilder.HtmlBody = htmlBody;
|
||||
|
||||
emailMessage.Body = bodyBuilder.ToMessageBody();
|
||||
|
||||
using var client = new SmtpClient();
|
||||
await client.ConnectAsync(_configuration.Server, _configuration.Port, _configuration.UseSsl);
|
||||
await client.AuthenticateAsync(_configuration.Username, _configuration.Password);
|
||||
await client.SendAsync(emailMessage);
|
||||
await client.DisconnectAsync(true);
|
||||
}
|
||||
|
||||
private static string _ConvertHtmlToPlainText(string html)
|
||||
{
|
||||
// Remove style tags and their contents
|
||||
html = System.Text.RegularExpressions.Regex.Replace(html, "<style[^>]*>.*?</style>", "",
|
||||
System.Text.RegularExpressions.RegexOptions.Singleline);
|
||||
|
||||
// Replace header tags with text + newlines
|
||||
html = System.Text.RegularExpressions.Regex.Replace(html, "<h[1-6][^>]*>(.*?)</h[1-6]>", "$1\n\n",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
|
||||
// Replace line breaks
|
||||
html = html.Replace("<br>", "\n").Replace("<br/>", "\n").Replace("<br />", "\n");
|
||||
|
||||
// Remove all remaining HTML tags
|
||||
html = System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", "");
|
||||
|
||||
// Decode HTML entities
|
||||
html = System.Net.WebUtility.HtmlDecode(html);
|
||||
|
||||
// Remove excess whitespace
|
||||
html = System.Text.RegularExpressions.Regex.Replace(html, @"\s+", " ").Trim();
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
public async Task SendTemplatedEmailAsync<TComponent, TModel>(string? recipientName, string recipientEmail,
|
||||
string subject, TModel model)
|
||||
where TComponent : IComponent
|
||||
@@ -94,8 +58,7 @@ public class EmailService
|
||||
try
|
||||
{
|
||||
var htmlBody = await _viewRenderer.RenderComponentToStringAsync<TComponent, TModel>(model);
|
||||
var fallbackTextBody = _ConvertHtmlToPlainText(htmlBody);
|
||||
await SendEmailAsync(recipientName, recipientEmail, subject, fallbackTextBody, htmlBody);
|
||||
await SendEmailAsync(recipientName, recipientEmail, subject, htmlBody);
|
||||
}
|
||||
catch (Exception err)
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using DysonNetwork.Pass;
|
||||
using DysonNetwork.Pass.Account;
|
||||
using DysonNetwork.Pass.Startup;
|
||||
using DysonNetwork.Shared.Registry;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
@@ -12,6 +13,7 @@ builder.ConfigureAppKestrel();
|
||||
builder.Services.AddAppMetrics();
|
||||
|
||||
// Add application services
|
||||
builder.Services.AddEtcdService(builder.Configuration);
|
||||
builder.Services.AddAppServices(builder.Configuration);
|
||||
builder.Services.AddAppRateLimiting();
|
||||
builder.Services.AddAppAuthentication();
|
||||
@@ -26,6 +28,8 @@ builder.Services.AddAppBusinessServices(builder.Configuration);
|
||||
// Add scheduled jobs
|
||||
builder.Services.AddAppScheduledJobs();
|
||||
|
||||
builder.Services.AddHostedService<ServiceRegistrationHostedService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Run database migrations
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Net;
|
||||
using DysonNetwork.Pass.Account;
|
||||
using DysonNetwork.Pass.Auth;
|
||||
using DysonNetwork.Pass.Permission;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Prometheus;
|
||||
@@ -68,6 +69,7 @@ public static class ApplicationConfiguration
|
||||
public static WebApplication ConfigureGrpcServices(this WebApplication app)
|
||||
{
|
||||
app.MapGrpcService<AccountServiceGrpc>();
|
||||
app.MapGrpcService<AuthServiceGrpc>();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ using DysonNetwork.Pass.Auth.OidcProvider.Services;
|
||||
using DysonNetwork.Pass.Handlers;
|
||||
using DysonNetwork.Pass.Wallet.PaymentHandlers;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using DysonNetwork.Shared.Geo;
|
||||
using DysonNetwork.Shared.GeoIp;
|
||||
|
||||
namespace DysonNetwork.Pass.Startup;
|
||||
|
||||
@@ -53,6 +53,7 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
// Register gRPC services
|
||||
services.AddScoped<AccountServiceGrpc>();
|
||||
services.AddScoped<AuthServiceGrpc>();
|
||||
|
||||
// Register OIDC services
|
||||
services.AddScoped<OidcService, GoogleOidcService>();
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using DysonNetwork.Shared.Registry;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace DysonNetwork.Pass.Startup;
|
||||
|
||||
public class ServiceRegistrationHostedService : IHostedService
|
||||
{
|
||||
private readonly ServiceRegistry _serviceRegistry;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<ServiceRegistrationHostedService> _logger;
|
||||
|
||||
public ServiceRegistrationHostedService(
|
||||
ServiceRegistry serviceRegistry,
|
||||
IConfiguration configuration,
|
||||
ILogger<ServiceRegistrationHostedService> logger)
|
||||
{
|
||||
_serviceRegistry = serviceRegistry;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var serviceName = "DysonNetwork.Pass"; // Preset service name
|
||||
var serviceUrl = _configuration["Service:Url"];
|
||||
|
||||
if (string.IsNullOrEmpty(serviceName) || string.IsNullOrEmpty(serviceUrl))
|
||||
{
|
||||
_logger.LogWarning("Service name or URL not configured. Skipping Etcd registration.");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Registering service {ServiceName} at {ServiceUrl} with Etcd.", serviceName, serviceUrl);
|
||||
try
|
||||
{
|
||||
await _serviceRegistry.RegisterService(serviceName, serviceUrl);
|
||||
_logger.LogInformation("Service {ServiceName} registered successfully.", serviceName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to register service {ServiceName} with Etcd.", serviceName);
|
||||
}
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// The lease will expire automatically if the service stops.
|
||||
// For explicit unregistration, you would implement it here.
|
||||
_logger.LogInformation("Service registration hosted service is stopping.");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
namespace DysonNetwork.Pass;
|
||||
|
||||
public class WeatherForecast
|
||||
{
|
||||
public DateOnly Date { get; set; }
|
||||
|
||||
public int TemperatureC { get; set; }
|
||||
|
||||
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
|
||||
|
||||
public string? Summary { get; set; }
|
||||
}
|
||||
178
DysonNetwork.Pusher/AppDatabase.cs
Normal file
178
DysonNetwork.Pusher/AppDatabase.cs
Normal file
@@ -0,0 +1,178 @@
|
||||
using System.Linq.Expressions;
|
||||
using System.Reflection;
|
||||
using DysonNetwork.Pusher.Notification;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
using Microsoft.EntityFrameworkCore.Query;
|
||||
using NodaTime;
|
||||
using Quartz;
|
||||
|
||||
namespace DysonNetwork.Pusher;
|
||||
|
||||
public class AppDatabase(
|
||||
DbContextOptions<AppDatabase> options,
|
||||
IConfiguration configuration
|
||||
) : DbContext(options)
|
||||
{
|
||||
public DbSet<Notification.Notification> Notifications { get; set; } = null!;
|
||||
public DbSet<PushSubscription> PushSubscriptions { get; set; }
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
optionsBuilder.UseNpgsql(
|
||||
configuration.GetConnectionString("App"),
|
||||
opt => opt
|
||||
.ConfigureDataSource(optSource => optSource.EnableDynamicJson())
|
||||
.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)
|
||||
.UseNodaTime()
|
||||
).UseSnakeCaseNamingConvention();
|
||||
|
||||
base.OnConfiguring(optionsBuilder);
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
// Automatically apply soft-delete filter to all entities inheriting BaseModel
|
||||
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
|
||||
{
|
||||
if (!typeof(ModelBase).IsAssignableFrom(entityType.ClrType)) continue;
|
||||
var method = typeof(AppDatabase)
|
||||
.GetMethod(nameof(SetSoftDeleteFilter),
|
||||
BindingFlags.NonPublic | BindingFlags.Static)!
|
||||
.MakeGenericMethod(entityType.ClrType);
|
||||
|
||||
method.Invoke(null, [modelBuilder]);
|
||||
}
|
||||
}
|
||||
|
||||
private static void SetSoftDeleteFilter<TEntity>(ModelBuilder modelBuilder)
|
||||
where TEntity : ModelBase
|
||||
{
|
||||
modelBuilder.Entity<TEntity>().HasQueryFilter(e => e.DeletedAt == null);
|
||||
}
|
||||
|
||||
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
foreach (var entry in ChangeTracker.Entries<ModelBase>())
|
||||
{
|
||||
switch (entry.State)
|
||||
{
|
||||
case EntityState.Added:
|
||||
entry.Entity.CreatedAt = now;
|
||||
entry.Entity.UpdatedAt = now;
|
||||
break;
|
||||
case EntityState.Modified:
|
||||
entry.Entity.UpdatedAt = now;
|
||||
break;
|
||||
case EntityState.Deleted:
|
||||
entry.State = EntityState.Modified;
|
||||
entry.Entity.DeletedAt = now;
|
||||
break;
|
||||
case EntityState.Detached:
|
||||
case EntityState.Unchanged:
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return await base.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public class AppDatabaseRecyclingJob(AppDatabase db, ILogger<AppDatabaseRecyclingJob> logger) : IJob
|
||||
{
|
||||
public async Task Execute(IJobExecutionContext context)
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
logger.LogInformation("Deleting soft-deleted records...");
|
||||
|
||||
var threshold = now - Duration.FromDays(7);
|
||||
|
||||
var entityTypes = db.Model.GetEntityTypes()
|
||||
.Where(t => typeof(ModelBase).IsAssignableFrom(t.ClrType) && t.ClrType != typeof(ModelBase))
|
||||
.Select(t => t.ClrType);
|
||||
|
||||
foreach (var entityType in entityTypes)
|
||||
{
|
||||
var set = (IQueryable)db.GetType().GetMethod(nameof(DbContext.Set), Type.EmptyTypes)!
|
||||
.MakeGenericMethod(entityType).Invoke(db, null)!;
|
||||
var parameter = Expression.Parameter(entityType, "e");
|
||||
var property = Expression.Property(parameter, nameof(ModelBase.DeletedAt));
|
||||
var condition = Expression.LessThan(property, Expression.Constant(threshold, typeof(Instant?)));
|
||||
var notNull = Expression.NotEqual(property, Expression.Constant(null, typeof(Instant?)));
|
||||
var finalCondition = Expression.AndAlso(notNull, condition);
|
||||
var lambda = Expression.Lambda(finalCondition, parameter);
|
||||
|
||||
var queryable = set.Provider.CreateQuery(
|
||||
Expression.Call(
|
||||
typeof(Queryable),
|
||||
"Where",
|
||||
[entityType],
|
||||
set.Expression,
|
||||
Expression.Quote(lambda)
|
||||
)
|
||||
);
|
||||
|
||||
var toListAsync = typeof(EntityFrameworkQueryableExtensions)
|
||||
.GetMethod(nameof(EntityFrameworkQueryableExtensions.ToListAsync))!
|
||||
.MakeGenericMethod(entityType);
|
||||
|
||||
var items = await (dynamic)toListAsync.Invoke(null, [queryable, CancellationToken.None])!;
|
||||
db.RemoveRange(items);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public class AppDatabaseFactory : IDesignTimeDbContextFactory<AppDatabase>
|
||||
{
|
||||
public AppDatabase CreateDbContext(string[] args)
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.SetBasePath(Directory.GetCurrentDirectory())
|
||||
.AddJsonFile("appsettings.json")
|
||||
.Build();
|
||||
|
||||
var optionsBuilder = new DbContextOptionsBuilder<AppDatabase>();
|
||||
return new AppDatabase(optionsBuilder.Options, configuration);
|
||||
}
|
||||
}
|
||||
|
||||
public static class OptionalQueryExtensions
|
||||
{
|
||||
public static IQueryable<T> If<T>(
|
||||
this IQueryable<T> source,
|
||||
bool condition,
|
||||
Func<IQueryable<T>, IQueryable<T>> transform
|
||||
)
|
||||
{
|
||||
return condition ? transform(source) : source;
|
||||
}
|
||||
|
||||
public static IQueryable<T> If<T, TP>(
|
||||
this IIncludableQueryable<T, TP> source,
|
||||
bool condition,
|
||||
Func<IIncludableQueryable<T, TP>, IQueryable<T>> transform
|
||||
)
|
||||
where T : class
|
||||
{
|
||||
return condition ? transform(source) : source;
|
||||
}
|
||||
|
||||
public static IQueryable<T> If<T, TP>(
|
||||
this IIncludableQueryable<T, IEnumerable<TP>> source,
|
||||
bool condition,
|
||||
Func<IIncludableQueryable<T, IEnumerable<TP>>, IQueryable<T>> transform
|
||||
)
|
||||
where T : class
|
||||
{
|
||||
return condition ? transform(source) : source;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,17 @@
|
||||
using System.Net.WebSockets;
|
||||
using 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";
|
||||
@@ -31,7 +33,7 @@ public class WebSocketPacket
|
||||
DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
};
|
||||
return JsonSerializer.Deserialize<WebSocketPacket>(json, jsonOpts) ??
|
||||
throw new JsonException("Failed to deserialize WebSocketPacket");
|
||||
throw new JsonException("Failed to deserialize WebSocketPacket");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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,20 @@
|
||||
</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="Quartz.Extensions.Hosting" Version="3.14.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.3" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -18,4 +30,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!;
|
||||
}
|
||||
|
||||
246
DysonNetwork.Pusher/Notification/PushService.cs
Normal file
246
DysonNetwork.Pusher/Notification/PushService.cs
Normal file
@@ -0,0 +1,246 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using EFCore.BulkExtensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Pusher.Notification;
|
||||
|
||||
public class PushService(IConfiguration config, AppDatabase db, IHttpClientFactory httpFactory)
|
||||
{
|
||||
private readonly string _notifyTopic = config["Notifications:Topic"]!;
|
||||
private readonly Uri _notifyEndpoint = new(config["Notifications:Endpoint"]!);
|
||||
|
||||
public async Task UnsubscribePushNotifications(string deviceId)
|
||||
{
|
||||
await db.PushSubscriptions
|
||||
.Where(s => s.DeviceId == deviceId)
|
||||
.ExecuteDeleteAsync();
|
||||
}
|
||||
|
||||
public async Task<PushSubscription> SubscribePushNotification(
|
||||
string deviceId,
|
||||
string deviceToken,
|
||||
PushProvider provider,
|
||||
Account account
|
||||
)
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
// First check if a matching subscription exists
|
||||
var accountId = Guid.Parse(account.Id!);
|
||||
var existingSubscription = await db.PushSubscriptions
|
||||
.Where(s => s.AccountId == accountId)
|
||||
.Where(s => s.DeviceId == deviceId || s.DeviceToken == deviceToken)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (existingSubscription is not null)
|
||||
{
|
||||
// Update the existing subscription directly in the database
|
||||
await db.PushSubscriptions
|
||||
.Where(s => s.Id == existingSubscription.Id)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(s => s.DeviceId, deviceId)
|
||||
.SetProperty(s => s.DeviceToken, deviceToken)
|
||||
.SetProperty(s => s.UpdatedAt, now));
|
||||
|
||||
// Return the updated subscription
|
||||
existingSubscription.DeviceId = deviceId;
|
||||
existingSubscription.DeviceToken = deviceToken;
|
||||
existingSubscription.UpdatedAt = now;
|
||||
return existingSubscription;
|
||||
}
|
||||
|
||||
var subscription = new PushSubscription
|
||||
{
|
||||
DeviceId = deviceId,
|
||||
DeviceToken = deviceToken,
|
||||
Provider = provider,
|
||||
AccountId = accountId,
|
||||
};
|
||||
|
||||
db.PushSubscriptions.Add(subscription);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
public async Task SendNotification(Account account,
|
||||
string topic,
|
||||
string? title = null,
|
||||
string? subtitle = null,
|
||||
string? content = null,
|
||||
Dictionary<string, object>? meta = null,
|
||||
string? actionUri = null,
|
||||
bool isSilent = false,
|
||||
bool save = true)
|
||||
{
|
||||
if (title is null && subtitle is null && content is null)
|
||||
throw new ArgumentException("Unable to send notification that completely empty.");
|
||||
|
||||
meta ??= new Dictionary<string, object>();
|
||||
if (actionUri is not null) meta["action_uri"] = actionUri;
|
||||
|
||||
var accountId = Guid.Parse(account.Id!);
|
||||
var notification = new Notification
|
||||
{
|
||||
Topic = topic,
|
||||
Title = title,
|
||||
Subtitle = subtitle,
|
||||
Content = content,
|
||||
Meta = meta,
|
||||
AccountId = accountId,
|
||||
};
|
||||
|
||||
if (save)
|
||||
{
|
||||
db.Add(notification);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
if (!isSilent) _ = DeliveryNotification(notification);
|
||||
}
|
||||
|
||||
public async Task DeliveryNotification(Pusher.Notification.Notification notification)
|
||||
{
|
||||
// Pushing the notification
|
||||
var subscribers = await db.PushSubscriptions
|
||||
.Where(s => s.AccountId == notification.AccountId)
|
||||
.ToListAsync();
|
||||
|
||||
await _PushNotification(notification, subscribers);
|
||||
}
|
||||
|
||||
public async Task MarkNotificationsViewed(ICollection<Notification> notifications)
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var id = notifications.Where(n => n.ViewedAt == null).Select(n => n.Id).ToList();
|
||||
if (id.Count == 0) return;
|
||||
|
||||
await db.Notifications
|
||||
.Where(n => id.Contains(n.Id))
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(n => n.ViewedAt, now)
|
||||
);
|
||||
}
|
||||
|
||||
public async Task SendNotificationBatch(Notification notification, List<Guid> accounts, bool save = false)
|
||||
{
|
||||
if (save)
|
||||
{
|
||||
var notifications = accounts.Select(x =>
|
||||
{
|
||||
var newNotification = new Notification
|
||||
{
|
||||
Topic = notification.Topic,
|
||||
Title = notification.Title,
|
||||
Subtitle = notification.Subtitle,
|
||||
Content = notification.Content,
|
||||
Meta = notification.Meta,
|
||||
Priority = notification.Priority,
|
||||
AccountId = x
|
||||
};
|
||||
return newNotification;
|
||||
}).ToList();
|
||||
await db.BulkInsertAsync(notifications);
|
||||
}
|
||||
|
||||
var subscribers = await db.PushSubscriptions
|
||||
.Where(s => accounts.Contains(s.AccountId))
|
||||
.ToListAsync();
|
||||
await _PushNotification(notification, subscribers);
|
||||
}
|
||||
|
||||
private List<Dictionary<string, object>> _BuildNotificationPayload(Notification notification,
|
||||
IEnumerable<PushSubscription> subscriptions)
|
||||
{
|
||||
var subDict = subscriptions
|
||||
.GroupBy(x => x.Provider)
|
||||
.ToDictionary(x => x.Key, x => x.ToList());
|
||||
|
||||
var notifications = subDict.Select(value =>
|
||||
{
|
||||
var platformCode = value.Key switch
|
||||
{
|
||||
PushProvider.Apple => 1,
|
||||
PushProvider.Google => 2,
|
||||
_ => throw new InvalidOperationException($"Unknown push provider: {value.Key}")
|
||||
};
|
||||
|
||||
var tokens = value.Value.Select(x => x.DeviceToken).ToList();
|
||||
return _BuildNotificationPayload(notification, platformCode, tokens);
|
||||
}).ToList();
|
||||
|
||||
return notifications.ToList();
|
||||
}
|
||||
|
||||
private Dictionary<string, object> _BuildNotificationPayload(Pusher.Notification.Notification notification,
|
||||
int platformCode,
|
||||
IEnumerable<string> deviceTokens)
|
||||
{
|
||||
var alertDict = new Dictionary<string, object>();
|
||||
var dict = new Dictionary<string, object>
|
||||
{
|
||||
["notif_id"] = notification.Id.ToString(),
|
||||
["apns_id"] = notification.Id.ToString(),
|
||||
["topic"] = _notifyTopic,
|
||||
["tokens"] = deviceTokens,
|
||||
["data"] = new Dictionary<string, object>
|
||||
{
|
||||
["type"] = notification.Topic,
|
||||
["meta"] = notification.Meta ?? new Dictionary<string, object>(),
|
||||
},
|
||||
["mutable_content"] = true,
|
||||
["priority"] = notification.Priority >= 5 ? "high" : "normal",
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(notification.Title))
|
||||
{
|
||||
dict["title"] = notification.Title;
|
||||
alertDict["title"] = notification.Title;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(notification.Content))
|
||||
{
|
||||
dict["message"] = notification.Content;
|
||||
alertDict["body"] = notification.Content;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(notification.Subtitle))
|
||||
{
|
||||
dict["message"] = $"{notification.Subtitle}\n{dict["message"]}";
|
||||
alertDict["subtitle"] = notification.Subtitle;
|
||||
}
|
||||
|
||||
if (notification.Priority >= 5)
|
||||
dict["name"] = "default";
|
||||
|
||||
dict["platform"] = platformCode;
|
||||
dict["alert"] = alertDict;
|
||||
|
||||
return dict;
|
||||
}
|
||||
|
||||
private async Task _PushNotification(
|
||||
Notification notification,
|
||||
IEnumerable<PushSubscription> subscriptions
|
||||
)
|
||||
{
|
||||
var subList = subscriptions.ToList();
|
||||
if (subList.Count == 0) return;
|
||||
|
||||
var requestDict = new Dictionary<string, object>
|
||||
{
|
||||
["notifications"] = _BuildNotificationPayload(notification, subList)
|
||||
};
|
||||
|
||||
var client = httpFactory.CreateClient();
|
||||
client.BaseAddress = _notifyEndpoint;
|
||||
var request = await client.PostAsync("/push", new StringContent(
|
||||
JsonSerializer.Serialize(requestDict),
|
||||
Encoding.UTF8,
|
||||
"application/json"
|
||||
));
|
||||
request.EnsureSuccessStatusCode();
|
||||
}
|
||||
}
|
||||
25
DysonNetwork.Pusher/Notification/PushSubscription.cs
Normal file
25
DysonNetwork.Pusher/Notification/PushSubscription.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Pusher.Notification;
|
||||
|
||||
public enum PushProvider
|
||||
{
|
||||
Apple,
|
||||
Google
|
||||
}
|
||||
|
||||
[Index(nameof(AccountId), nameof(DeviceId), nameof(DeletedAt), IsUnique = true)]
|
||||
public class PushSubscription : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid AccountId { get; set; }
|
||||
[MaxLength(8192)] public string DeviceId { get; set; } = null!;
|
||||
[MaxLength(8192)] public string DeviceToken { get; set; } = null!;
|
||||
public PushProvider Provider { get; set; }
|
||||
|
||||
public int CountDelivered { get; set; }
|
||||
public Instant? LastUsedAt { get; set; }
|
||||
}
|
||||
@@ -1,23 +1,45 @@
|
||||
using DysonNetwork.Pass.Startup;
|
||||
using DysonNetwork.Pusher;
|
||||
using DysonNetwork.Pusher.Startup;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add services to the container.
|
||||
// Configure Kestrel and server options
|
||||
builder.ConfigureAppKestrel();
|
||||
|
||||
builder.Services.AddControllers();
|
||||
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
|
||||
builder.Services.AddOpenApi();
|
||||
// Add application services
|
||||
builder.Services.AddAppServices(builder.Configuration);
|
||||
builder.Services.AddAppRateLimiting();
|
||||
builder.Services.AddAppAuthentication();
|
||||
builder.Services.AddAppSwagger();
|
||||
|
||||
// Add flush handlers and websocket handlers
|
||||
builder.Services.AddAppFlushHandlers();
|
||||
|
||||
// Add business services
|
||||
builder.Services.AddAppBusinessServices();
|
||||
|
||||
// Add scheduled jobs
|
||||
builder.Services.AddAppScheduledJobs();
|
||||
|
||||
builder.Services.AddHostedService<ServiceRegistrationHostedService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment())
|
||||
// Run database migrations
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
|
||||
await db.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
// Configure application middleware pipeline
|
||||
app.ConfigureAppMiddleware(builder.Configuration);
|
||||
|
||||
app.UseAuthorization();
|
||||
app.UseMiddleware<DysonNetwork.Shared.Middleware.AuthMiddleware>();
|
||||
|
||||
app.MapControllers();
|
||||
// Configure gRPC
|
||||
app.ConfigureGrpcServices();
|
||||
|
||||
app.Run();
|
||||
162
DysonNetwork.Pusher/Services/PusherServiceGrpc.cs
Normal file
162
DysonNetwork.Pusher/Services/PusherServiceGrpc.cs
Normal file
@@ -0,0 +1,162 @@
|
||||
using DysonNetwork.Pusher.Connection;
|
||||
using DysonNetwork.Pusher.Email;
|
||||
using DysonNetwork.Pusher.Notification;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using Grpc.Core;
|
||||
|
||||
namespace DysonNetwork.Pusher.Services;
|
||||
|
||||
public class PusherServiceGrpc(
|
||||
EmailService emailService,
|
||||
WebSocketService webSocketService,
|
||||
PushService pushService
|
||||
) : PusherService.PusherServiceBase
|
||||
{
|
||||
public override async Task<Empty> SendEmail(SendEmailRequest request, ServerCallContext context)
|
||||
{
|
||||
await emailService.SendEmailAsync(
|
||||
request.Email.ToName,
|
||||
request.Email.ToAddress,
|
||||
request.Email.Subject,
|
||||
request.Email.Body
|
||||
);
|
||||
return new Empty();
|
||||
}
|
||||
|
||||
public override Task<Empty> PushWebSocketPacket(PushWebSocketPacketRequest request, ServerCallContext context)
|
||||
{
|
||||
var packet = new Connection.WebSocketPacket
|
||||
{
|
||||
Type = request.Packet.Type,
|
||||
Data = request.Packet.Data,
|
||||
ErrorMessage = request.Packet.ErrorMessage
|
||||
};
|
||||
webSocketService.SendPacketToAccount(request.UserId, packet);
|
||||
return Task.FromResult(new Empty());
|
||||
}
|
||||
|
||||
public override Task<Empty> PushWebSocketPacketToUsers(PushWebSocketPacketToUsersRequest request,
|
||||
ServerCallContext context)
|
||||
{
|
||||
var packet = new Connection.WebSocketPacket
|
||||
{
|
||||
Type = request.Packet.Type,
|
||||
Data = request.Packet.Data,
|
||||
ErrorMessage = request.Packet.ErrorMessage
|
||||
};
|
||||
foreach (var userId in request.UserIds)
|
||||
webSocketService.SendPacketToAccount(userId, packet);
|
||||
|
||||
return Task.FromResult(new Empty());
|
||||
}
|
||||
|
||||
public override Task<Empty> PushWebSocketPacketToDevice(PushWebSocketPacketToDeviceRequest request,
|
||||
ServerCallContext context)
|
||||
{
|
||||
var packet = new Connection.WebSocketPacket
|
||||
{
|
||||
Type = request.Packet.Type,
|
||||
Data = request.Packet.Data,
|
||||
ErrorMessage = request.Packet.ErrorMessage
|
||||
};
|
||||
webSocketService.SendPacketToDevice(request.DeviceId, packet);
|
||||
return Task.FromResult(new Empty());
|
||||
}
|
||||
|
||||
public override Task<Empty> PushWebSocketPacketToDevices(PushWebSocketPacketToDevicesRequest request,
|
||||
ServerCallContext context)
|
||||
{
|
||||
var packet = new Connection.WebSocketPacket
|
||||
{
|
||||
Type = request.Packet.Type,
|
||||
Data = request.Packet.Data,
|
||||
ErrorMessage = request.Packet.ErrorMessage
|
||||
};
|
||||
foreach (var deviceId in request.DeviceIds)
|
||||
webSocketService.SendPacketToDevice(deviceId, packet);
|
||||
|
||||
return Task.FromResult(new Empty());
|
||||
}
|
||||
|
||||
public override async Task<Empty> SendPushNotification(SendPushNotificationRequest request,
|
||||
ServerCallContext context)
|
||||
{
|
||||
// This is a placeholder implementation. In a real-world scenario, you would
|
||||
// need to retrieve the account from the database based on the device token.
|
||||
var account = new Account();
|
||||
await pushService.SendNotification(
|
||||
account,
|
||||
request.Notification.Topic,
|
||||
request.Notification.Title,
|
||||
request.Notification.Subtitle,
|
||||
request.Notification.Body,
|
||||
GrpcTypeHelper.ConvertFromValueMap(request.Notification.Meta),
|
||||
request.Notification.ActionUri,
|
||||
request.Notification.IsSilent,
|
||||
request.Notification.IsSavable
|
||||
);
|
||||
return new Empty();
|
||||
}
|
||||
|
||||
public override async Task<Empty> SendPushNotificationToDevices(SendPushNotificationToDevicesRequest request,
|
||||
ServerCallContext context)
|
||||
{
|
||||
// This is a placeholder implementation. In a real-world scenario, you would
|
||||
// need to retrieve the accounts from the database based on the device tokens.
|
||||
var account = new Account();
|
||||
foreach (var deviceId in request.DeviceIds)
|
||||
{
|
||||
await pushService.SendNotification(
|
||||
account,
|
||||
request.Notification.Topic,
|
||||
request.Notification.Title,
|
||||
request.Notification.Subtitle,
|
||||
request.Notification.Body,
|
||||
GrpcTypeHelper.ConvertFromValueMap(request.Notification.Meta),
|
||||
request.Notification.ActionUri,
|
||||
request.Notification.IsSilent,
|
||||
request.Notification.IsSavable
|
||||
);
|
||||
}
|
||||
|
||||
return new Empty();
|
||||
}
|
||||
|
||||
public override async Task<Empty> SendPushNotificationToUser(SendPushNotificationToUserRequest request,
|
||||
ServerCallContext context)
|
||||
{
|
||||
// This is a placeholder implementation. In a real-world scenario, you would
|
||||
// need to retrieve the account from the database based on the user ID.
|
||||
var account = new Account();
|
||||
await pushService.SendNotification(
|
||||
account,
|
||||
request.Notification.Topic,
|
||||
request.Notification.Title,
|
||||
request.Notification.Subtitle,
|
||||
request.Notification.Body,
|
||||
GrpcTypeHelper.ConvertFromValueMap(request.Notification.Meta),
|
||||
request.Notification.ActionUri,
|
||||
request.Notification.IsSilent,
|
||||
request.Notification.IsSavable
|
||||
);
|
||||
return new Empty();
|
||||
}
|
||||
|
||||
public override async Task<Empty> SendPushNotificationToUsers(SendPushNotificationToUsersRequest request,
|
||||
ServerCallContext context)
|
||||
{
|
||||
var notification = new Notification.Notification
|
||||
{
|
||||
Topic = request.Notification.Topic,
|
||||
Title = request.Notification.Title,
|
||||
Subtitle = request.Notification.Subtitle, Content = request.Notification.Body,
|
||||
Meta = GrpcTypeHelper.ConvertFromValueMap(request.Notification.Meta),
|
||||
};
|
||||
if (request.Notification.ActionUri is not null)
|
||||
notification.Meta["action_uri"] = request.Notification.ActionUri;
|
||||
var accounts = request.UserIds.Select(Guid.Parse).ToList();
|
||||
await pushService.SendNotificationBatch(notification, accounts, request.Notification.IsSavable);
|
||||
return new Empty();
|
||||
}
|
||||
}
|
||||
70
DysonNetwork.Pusher/Startup/ApplicationConfiguration.cs
Normal file
70
DysonNetwork.Pusher/Startup/ApplicationConfiguration.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
using System.Net;
|
||||
using DysonNetwork.Pusher.Services;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
|
||||
namespace DysonNetwork.Pusher.Startup;
|
||||
|
||||
public static class ApplicationConfiguration
|
||||
{
|
||||
public static WebApplication ConfigureAppMiddleware(this WebApplication app, IConfiguration configuration)
|
||||
{
|
||||
app.MapOpenApi();
|
||||
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
|
||||
app.UseRequestLocalization();
|
||||
|
||||
ConfigureForwardedHeaders(app, configuration);
|
||||
|
||||
app.UseCors(opts =>
|
||||
opts.SetIsOriginAllowed(_ => true)
|
||||
.WithExposedHeaders("*")
|
||||
.WithHeaders()
|
||||
.AllowCredentials()
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod()
|
||||
);
|
||||
|
||||
app.UseWebSockets();
|
||||
app.UseRateLimiter();
|
||||
app.UseHttpsRedirection();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapControllers().RequireRateLimiting("fixed");
|
||||
app.MapStaticAssets().RequireRateLimiting("fixed");
|
||||
app.MapRazorPages().RequireRateLimiting("fixed");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static void ConfigureForwardedHeaders(WebApplication app, IConfiguration configuration)
|
||||
{
|
||||
var knownProxiesSection = configuration.GetSection("KnownProxies");
|
||||
var forwardedHeadersOptions = new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.All };
|
||||
|
||||
if (knownProxiesSection.Exists())
|
||||
{
|
||||
var proxyAddresses = knownProxiesSection.Get<string[]>();
|
||||
if (proxyAddresses != null)
|
||||
foreach (var proxy in proxyAddresses)
|
||||
if (IPAddress.TryParse(proxy, out var ipAddress))
|
||||
forwardedHeadersOptions.KnownProxies.Add(ipAddress);
|
||||
}
|
||||
else
|
||||
{
|
||||
forwardedHeadersOptions.KnownProxies.Add(IPAddress.Any);
|
||||
forwardedHeadersOptions.KnownProxies.Add(IPAddress.IPv6Any);
|
||||
}
|
||||
|
||||
app.UseForwardedHeaders(forwardedHeadersOptions);
|
||||
}
|
||||
|
||||
public static WebApplication ConfigureGrpcServices(this WebApplication app)
|
||||
{
|
||||
app.MapGrpcService<PusherServiceGrpc>();
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
17
DysonNetwork.Pusher/Startup/KestrelConfiguration.cs
Normal file
17
DysonNetwork.Pusher/Startup/KestrelConfiguration.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace DysonNetwork.Pass.Startup;
|
||||
|
||||
public static class KestrelConfiguration
|
||||
{
|
||||
public static WebApplicationBuilder ConfigureAppKestrel(this WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Host.UseContentRoot(Directory.GetCurrentDirectory());
|
||||
builder.WebHost.ConfigureKestrel(options =>
|
||||
{
|
||||
options.Limits.MaxRequestBodySize = 50 * 1024 * 1024;
|
||||
options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(2);
|
||||
options.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
22
DysonNetwork.Pusher/Startup/ScheduledJobsConfiguration.cs
Normal file
22
DysonNetwork.Pusher/Startup/ScheduledJobsConfiguration.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using Quartz;
|
||||
|
||||
namespace DysonNetwork.Pusher.Startup;
|
||||
|
||||
public static class ScheduledJobsConfiguration
|
||||
{
|
||||
public static IServiceCollection AddAppScheduledJobs(this IServiceCollection services)
|
||||
{
|
||||
services.AddQuartz(q =>
|
||||
{
|
||||
var appDatabaseRecyclingJob = new JobKey("AppDatabaseRecycling");
|
||||
q.AddJob<AppDatabaseRecyclingJob>(opts => opts.WithIdentity(appDatabaseRecyclingJob));
|
||||
q.AddTrigger(opts => opts
|
||||
.ForJob(appDatabaseRecyclingJob)
|
||||
.WithIdentity("AppDatabaseRecyclingTrigger")
|
||||
.WithCronSchedule("0 0 0 * * ?"));
|
||||
});
|
||||
services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
152
DysonNetwork.Pusher/Startup/ServiceCollectionExtensions.cs
Normal file
152
DysonNetwork.Pusher/Startup/ServiceCollectionExtensions.cs
Normal file
@@ -0,0 +1,152 @@
|
||||
using System.Text.Json;
|
||||
using System.Threading.RateLimiting;
|
||||
using dotnet_etcd.interfaces;
|
||||
using DysonNetwork.Pusher.Email;
|
||||
using DysonNetwork.Pusher.Notification;
|
||||
using DysonNetwork.Pusher.Services;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using NodaTime;
|
||||
using NodaTime.Serialization.SystemTextJson;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace DysonNetwork.Pusher.Startup;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddDbContext<AppDatabase>();
|
||||
services.AddSingleton<IConnectionMultiplexer>(_ =>
|
||||
{
|
||||
var connection = configuration.GetConnectionString("FastRetrieve")!;
|
||||
return ConnectionMultiplexer.Connect(connection);
|
||||
});
|
||||
services.AddSingleton<IClock>(SystemClock.Instance);
|
||||
services.AddHttpContextAccessor();
|
||||
services.AddSingleton<ICacheService, CacheServiceRedis>();
|
||||
|
||||
services.AddHttpClient();
|
||||
|
||||
// Register gRPC services
|
||||
services.AddGrpc(options =>
|
||||
{
|
||||
options.EnableDetailedErrors = true; // Will be adjusted in Program.cs
|
||||
options.MaxReceiveMessageSize = 16 * 1024 * 1024; // 16MB
|
||||
options.MaxSendMessageSize = 16 * 1024 * 1024; // 16MB
|
||||
});
|
||||
|
||||
// Register gRPC reflection for service discovery
|
||||
services.AddGrpc();
|
||||
|
||||
// Register gRPC services
|
||||
services.AddScoped<PusherServiceGrpc>();
|
||||
|
||||
// Register AuthService.AuthServiceClient for AuthMiddleware
|
||||
services.AddSingleton(sp =>
|
||||
{
|
||||
var etcdClient = sp.GetRequiredService<IEtcdClient>();
|
||||
var configuration = sp.GetRequiredService<IConfiguration>();
|
||||
var clientCertPath = configuration["ClientCert:Path"];
|
||||
var clientKeyPath = configuration["ClientKey:Path"];
|
||||
var clientCertPassword = configuration["ClientCert:Password"];
|
||||
|
||||
return GrpcClientHelper.CreateAuthServiceClient(etcdClient, clientCertPath, clientKeyPath, clientCertPassword);
|
||||
});
|
||||
|
||||
// Register OIDC services
|
||||
services.AddControllers().AddJsonOptions(options =>
|
||||
{
|
||||
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
|
||||
options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower;
|
||||
|
||||
options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddAppRateLimiting(this IServiceCollection services)
|
||||
{
|
||||
services.AddRateLimiter(o => o.AddFixedWindowLimiter(policyName: "fixed", opts =>
|
||||
{
|
||||
opts.Window = TimeSpan.FromMinutes(1);
|
||||
opts.PermitLimit = 120;
|
||||
opts.QueueLimit = 2;
|
||||
opts.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
|
||||
}));
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddAppAuthentication(this IServiceCollection services)
|
||||
{
|
||||
services.AddCors();
|
||||
services.AddAuthorization();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddAppSwagger(this IServiceCollection services)
|
||||
{
|
||||
services.AddEndpointsApiExplorer();
|
||||
services.AddSwaggerGen(options =>
|
||||
{
|
||||
options.SwaggerDoc("v1", new OpenApiInfo
|
||||
{
|
||||
Version = "v1",
|
||||
Title = "Solar Network API",
|
||||
Description = "An open-source social network",
|
||||
TermsOfService = new Uri("https://solsynth.dev/terms"),
|
||||
License = new OpenApiLicense
|
||||
{
|
||||
Name = "APGLv3",
|
||||
Url = new Uri("https://www.gnu.org/licenses/agpl-3.0.html")
|
||||
}
|
||||
});
|
||||
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
|
||||
{
|
||||
In = ParameterLocation.Header,
|
||||
Description = "Please enter a valid token",
|
||||
Name = "Authorization",
|
||||
Type = SecuritySchemeType.Http,
|
||||
BearerFormat = "JWT",
|
||||
Scheme = "Bearer"
|
||||
});
|
||||
options.AddSecurityRequirement(new OpenApiSecurityRequirement
|
||||
{
|
||||
{
|
||||
new OpenApiSecurityScheme
|
||||
{
|
||||
Reference = new OpenApiReference
|
||||
{
|
||||
Type = ReferenceType.SecurityScheme,
|
||||
Id = "Bearer"
|
||||
}
|
||||
},
|
||||
[]
|
||||
}
|
||||
});
|
||||
});
|
||||
services.AddOpenApi();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddAppFlushHandlers(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<FlushBufferService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddAppBusinessServices(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<EmailService>();
|
||||
services.AddScoped<PushService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using DysonNetwork.Shared.Registry;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace DysonNetwork.Pusher.Startup;
|
||||
|
||||
public class ServiceRegistrationHostedService : IHostedService
|
||||
{
|
||||
private readonly ServiceRegistry _serviceRegistry;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<ServiceRegistrationHostedService> _logger;
|
||||
|
||||
public ServiceRegistrationHostedService(
|
||||
ServiceRegistry serviceRegistry,
|
||||
IConfiguration configuration,
|
||||
ILogger<ServiceRegistrationHostedService> logger)
|
||||
{
|
||||
_serviceRegistry = serviceRegistry;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var serviceName = "DysonNetwork.Pusher"; // Preset service name
|
||||
var serviceUrl = _configuration["Service:Url"];
|
||||
|
||||
if (string.IsNullOrEmpty(serviceUrl))
|
||||
{
|
||||
_logger.LogWarning("Service URL not configured. Skipping Etcd registration.");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Registering service {ServiceName} at {ServiceUrl} with Etcd.", serviceName, serviceUrl);
|
||||
try
|
||||
{
|
||||
await _serviceRegistry.RegisterService(serviceName, serviceUrl);
|
||||
_logger.LogInformation("Service {ServiceName} registered successfully.", serviceName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to register service {ServiceName} with Etcd.", serviceName);
|
||||
}
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// The lease will expire automatically if the service stops.
|
||||
// For explicit unregistration, you would implement it here.
|
||||
_logger.LogInformation("Service registration hosted service is stopping.");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
22
DysonNetwork.Shared/Auth/AuthConstants.cs
Normal file
22
DysonNetwork.Shared/Auth/AuthConstants.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
namespace DysonNetwork.Shared.Auth;
|
||||
|
||||
public static class AuthConstants
|
||||
{
|
||||
public const string SchemeName = "DysonToken";
|
||||
public const string TokenQueryParamName = "tk";
|
||||
public const string CookieTokenName = "AuthToken";
|
||||
}
|
||||
|
||||
public enum TokenType
|
||||
{
|
||||
AuthKey,
|
||||
ApiKey,
|
||||
OidcKey,
|
||||
Unknown
|
||||
}
|
||||
|
||||
public class TokenInfo
|
||||
{
|
||||
public string Token { get; set; } = string.Empty;
|
||||
public TokenType Type { get; set; } = TokenType.Unknown;
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="dotnet-etcd" Version="8.0.1" />
|
||||
<PackageReference Include="Google.Api.CommonProtos" Version="2.17.0"/>
|
||||
<PackageReference Include="Google.Protobuf" Version="3.31.1" />
|
||||
<PackageReference Include="Google.Protobuf.Tools" Version="3.31.1" />
|
||||
@@ -17,12 +18,16 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="MaxMind.GeoIP2" Version="5.3.0"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.3.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.7"/>
|
||||
<PackageReference Include="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"/>
|
||||
<PackageReference Include="System.Net.Http" Version="4.3.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -3,7 +3,7 @@ using Microsoft.Extensions.Options;
|
||||
using NetTopologySuite.Geometries;
|
||||
using Point = NetTopologySuite.Geometries.Point;
|
||||
|
||||
namespace DysonNetwork.Shared.Geo;
|
||||
namespace DysonNetwork.Shared.GeoIp;
|
||||
|
||||
public class GeoIpOptions
|
||||
{
|
||||
107
DysonNetwork.Shared/Middleware/AuthMiddleware.cs
Normal file
107
DysonNetwork.Shared/Middleware/AuthMiddleware.cs
Normal file
@@ -0,0 +1,107 @@
|
||||
using Grpc.Core;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using System.Threading.Tasks;
|
||||
using DysonNetwork.Shared.Auth;
|
||||
|
||||
namespace DysonNetwork.Shared.Middleware;
|
||||
|
||||
public class AuthMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<AuthMiddleware> _logger;
|
||||
|
||||
public AuthMiddleware(RequestDelegate next, ILogger<AuthMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context, AuthService.AuthServiceClient authServiceClient)
|
||||
{
|
||||
var tokenInfo = _ExtractToken(context.Request);
|
||||
|
||||
if (tokenInfo == null || string.IsNullOrEmpty(tokenInfo.Token))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var authSession = await authServiceClient.AuthenticateAsync(new AuthenticateRequest { Token = tokenInfo.Token });
|
||||
context.Items["AuthSession"] = authSession;
|
||||
context.Items["CurrentTokenType"] = tokenInfo.Type.ToString();
|
||||
// Assuming AuthSession contains Account information or can be retrieved
|
||||
// context.Items["CurrentUser"] = authSession.Account; // You might need to fetch Account separately if not embedded
|
||||
}
|
||||
catch (RpcException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Authentication failed for token: {Token}", tokenInfo.Token);
|
||||
// Optionally, you can return an unauthorized response here
|
||||
// context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
// return;
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
private TokenInfo? _ExtractToken(HttpRequest request)
|
||||
{
|
||||
// Check for token in query parameters
|
||||
if (request.Query.TryGetValue(AuthConstants.TokenQueryParamName, out var queryToken))
|
||||
{
|
||||
return new TokenInfo
|
||||
{
|
||||
Token = queryToken.ToString(),
|
||||
Type = TokenType.AuthKey
|
||||
};
|
||||
}
|
||||
|
||||
// Check for token in Authorization header
|
||||
var authHeader = request.Headers["Authorization"].ToString();
|
||||
if (!string.IsNullOrEmpty(authHeader))
|
||||
{
|
||||
if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var token = authHeader["Bearer ".Length..].Trim();
|
||||
var parts = token.Split('.');
|
||||
|
||||
return new TokenInfo
|
||||
{
|
||||
Token = token,
|
||||
Type = parts.Length == 3 ? TokenType.OidcKey : TokenType.AuthKey
|
||||
};
|
||||
}
|
||||
else if (authHeader.StartsWith("AtField ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new TokenInfo
|
||||
{
|
||||
Token = authHeader["AtField ".Length..].Trim(),
|
||||
Type = TokenType.AuthKey
|
||||
};
|
||||
}
|
||||
else if (authHeader.StartsWith("AkField ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new TokenInfo
|
||||
{
|
||||
Token = authHeader["AkField ".Length..].Trim(),
|
||||
Type = TokenType.ApiKey
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check for token in cookies
|
||||
if (request.Cookies.TryGetValue(AuthConstants.CookieTokenName, out var cookieToken))
|
||||
{
|
||||
return new TokenInfo
|
||||
{
|
||||
Token = cookieToken,
|
||||
Type = cookieToken.Count(c => c == '.') == 2 ? TokenType.OidcKey : TokenType.AuthKey
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
104
DysonNetwork.Shared/Proto/GrpcClientHelper.cs
Normal file
104
DysonNetwork.Shared/Proto/GrpcClientHelper.cs
Normal file
@@ -0,0 +1,104 @@
|
||||
using Grpc.Net.Client;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Grpc.Core;
|
||||
using dotnet_etcd.interfaces;
|
||||
|
||||
namespace DysonNetwork.Shared.Proto;
|
||||
|
||||
public static class GrpcClientHelper
|
||||
{
|
||||
private static CallInvoker CreateCallInvoker(
|
||||
string url,
|
||||
string clientCertPath,
|
||||
string clientKeyPath,
|
||||
string? clientCertPassword = null
|
||||
)
|
||||
{
|
||||
var handler = new HttpClientHandler();
|
||||
handler.ClientCertificates.Add(
|
||||
clientCertPassword is null ?
|
||||
X509Certificate2.CreateFromPemFile(clientCertPath, clientKeyPath) :
|
||||
X509Certificate2.CreateFromEncryptedPemFile(clientCertPath, clientCertPassword, clientKeyPath)
|
||||
);
|
||||
return GrpcChannel.ForAddress(url, new GrpcChannelOptions { HttpHandler = handler }).CreateCallInvoker();
|
||||
}
|
||||
|
||||
private static async Task<string> GetServiceUrlFromEtcd(IEtcdClient etcdClient, string serviceName)
|
||||
{
|
||||
var response = await etcdClient.GetAsync($"/services/{serviceName}");
|
||||
if (response.Kvs.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Service '{serviceName}' not found in Etcd.");
|
||||
}
|
||||
return response.Kvs[0].Value.ToStringUtf8();
|
||||
}
|
||||
|
||||
public static AccountService.AccountServiceClient CreateAccountServiceClient(
|
||||
string url,
|
||||
string clientCertPath,
|
||||
string clientKeyPath,
|
||||
string? clientCertPassword = null
|
||||
)
|
||||
{
|
||||
return new AccountService.AccountServiceClient(CreateCallInvoker(url, clientCertPath, clientKeyPath,
|
||||
clientCertPassword));
|
||||
}
|
||||
|
||||
public static async Task<AccountService.AccountServiceClient> CreateAccountServiceClient(
|
||||
IEtcdClient etcdClient,
|
||||
string clientCertPath,
|
||||
string clientKeyPath,
|
||||
string? clientCertPassword = null
|
||||
)
|
||||
{
|
||||
var url = await GetServiceUrlFromEtcd(etcdClient, "AccountService");
|
||||
return new AccountService.AccountServiceClient(CreateCallInvoker(url, clientCertPath, clientKeyPath,
|
||||
clientCertPassword));
|
||||
}
|
||||
|
||||
public static AuthService.AuthServiceClient CreateAuthServiceClient(
|
||||
string url,
|
||||
string clientCertPath,
|
||||
string clientKeyPath,
|
||||
string? clientCertPassword = null
|
||||
)
|
||||
{
|
||||
return new AuthService.AuthServiceClient(CreateCallInvoker(url, clientCertPath, clientKeyPath,
|
||||
clientCertPassword));
|
||||
}
|
||||
|
||||
public static async Task<AuthService.AuthServiceClient> CreateAuthServiceClient(
|
||||
IEtcdClient etcdClient,
|
||||
string clientCertPath,
|
||||
string clientKeyPath,
|
||||
string? clientCertPassword = null
|
||||
)
|
||||
{
|
||||
var url = await GetServiceUrlFromEtcd(etcdClient, "AuthService");
|
||||
return new AuthService.AuthServiceClient(CreateCallInvoker(url, clientCertPath, clientKeyPath,
|
||||
clientCertPassword));
|
||||
}
|
||||
|
||||
public static PusherService.PusherServiceClient CreatePusherServiceClient(
|
||||
string url,
|
||||
string clientCertPath,
|
||||
string clientKeyPath,
|
||||
string? clientCertPassword = null
|
||||
)
|
||||
{
|
||||
return new PusherService.PusherServiceClient(CreateCallInvoker(url, clientCertPath, clientKeyPath,
|
||||
clientCertPassword));
|
||||
}
|
||||
|
||||
public static async Task<PusherService.PusherServiceClient> CreatePusherServiceClient(
|
||||
IEtcdClient etcdClient,
|
||||
string clientCertPath,
|
||||
string clientKeyPath,
|
||||
string? clientCertPassword = null
|
||||
)
|
||||
{
|
||||
var url = await GetServiceUrlFromEtcd(etcdClient, "PusherService");
|
||||
return new PusherService.PusherServiceClient(CreateCallInvoker(url, clientCertPath, clientKeyPath,
|
||||
clientCertPassword));
|
||||
}
|
||||
}
|
||||
89
DysonNetwork.Shared/Proto/GrpcTypeHelper.cs
Normal file
89
DysonNetwork.Shared/Proto/GrpcTypeHelper.cs
Normal file
@@ -0,0 +1,89 @@
|
||||
using Google.Protobuf.Collections;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace DysonNetwork.Shared.Proto;
|
||||
|
||||
public abstract class GrpcTypeHelper
|
||||
{
|
||||
private static readonly JsonSerializerSettings SerializerSettings = new()
|
||||
{
|
||||
PreserveReferencesHandling = PreserveReferencesHandling.All,
|
||||
ReferenceLoopHandling = ReferenceLoopHandling.Ignore
|
||||
};
|
||||
|
||||
public static MapField<string, Value> ConvertToValueMap(Dictionary<string, object> source)
|
||||
{
|
||||
var result = new MapField<string, Value>();
|
||||
foreach (var kvp in source)
|
||||
{
|
||||
result[kvp.Key] = kvp.Value switch
|
||||
{
|
||||
string s => Value.ForString(s),
|
||||
int i => Value.ForNumber(i),
|
||||
long l => Value.ForNumber(l),
|
||||
float f => Value.ForNumber(f),
|
||||
double d => Value.ForNumber(d),
|
||||
bool b => Value.ForBool(b),
|
||||
null => Value.ForNull(),
|
||||
_ => Value.ForString(JsonConvert.SerializeObject(kvp.Value, SerializerSettings)) // fallback to JSON string
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static Dictionary<string, object?> ConvertFromValueMap(MapField<string, Value> source)
|
||||
{
|
||||
var result = new Dictionary<string, object?>();
|
||||
foreach (var kvp in source)
|
||||
{
|
||||
var value = kvp.Value;
|
||||
switch (value.KindCase)
|
||||
{
|
||||
case Value.KindOneofCase.StringValue:
|
||||
try
|
||||
{
|
||||
// Try to parse as JSON object or primitive
|
||||
result[kvp.Key] = JsonConvert.DeserializeObject(value.StringValue);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fallback to raw string
|
||||
result[kvp.Key] = value.StringValue;
|
||||
}
|
||||
break;
|
||||
case Value.KindOneofCase.NumberValue:
|
||||
result[kvp.Key] = value.NumberValue;
|
||||
break;
|
||||
case Value.KindOneofCase.BoolValue:
|
||||
result[kvp.Key] = value.BoolValue;
|
||||
break;
|
||||
case Value.KindOneofCase.NullValue:
|
||||
result[kvp.Key] = null;
|
||||
break;
|
||||
case Value.KindOneofCase.StructValue:
|
||||
result[kvp.Key] = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(value.StructValue.Fields.ToDictionary(f => f.Key, f => ConvertField(f.Value)), SerializerSettings));
|
||||
break;
|
||||
case Value.KindOneofCase.ListValue:
|
||||
result[kvp.Key] = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(value.ListValue.Values.Select(ConvertField).ToList(), SerializerSettings));
|
||||
break;
|
||||
default:
|
||||
result[kvp.Key] = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static object? ConvertField(Value value)
|
||||
{
|
||||
return value.KindCase switch
|
||||
{
|
||||
Value.KindOneofCase.StringValue => value.StringValue,
|
||||
Value.KindOneofCase.NumberValue => value.NumberValue,
|
||||
Value.KindOneofCase.BoolValue => value.BoolValue,
|
||||
Value.KindOneofCase.NullValue => null,
|
||||
_ => JsonConvert.DeserializeObject(JsonConvert.SerializeObject(value, SerializerSettings))
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import "google/protobuf/timestamp.proto";
|
||||
import "google/protobuf/wrappers.proto";
|
||||
import "google/protobuf/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;
|
||||
}
|
||||
110
DysonNetwork.Shared/Proto/pusher.proto
Normal file
110
DysonNetwork.Shared/Proto/pusher.proto
Normal file
@@ -0,0 +1,110 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package proto;
|
||||
|
||||
option csharp_namespace = "DysonNetwork.Shared.Proto";
|
||||
|
||||
import "google/protobuf/struct.proto";
|
||||
import "google/protobuf/empty.proto";
|
||||
import "google/protobuf/wrappers.proto";
|
||||
|
||||
// PusherService provides methods to send various types of notifications.
|
||||
service PusherService {
|
||||
// Sends an email.
|
||||
rpc SendEmail(SendEmailRequest) returns (google.protobuf.Empty) {}
|
||||
|
||||
// Pushes a packet to a user via WebSocket.
|
||||
rpc PushWebSocketPacket(PushWebSocketPacketRequest) returns (google.protobuf.Empty) {}
|
||||
|
||||
// Pushes a packet to a list of users via WebSocket.
|
||||
rpc PushWebSocketPacketToUsers(PushWebSocketPacketToUsersRequest) returns (google.protobuf.Empty) {}
|
||||
|
||||
// Pushes a packet to a device via WebSocket.
|
||||
rpc PushWebSocketPacketToDevice(PushWebSocketPacketToDeviceRequest) returns (google.protobuf.Empty) {}
|
||||
|
||||
// Pushes a packet to a list of devices via WebSocket.
|
||||
rpc PushWebSocketPacketToDevices(PushWebSocketPacketToDevicesRequest) returns (google.protobuf.Empty) {}
|
||||
|
||||
// Sends a push notification to a device.
|
||||
rpc SendPushNotification(SendPushNotificationRequest) returns (google.protobuf.Empty) {}
|
||||
|
||||
// Sends a push notification to a list of devices.
|
||||
rpc SendPushNotificationToDevices(SendPushNotificationToDevicesRequest) returns (google.protobuf.Empty) {}
|
||||
|
||||
// Sends a push notification to a user.
|
||||
rpc SendPushNotificationToUser(SendPushNotificationToUserRequest) returns (google.protobuf.Empty) {}
|
||||
|
||||
// Sends a push notification to a list of users.
|
||||
rpc SendPushNotificationToUsers(SendPushNotificationToUsersRequest) returns (google.protobuf.Empty) {}
|
||||
}
|
||||
|
||||
// Represents an email message.
|
||||
message EmailMessage {
|
||||
string to_name = 1;
|
||||
string to_address = 2;
|
||||
string subject = 3;
|
||||
string body = 4;
|
||||
}
|
||||
|
||||
message SendEmailRequest {
|
||||
EmailMessage email = 1;
|
||||
}
|
||||
|
||||
// Represents a WebSocket packet.
|
||||
message WebSocketPacket {
|
||||
string type = 1;
|
||||
google.protobuf.Value data = 2;
|
||||
google.protobuf.StringValue error_message = 3;
|
||||
}
|
||||
|
||||
message PushWebSocketPacketRequest {
|
||||
string user_id = 1;
|
||||
WebSocketPacket packet = 2;
|
||||
}
|
||||
|
||||
message PushWebSocketPacketToUsersRequest {
|
||||
repeated string user_ids = 1;
|
||||
WebSocketPacket packet = 2;
|
||||
}
|
||||
|
||||
message PushWebSocketPacketToDeviceRequest {
|
||||
string device_id = 1;
|
||||
WebSocketPacket packet = 2;
|
||||
}
|
||||
|
||||
message PushWebSocketPacketToDevicesRequest {
|
||||
repeated string device_ids = 1;
|
||||
WebSocketPacket packet = 2;
|
||||
}
|
||||
|
||||
// Represents a push notification.
|
||||
message PushNotification {
|
||||
string topic = 1;
|
||||
string title = 2;
|
||||
string subtitle = 3;
|
||||
string body = 4;
|
||||
map<string, google.protobuf.Value> meta = 5;
|
||||
optional string action_uri = 6;
|
||||
bool is_silent = 7;
|
||||
bool is_savable = 8;
|
||||
}
|
||||
|
||||
message SendPushNotificationRequest {
|
||||
string device_id = 1;
|
||||
PushNotification notification = 2;
|
||||
}
|
||||
|
||||
message SendPushNotificationToDevicesRequest {
|
||||
repeated string device_ids = 1;
|
||||
PushNotification notification = 2;
|
||||
}
|
||||
|
||||
message SendPushNotificationToUserRequest {
|
||||
string user_id = 1;
|
||||
PushNotification notification = 2;
|
||||
}
|
||||
|
||||
message SendPushNotificationToUsersRequest {
|
||||
repeated string user_ids = 1;
|
||||
PushNotification notification = 2;
|
||||
}
|
||||
28
DysonNetwork.Shared/Registry/ServiceRegistry.cs
Normal file
28
DysonNetwork.Shared/Registry/ServiceRegistry.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using System.Text;
|
||||
using dotnet_etcd.interfaces;
|
||||
using Etcdserverpb;
|
||||
using Google.Protobuf;
|
||||
|
||||
namespace DysonNetwork.Shared.Registry;
|
||||
|
||||
public class ServiceRegistry(IEtcdClient etcd)
|
||||
{
|
||||
public async Task RegisterService(string serviceName, string serviceUrl, long leaseTtlSeconds = 60)
|
||||
{
|
||||
var key = $"/services/{serviceName}";
|
||||
var leaseResponse = await etcd.LeaseGrantAsync(new LeaseGrantRequest { TTL = leaseTtlSeconds });
|
||||
await etcd.PutAsync(new PutRequest
|
||||
{
|
||||
Key = ByteString.CopyFrom(key, Encoding.UTF8),
|
||||
Value = ByteString.CopyFrom(serviceUrl, Encoding.UTF8),
|
||||
Lease = leaseResponse.ID
|
||||
});
|
||||
await etcd.LeaseKeepAlive(leaseResponse.ID, CancellationToken.None);
|
||||
}
|
||||
|
||||
public async Task UnregisterService(string serviceName)
|
||||
{
|
||||
var key = $"/services/{serviceName}";
|
||||
await etcd.DeleteAsync(key);
|
||||
}
|
||||
}
|
||||
23
DysonNetwork.Shared/Registry/Startup.cs
Normal file
23
DysonNetwork.Shared/Registry/Startup.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using dotnet_etcd.DependencyInjection;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DysonNetwork.Shared.Registry;
|
||||
|
||||
public static class EtcdStartup
|
||||
{
|
||||
public static IServiceCollection AddEtcdService(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration
|
||||
)
|
||||
{
|
||||
services.AddEtcdClient(options =>
|
||||
{
|
||||
options.ConnectionString = configuration.GetConnectionString("Etcd");
|
||||
options.UseInsecureChannel = configuration.GetValue<bool>("Etcd:Insecure");
|
||||
});
|
||||
services.AddSingleton<ServiceRegistry>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@
|
||||
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" />
|
||||
<PackageReference Include="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" />
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"App": "Host=localhost;Port=5432;Database=dyson_network;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60",
|
||||
"FastRetrieve": "localhost:6379"
|
||||
"FastRetrieve": "localhost:6379",
|
||||
"Etcd": "localhost:2379"
|
||||
},
|
||||
"Authentication": {
|
||||
"Schemes": {
|
||||
@@ -125,5 +126,8 @@
|
||||
"KnownProxies": [
|
||||
"127.0.0.1",
|
||||
"::1"
|
||||
]
|
||||
],
|
||||
"Etcd": {
|
||||
"Insecure": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
||||
<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>
|
||||
@@ -9,6 +10,8 @@
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ABodyBuilder_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003Fc5c8aba04a29d49c65d772c9ffcd93ac7eb38ccbb49a5f506518a0b9bdcaa75_003FBodyBuilder_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ABucketArgs_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fd515fb889657fcdcace3fed90735057b458ff9e0bb60bded7c8fe8b3a4673c_003FBucketArgs_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ABucketArgs_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003Fd515fb889657fcdcace3fed90735057b458ff9e0bb60bded7c8fe8b3a4673c_003FBucketArgs_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AByteString_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F331aca3f6f414013b09964063341351379060_003F2e_003F1935b2a7_003FByteString_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ACallInvoker_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003F85d5cf7bc00d9afe6109255f942125d252e7d3bf3d33f44c445162ab59e52b_003FCallInvoker_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AChapterData_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ffef366b36a224d469ff150d30f9a866d23c00_003Fe6_003F64a6c0f7_003FChapterData_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AClaim_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fa7fdc52b6e574ae7b9822133be91162a15800_003Ff7_003Feebffd8d_003FClaim_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AConnectionMultiplexer_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003F2ed0e2f073b1d77b98dadb822da09ee8a9dfb91bf29bf2bbaecb8750d7e74cc9_003FConnectionMultiplexer_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
@@ -38,11 +41,14 @@
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AExifTag_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fef3339e864a448e2b1ec6fa7bbf4c6661fee00_003F5c_003F8ed75f18_003FExifTag_002Ecs_002Fz_003A2_002D1/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AFileResult_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F0b5acdd962e549369896cece0026e556214600_003F8c_003F9f6e3f4f_003FFileResult_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AForwardedHeaders_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fcfe5737f9bb84738979cbfedd11822a8ea00_003F50_003F9a335f87_003FForwardedHeaders_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHttpRequestHeaders_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb904f9896c4049fabd596decf1be9c381dc400_003F32_003F906beb77_003FHttpRequestHeaders_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHttpUtility_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F95cd5fa21c574d4087dec626d8227d77be00_003F08_003Fdd41228e_003FHttpUtility_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIConfiguration_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fbb55221b2bd14b31a20b0d8bdcc7ff457328_003F19_003F707d23be_003FIConfiguration_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIEtcdClient_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F866376757aa64634b820c41d3553727886400_003Fbb_003F0fd3f8d7_003FIEtcdClient_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIHtmlString_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F95cd5fa21c574d4087dec626d8227d77be00_003Ff1_003F3a8957fa_003FIHtmlString_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_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