♻️ Still don't know what I am doing

This commit is contained in:
2025-07-13 23:38:57 +08:00
parent 03e26ef93c
commit cde55eb237
23 changed files with 300 additions and 170 deletions

View File

@ -402,16 +402,17 @@ public class AccountService(
return; return;
} }
await mailer.SendTemplatedEmailAsync<DysonNetwork.Pass.Pages.Emails.VerificationEmail, VerificationEmailModel>( await mailer
account.Nick, .SendTemplatedEmailAsync<Pages.Emails.VerificationEmail, VerificationEmailModel>(
contact.Content, account.Nick,
localizer["VerificationEmail"], contact.Content,
new VerificationEmailModel localizer["VerificationEmail"],
{ new VerificationEmailModel
Name = account.Name, {
Code = code Name = account.Name,
} Code = code
); }
);
await _SetFactorCode(factor, code, TimeSpan.FromMinutes(30)); await _SetFactorCode(factor, code, TimeSpan.FromMinutes(30));
break; break;
@ -496,7 +497,10 @@ public class AccountService(
.ToListAsync(); .ToListAsync();
if (session.Challenge.DeviceId is not null) if (session.Challenge.DeviceId is not null)
await pusher.UnsubscribePushNotifications(session.Challenge.DeviceId); await pusher.UnsubscribePushNotificationsAsync(new UnsubscribePushNotificationsRequest()
{
DeviceId = session.Challenge.DeviceId
});
// The current session should be included in the sessions' list // The current session should be included in the sessions' list
await db.AuthSessions await db.AuthSessions
@ -655,7 +659,8 @@ public class AccountService(
if (missingId.Count != 0) if (missingId.Count != 0)
{ {
var newProfiles = missingId.Select(id => new AccountProfile { Id = Guid.NewGuid(), AccountId = id }).ToList(); var newProfiles = missingId.Select(id => new AccountProfile { Id = Guid.NewGuid(), AccountId = id })
.ToList();
await db.BulkInsertAsync(newProfiles); await db.BulkInsertAsync(newProfiles);
} }
} }

View File

@ -36,6 +36,24 @@ public class AccountServiceGrpc(
return account.ToProtoValue(); return account.ToProtoValue();
} }
public override async Task<GetAccountBatchResponse> GetAccountBatch(GetAccountBatchRequest request, ServerCallContext context)
{
var accountIds = request.Id
.Select(id => Guid.TryParse(id, out var accountId) ? accountId : (Guid?)null)
.Where(id => id.HasValue)
.Select(id => id!.Value)
.ToList();
var accounts = await _db.Accounts
.AsNoTracking()
.Where(a => accountIds.Contains(a.Id))
.ToListAsync();
var response = new GetAccountBatchResponse();
response.Accounts.AddRange(accounts.Select(a => a.ToProtoValue()));
return response;
}
public override async Task<Shared.Proto.Account> CreateAccount(CreateAccountRequest request, public override async Task<Shared.Proto.Account> CreateAccount(CreateAccountRequest request,
ServerCallContext context) ServerCallContext context)
{ {

View File

@ -6,18 +6,11 @@ using Microsoft.AspNetCore.Components;
namespace DysonNetwork.Pass.Email; namespace DysonNetwork.Pass.Email;
public class EmailService( public class EmailService(
IEtcdClient etcd, PusherService.PusherServiceClient pusher,
RazorViewRenderer viewRenderer, RazorViewRenderer viewRenderer,
IConfiguration configuration,
ILogger<EmailService> logger ILogger<EmailService> logger
) )
{ {
private readonly PusherService.PusherServiceClient _client = GrpcClientHelper.CreatePusherServiceClient(
etcd,
configuration["Service:CertPath"]!,
configuration["Service:KeyPath"]!
).GetAwaiter().GetResult();
public async Task SendEmailAsync( public async Task SendEmailAsync(
string? recipientName, string? recipientName,
string recipientEmail, string recipientEmail,
@ -27,7 +20,7 @@ public class EmailService(
{ {
subject = $"[Solarpass] {subject}"; subject = $"[Solarpass] {subject}";
await _client.SendEmailAsync( await pusher.SendEmailAsync(
new SendEmailRequest() new SendEmailRequest()
{ {
Email = new EmailMessage() Email = new EmailMessage()

View File

@ -2,7 +2,7 @@ namespace DysonNetwork.Pass.Permission;
using System; using System;
[AttributeUsage(AttributeTargets.Method, Inherited = true)] [AttributeUsage(AttributeTargets.Method)]
public class RequiredPermissionAttribute(string area, string key) : Attribute public class RequiredPermissionAttribute(string area, string key) : Attribute
{ {
public string Area { get; set; } = area; public string Area { get; set; } = area;

View File

@ -19,6 +19,7 @@ using DysonNetwork.Pass.Handlers;
using DysonNetwork.Pass.Wallet.PaymentHandlers; using DysonNetwork.Pass.Wallet.PaymentHandlers;
using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.GeoIp; using DysonNetwork.Shared.GeoIp;
using DysonNetwork.Shared.Registry;
namespace DysonNetwork.Pass.Startup; namespace DysonNetwork.Pass.Startup;
@ -48,9 +49,8 @@ public static class ServiceCollectionExtensions
options.MaxSendMessageSize = 16 * 1024 * 1024; // 16MB options.MaxSendMessageSize = 16 * 1024 * 1024; // 16MB
}); });
// Register gRPC reflection for service discovery services.AddPusherService();
services.AddGrpc();
// Register gRPC services // Register gRPC services
services.AddScoped<AccountServiceGrpc>(); services.AddScoped<AccountServiceGrpc>();
services.AddScoped<AuthServiceGrpc>(); services.AddScoped<AuthServiceGrpc>();
@ -194,7 +194,6 @@ public static class ServiceCollectionExtensions
services.AddScoped<ActionLogService>(); services.AddScoped<ActionLogService>();
services.AddScoped<RelationshipService>(); services.AddScoped<RelationshipService>();
services.AddScoped<MagicSpellService>(); services.AddScoped<MagicSpellService>();
services.AddScoped<NotificationService>();
services.AddScoped<AuthService>(); services.AddScoped<AuthService>();
services.AddScoped<AccountUsernameService>(); services.AddScoped<AccountUsernameService>();
services.AddScoped<WalletService>(); services.AddScoped<WalletService>();

View File

@ -1,17 +1,18 @@
using System.Globalization; using System.Globalization;
using DysonNetwork.Pass.Account;
using DysonNetwork.Pass.Localization; using DysonNetwork.Pass.Localization;
using DysonNetwork.Shared.Proto;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage; using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
using NodaTime; using NodaTime;
using AccountService = DysonNetwork.Pass.Account.AccountService;
namespace DysonNetwork.Pass.Wallet; namespace DysonNetwork.Pass.Wallet;
public class PaymentService( public class PaymentService(
AppDatabase db, AppDatabase db,
WalletService wat, WalletService wat,
NotificationService nty, PusherService.PusherServiceClient pusher,
IStringLocalizer<NotificationResource> localizer IStringLocalizer<NotificationResource> localizer
) )
{ {
@ -205,16 +206,19 @@ public class PaymentService(
var readableOrderId = order.Id.ToString().Replace("-", "")[..8]; var readableOrderId = order.Id.ToString().Replace("-", "")[..8];
var readableOrderRemark = order.Remarks ?? $"#{readableOrderId}"; var readableOrderRemark = order.Remarks ?? $"#{readableOrderId}";
await nty.SendNotification(
account, await pusher.SendPushNotificationToUserAsync(
"wallets.orders.paid", new SendPushNotificationToUserRequest
localizer["OrderPaidTitle", $"#{readableOrderId}"],
null,
localizer["OrderPaidBody", order.Amount.ToString(CultureInfo.InvariantCulture), order.Currency,
readableOrderRemark],
new Dictionary<string, object>()
{ {
["order_id"] = order.Id.ToString() UserId = account.Id.ToString(),
Notification = new PushNotification
{
Topic = "wallets.orders.paid",
Title = localizer["OrderPaidTitle", $"#{readableOrderId}"],
Body = localizer["OrderPaidBody", order.Amount.ToString(CultureInfo.InvariantCulture), order.Currency,
readableOrderRemark],
IsSavable = false
}
} }
); );
} }

View File

@ -1,11 +1,14 @@
using System.Text.Json; using System.Text.Json;
using DysonNetwork.Pass.Account;
using DysonNetwork.Pass.Localization; using DysonNetwork.Pass.Localization;
using DysonNetwork.Pass.Wallet.PaymentHandlers; using DysonNetwork.Pass.Wallet.PaymentHandlers;
using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Proto;
using Google.Protobuf.WellKnownTypes;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
using NodaTime; using NodaTime;
using AccountService = DysonNetwork.Pass.Account.AccountService;
using Duration = NodaTime.Duration;
namespace DysonNetwork.Pass.Wallet; namespace DysonNetwork.Pass.Wallet;
@ -13,7 +16,7 @@ public class SubscriptionService(
AppDatabase db, AppDatabase db,
PaymentService payment, PaymentService payment,
AccountService accounts, AccountService accounts,
NotificationService nty, PusherService.PusherServiceClient pusher,
IStringLocalizer<NotificationResource> localizer, IStringLocalizer<NotificationResource> localizer,
IConfiguration configuration, IConfiguration configuration,
ICacheService cache, ICacheService cache,
@ -352,15 +355,19 @@ public class SubscriptionService(
? subscription.EndedAt.Value.Minus(subscription.BegunAt).Days.ToString() ? subscription.EndedAt.Value.Minus(subscription.BegunAt).Days.ToString()
: "infinite"; : "infinite";
await nty.SendNotification( var notification = new PushNotification
account, {
"subscriptions.begun", Topic = "subscriptions.begun",
localizer["SubscriptionAppliedTitle", humanReadableName], Title = localizer["SubscriptionAppliedTitle", humanReadableName],
null, Body = localizer["SubscriptionAppliedBody", duration, humanReadableName],
localizer["SubscriptionAppliedBody", duration, humanReadableName], IsSavable = false,
new Dictionary<string, object>() };
notification.Meta.Add("subscription_id", Value.ForString(subscription.Id.ToString()));
await pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest
{ {
["subscription_id"] = subscription.Id.ToString(), UserId = account.Id.ToString(),
Notification = notification
} }
); );
} }

View File

@ -20,31 +20,6 @@ public class WebSocketService
private static readonly ConcurrentDictionary<string, string> ActiveSubscriptions = new(); // deviceId -> chatRoomId private static readonly ConcurrentDictionary<string, string> ActiveSubscriptions = new(); // deviceId -> chatRoomId
public void SubscribeToChatRoom(string chatRoomId, string deviceId)
{
ActiveSubscriptions[deviceId] = chatRoomId;
}
public void UnsubscribeFromChatRoom(string deviceId)
{
ActiveSubscriptions.TryRemove(deviceId, out _);
}
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)
{
return true;
}
}
return false;
}
public bool TryAdd( public bool TryAdd(
(string AccountId, string DeviceId) key, (string AccountId, string DeviceId) key,
WebSocket socket, WebSocket socket,
@ -67,7 +42,11 @@ public class WebSocketService
); );
data.Cts.Cancel(); data.Cts.Cancel();
ActiveConnections.TryRemove(key, out _); ActiveConnections.TryRemove(key, out _);
UnsubscribeFromChatRoom(key.DeviceId); }
public bool GetDeviceIsConnected(string deviceId)
{
return ActiveConnections.Any(c => c.Key.DeviceId == deviceId);
} }
public bool GetAccountIsConnected(string accountId) public bool GetAccountIsConnected(string accountId)

View File

@ -1,15 +1,20 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using AccountService = DysonNetwork.Shared.Proto.AccountService;
namespace DysonNetwork.Pusher.Notification; namespace DysonNetwork.Pusher.Notification;
[ApiController] [ApiController]
[Route("/api/notifications")] [Route("/api/notifications")]
public class NotificationController(AppDatabase db, NotificationService nty) : ControllerBase public class NotificationController(
AppDatabase db,
PushService nty,
AccountService.AccountServiceClient accounts) : ControllerBase
{ {
[HttpGet("count")] [HttpGet("count")]
[Authorize] [Authorize]
@ -17,9 +22,10 @@ public class NotificationController(AppDatabase db, NotificationService nty) : C
{ {
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
if (currentUserValue is not Account currentUser) return Unauthorized(); if (currentUserValue is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var count = await db.Notifications var count = await db.Notifications
.Where(s => s.AccountId == currentUser.Id && s.ViewedAt == null) .Where(s => s.AccountId == accountId && s.ViewedAt == null)
.CountAsync(); .CountAsync();
return Ok(count); return Ok(count);
} }
@ -30,24 +36,25 @@ public class NotificationController(AppDatabase db, NotificationService nty) : C
[FromQuery] int offset = 0, [FromQuery] int offset = 0,
// The page size set to 5 is to avoid the client pulled the notification // The page size set to 5 is to avoid the client pulled the notification
// but didn't render it in the screen-viewable region. // but didn't render it in the screen-viewable region.
[FromQuery] int take = 5 [FromQuery] int take = 8
) )
{ {
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
if (currentUserValue is not Account currentUser) return Unauthorized(); if (currentUserValue is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var totalCount = await db.Notifications var totalCount = await db.Notifications
.Where(s => s.AccountId == currentUser.Id) .Where(s => s.AccountId == accountId)
.CountAsync(); .CountAsync();
var notifications = await db.Notifications var notifications = await db.Notifications
.Where(s => s.AccountId == currentUser.Id) .Where(s => s.AccountId == accountId)
.OrderByDescending(e => e.CreatedAt) .OrderByDescending(e => e.CreatedAt)
.Skip(offset) .Skip(offset)
.Take(take) .Take(take)
.ToListAsync(); .ToListAsync();
Response.Headers["X-Total"] = totalCount.ToString(); Response.Headers["X-Total"] = totalCount.ToString();
await nty.MarkNotificationsViewed(notifications); await nty.MarkNotificationsViewed(notifications.ToList());
return Ok(notifications); return Ok(notifications);
} }
@ -55,14 +62,15 @@ public class NotificationController(AppDatabase db, NotificationService nty) : C
public class PushNotificationSubscribeRequest public class PushNotificationSubscribeRequest
{ {
[MaxLength(4096)] public string DeviceToken { get; set; } = null!; [MaxLength(4096)] public string DeviceToken { get; set; } = null!;
public NotificationPushProvider Provider { get; set; } public PushProvider Provider { get; set; }
} }
[HttpPut("subscription")] [HttpPut("subscription")]
[Authorize] [Authorize]
public async Task<ActionResult<NotificationPushSubscription>> SubscribeToPushNotification( public async Task<ActionResult<PushSubscription>>
[FromBody] PushNotificationSubscribeRequest request SubscribeToPushNotification(
) [FromBody] PushNotificationSubscribeRequest request
)
{ {
HttpContext.Items.TryGetValue("CurrentSession", out var currentSessionValue); HttpContext.Items.TryGetValue("CurrentSession", out var currentSessionValue);
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
@ -72,8 +80,12 @@ public class NotificationController(AppDatabase db, NotificationService nty) : C
if (currentSession == null) return Unauthorized(); if (currentSession == null) return Unauthorized();
var result = var result =
await nty.SubscribePushNotification(currentUser, request.Provider, currentSession.Challenge.DeviceId!, await nty.SubscribeDevice(
request.DeviceToken); currentSession.Challenge.DeviceId!,
request.DeviceToken,
request.Provider,
currentUser
);
return Ok(result); return Ok(result);
} }
@ -88,10 +100,11 @@ public class NotificationController(AppDatabase db, NotificationService nty) : C
if (currentUser == null) return Unauthorized(); if (currentUser == null) return Unauthorized();
var currentSession = currentSessionValue as AuthSession; var currentSession = currentSessionValue as AuthSession;
if (currentSession == null) return Unauthorized(); if (currentSession == null) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var affectedRows = await db.NotificationPushSubscriptions var affectedRows = await db.PushSubscriptions
.Where(s => .Where(s =>
s.AccountId == currentUser.Id && s.AccountId == accountId &&
s.DeviceId == currentSession.Challenge.DeviceId s.DeviceId == currentSession.Challenge.DeviceId
).ExecuteDeleteAsync(); ).ExecuteDeleteAsync();
return Ok(affectedRows); return Ok(affectedRows);
@ -107,36 +120,11 @@ public class NotificationController(AppDatabase db, NotificationService nty) : C
public int Priority { get; set; } = 10; public int Priority { get; set; } = 10;
} }
[HttpPost("broadcast")]
[Authorize]
[RequiredPermission("global", "notifications.broadcast")]
public async Task<ActionResult> BroadcastNotification(
[FromBody] NotificationRequest request,
[FromQuery] bool save = false
)
{
await nty.BroadcastNotification(
new Notification
{
CreatedAt = SystemClock.Instance.GetCurrentInstant(),
UpdatedAt = SystemClock.Instance.GetCurrentInstant(),
Topic = request.Topic,
Title = request.Title,
Subtitle = request.Subtitle,
Content = request.Content,
Meta = request.Meta,
Priority = request.Priority,
},
save
);
return Ok();
}
public class NotificationWithAimRequest : NotificationRequest public class NotificationWithAimRequest : NotificationRequest
{ {
[Required] public List<Guid> AccountId { get; set; } = null!; [Required] public List<Guid> AccountId { get; set; } = null!;
} }
[HttpPost("send")] [HttpPost("send")]
[Authorize] [Authorize]
[RequiredPermission("global", "notifications.send")] [RequiredPermission("global", "notifications.send")]
@ -145,7 +133,6 @@ public class NotificationController(AppDatabase db, NotificationService nty) : C
[FromQuery] bool save = false [FromQuery] bool save = false
) )
{ {
var accounts = await db.Accounts.Where(a => request.AccountId.Contains(a.Id)).ToListAsync();
await nty.SendNotificationBatch( await nty.SendNotificationBatch(
new Notification new Notification
{ {
@ -157,7 +144,7 @@ public class NotificationController(AppDatabase db, NotificationService nty) : C
Content = request.Content, Content = request.Content,
Meta = request.Meta, Meta = request.Meta,
}, },
accounts, request.AccountId,
save save
); );
return Ok(); return Ok();

View File

@ -12,14 +12,14 @@ public class PushService(IConfiguration config, AppDatabase db, IHttpClientFacto
private readonly string _notifyTopic = config["Notifications:Topic"]!; private readonly string _notifyTopic = config["Notifications:Topic"]!;
private readonly Uri _notifyEndpoint = new(config["Notifications:Endpoint"]!); private readonly Uri _notifyEndpoint = new(config["Notifications:Endpoint"]!);
public async Task UnsubscribePushNotifications(string deviceId) public async Task UnsubscribeDevice(string deviceId)
{ {
await db.PushSubscriptions await db.PushSubscriptions
.Where(s => s.DeviceId == deviceId) .Where(s => s.DeviceId == deviceId)
.ExecuteDeleteAsync(); .ExecuteDeleteAsync();
} }
public async Task<PushSubscription> SubscribePushNotification( public async Task<PushSubscription> SubscribeDevice(
string deviceId, string deviceId,
string deviceToken, string deviceToken,
PushProvider provider, PushProvider provider,

View File

@ -9,7 +9,7 @@ namespace DysonNetwork.Pusher.Services;
public class PusherServiceGrpc( public class PusherServiceGrpc(
EmailService emailService, EmailService emailService,
WebSocketService webSocketService, WebSocketService websocket,
PushService pushService PushService pushService
) : PusherService.PusherServiceBase ) : PusherService.PusherServiceBase
{ {
@ -32,7 +32,7 @@ public class PusherServiceGrpc(
Data = request.Packet.Data, Data = request.Packet.Data,
ErrorMessage = request.Packet.ErrorMessage ErrorMessage = request.Packet.ErrorMessage
}; };
webSocketService.SendPacketToAccount(request.UserId, packet); websocket.SendPacketToAccount(request.UserId, packet);
return Task.FromResult(new Empty()); return Task.FromResult(new Empty());
} }
@ -46,7 +46,7 @@ public class PusherServiceGrpc(
ErrorMessage = request.Packet.ErrorMessage ErrorMessage = request.Packet.ErrorMessage
}; };
foreach (var userId in request.UserIds) foreach (var userId in request.UserIds)
webSocketService.SendPacketToAccount(userId, packet); websocket.SendPacketToAccount(userId, packet);
return Task.FromResult(new Empty()); return Task.FromResult(new Empty());
} }
@ -60,7 +60,7 @@ public class PusherServiceGrpc(
Data = request.Packet.Data, Data = request.Packet.Data,
ErrorMessage = request.Packet.ErrorMessage ErrorMessage = request.Packet.ErrorMessage
}; };
webSocketService.SendPacketToDevice(request.DeviceId, packet); websocket.SendPacketToDevice(request.DeviceId, packet);
return Task.FromResult(new Empty()); return Task.FromResult(new Empty());
} }
@ -74,7 +74,7 @@ public class PusherServiceGrpc(
ErrorMessage = request.Packet.ErrorMessage ErrorMessage = request.Packet.ErrorMessage
}; };
foreach (var deviceId in request.DeviceIds) foreach (var deviceId in request.DeviceIds)
webSocketService.SendPacketToDevice(deviceId, packet); websocket.SendPacketToDevice(deviceId, packet);
return Task.FromResult(new Empty()); return Task.FromResult(new Empty());
} }
@ -159,4 +159,22 @@ public class PusherServiceGrpc(
await pushService.SendNotificationBatch(notification, accounts, request.Notification.IsSavable); await pushService.SendNotificationBatch(notification, accounts, request.Notification.IsSavable);
return new Empty(); return new Empty();
} }
public override async Task<Empty> UnsubscribePushNotifications(UnsubscribePushNotificationsRequest request, ServerCallContext context)
{
await pushService.UnsubscribeDevice(request.DeviceId);
return new Empty();
}
public override Task<GetWebsocketConnectionStatusResponse> GetWebsocketConnectionStatus(GetWebsocketConnectionStatusRequest request, ServerCallContext context)
{
var isConnected = request.IdCase switch
{
GetWebsocketConnectionStatusRequest.IdOneofCase.DeviceId => websocket.GetDeviceIsConnected(request.DeviceId),
GetWebsocketConnectionStatusRequest.IdOneofCase.UserId => websocket.GetAccountIsConnected(request.UserId),
_ => false
};
return Task.FromResult(new GetWebsocketConnectionStatusResponse { IsConnected = isConnected });
}
} }

View File

@ -16,10 +16,9 @@ public class DysonTokenAuthHandler(
IOptionsMonitor<DysonTokenAuthOptions> options, IOptionsMonitor<DysonTokenAuthOptions> options,
ILoggerFactory logger, ILoggerFactory logger,
UrlEncoder encoder, UrlEncoder encoder,
ISystemClock clock,
AuthService.AuthServiceClient auth AuthService.AuthServiceClient auth
) )
: AuthenticationHandler<DysonTokenAuthOptions>(options, logger, encoder, clock) : AuthenticationHandler<DysonTokenAuthOptions>(options, logger, encoder)
{ {
protected override async Task<AuthenticateResult> HandleAuthenticateAsync() protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{ {

View File

@ -0,0 +1,72 @@
using DysonNetwork.Shared.Proto;
using Grpc.Core;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace DysonNetwork.Shared.Auth
{
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
public class RequiredPermissionAttribute(string area, string key) : Attribute
{
public string Area { get; set; } = area;
public string Key { get; } = key;
}
public class PermissionMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext httpContext, PermissionService.PermissionServiceClient permissionService, ILogger<PermissionMiddleware> logger)
{
var endpoint = httpContext.GetEndpoint();
var attr = endpoint?.Metadata
.OfType<RequiredPermissionAttribute>()
.FirstOrDefault();
if (attr != null)
{
if (httpContext.Items["CurrentUser"] is not Account currentUser)
{
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
await httpContext.Response.WriteAsync("Unauthorized");
return;
}
// Assuming Account proto has a bool field 'is_superuser' which is generated as 'IsSuperuser'
if (currentUser.IsSuperuser)
{
// Bypass the permission check for performance
await next(httpContext);
return;
}
var actor = $"user:{currentUser.Id}";
try
{
var permResp = await permissionService.HasPermissionAsync(new HasPermissionRequest
{
Actor = actor,
Area = attr.Area,
Key = attr.Key
});
if (!permResp.HasPermission)
{
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
await httpContext.Response.WriteAsync($"Permission {attr.Area}/{attr.Key} was required.");
return;
}
}
catch (RpcException ex)
{
logger.LogError(ex, "gRPC call to PermissionService failed while checking permission {Area}/{Key} for actor {Actor}", attr.Area, attr.Key, actor);
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
await httpContext.Response.WriteAsync("Error checking permissions.");
return;
}
}
await next(httpContext);
}
}
}

View File

@ -16,9 +16,9 @@ public static class DysonAuthStartup
{ {
var etcdClient = sp.GetRequiredService<IEtcdClient>(); var etcdClient = sp.GetRequiredService<IEtcdClient>();
var config = sp.GetRequiredService<IConfiguration>(); var config = sp.GetRequiredService<IConfiguration>();
var clientCertPath = config["ClientCert:Path"]; var clientCertPath = config["Service:ClientCert"];
var clientKeyPath = config["ClientKey:Path"]; var clientKeyPath = config["Service:ClientKey"];
var clientCertPassword = config["ClientCert:Password"]; var clientCertPassword = config["Service:CertPassword"];
return GrpcClientHelper return GrpcClientHelper
.CreateAuthServiceClient(etcdClient, clientCertPath, clientKeyPath, clientCertPassword) .CreateAuthServiceClient(etcdClient, clientCertPath, clientKeyPath, clientCertPassword)

View File

@ -19,8 +19,7 @@
</PackageReference> </PackageReference>
<PackageReference Include="MaxMind.GeoIP2" Version="5.3.0" /> <PackageReference Include="MaxMind.GeoIP2" Version="5.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication" Version="2.3.0" /> <PackageReference Include="Microsoft.AspNetCore.Authentication" Version="2.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.3.0" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7" />
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.7" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.7" />
<PackageReference Include="NetTopologySuite" Version="2.6.0" /> <PackageReference Include="NetTopologySuite" Version="2.6.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />

View File

@ -33,17 +33,6 @@ public static class GrpcClientHelper
return response.Kvs[0].Value.ToStringUtf8(); 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( public static async Task<AccountService.AccountServiceClient> CreateAccountServiceClient(
IEtcdClient etcdClient, IEtcdClient etcdClient,
string clientCertPath, string clientCertPath,
@ -51,22 +40,11 @@ public static class GrpcClientHelper
string? clientCertPassword = null string? clientCertPassword = null
) )
{ {
var url = await GetServiceUrlFromEtcd(etcdClient, "AccountService"); var url = await GetServiceUrlFromEtcd(etcdClient, "DysonNetwork.Pass");
return new AccountService.AccountServiceClient(CreateCallInvoker(url, clientCertPath, clientKeyPath, return new AccountService.AccountServiceClient(CreateCallInvoker(url, clientCertPath, clientKeyPath,
clientCertPassword)); 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( public static async Task<AuthService.AuthServiceClient> CreateAuthServiceClient(
IEtcdClient etcdClient, IEtcdClient etcdClient,
string clientCertPath, string clientCertPath,
@ -74,22 +52,11 @@ public static class GrpcClientHelper
string? clientCertPassword = null string? clientCertPassword = null
) )
{ {
var url = await GetServiceUrlFromEtcd(etcdClient, "AuthService"); var url = await GetServiceUrlFromEtcd(etcdClient, "DysonNetwork.Pass");
return new AuthService.AuthServiceClient(CreateCallInvoker(url, clientCertPath, clientKeyPath, return new AuthService.AuthServiceClient(CreateCallInvoker(url, clientCertPath, clientKeyPath,
clientCertPassword)); 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( public static async Task<PusherService.PusherServiceClient> CreatePusherServiceClient(
IEtcdClient etcdClient, IEtcdClient etcdClient,
string clientCertPath, string clientCertPath,
@ -97,7 +64,7 @@ public static class GrpcClientHelper
string? clientCertPassword = null string? clientCertPassword = null
) )
{ {
var url = await GetServiceUrlFromEtcd(etcdClient, "PusherService"); var url = await GetServiceUrlFromEtcd(etcdClient, "DysonNetwork.Pusher");
return new PusherService.PusherServiceClient(CreateCallInvoker(url, clientCertPath, clientKeyPath, return new PusherService.PusherServiceClient(CreateCallInvoker(url, clientCertPath, clientKeyPath,
clientCertPassword)); clientCertPassword));
} }

View File

@ -195,6 +195,7 @@ message LevelingInfo {
service AccountService { service AccountService {
// Account Operations // Account Operations
rpc GetAccount(GetAccountRequest) returns (Account) {} rpc GetAccount(GetAccountRequest) returns (Account) {}
rpc GetAccountBatch(GetAccountBatchRequest) returns (GetAccountBatchResponse) {}
rpc CreateAccount(CreateAccountRequest) returns (Account) {} rpc CreateAccount(CreateAccountRequest) returns (Account) {}
rpc UpdateAccount(UpdateAccountRequest) returns (Account) {} rpc UpdateAccount(UpdateAccountRequest) returns (Account) {}
rpc DeleteAccount(DeleteAccountRequest) returns (google.protobuf.Empty) {} rpc DeleteAccount(DeleteAccountRequest) returns (google.protobuf.Empty) {}
@ -243,6 +244,14 @@ message GetAccountRequest {
string id = 1; // Account ID to retrieve string id = 1; // Account ID to retrieve
} }
message GetAccountBatchRequest {
repeated string id = 1; // Account ID to retrieve
}
message GetAccountBatchResponse {
repeated Account accounts = 1; // List of accounts
}
message CreateAccountRequest { message CreateAccountRequest {
string name = 1; // Required: Unique username string name = 1; // Required: Unique username
string nick = 2; // Optional: Display name string nick = 2; // Optional: Display name

View File

@ -36,6 +36,12 @@ service PusherService {
// Sends a push notification to a list of users. // Sends a push notification to a list of users.
rpc SendPushNotificationToUsers(SendPushNotificationToUsersRequest) returns (google.protobuf.Empty) {} rpc SendPushNotificationToUsers(SendPushNotificationToUsersRequest) returns (google.protobuf.Empty) {}
// Unsubscribes a device from push notifications.
rpc UnsubscribePushNotifications(UnsubscribePushNotificationsRequest) returns (google.protobuf.Empty) {}
// Gets the WebSocket connection status for a device or user.
rpc GetWebsocketConnectionStatus(GetWebsocketConnectionStatusRequest) returns (GetWebsocketConnectionStatusResponse) {}
} }
// Represents an email message. // Represents an email message.
@ -108,3 +114,18 @@ message SendPushNotificationToUsersRequest {
repeated string user_ids = 1; repeated string user_ids = 1;
PushNotification notification = 2; PushNotification notification = 2;
} }
message UnsubscribePushNotificationsRequest {
string device_id = 1;
}
message GetWebsocketConnectionStatusRequest {
oneof id {
string device_id = 1;
string user_id = 2;
}
}
message GetWebsocketConnectionStatusResponse {
bool is_connected = 1;
}

View File

@ -0,0 +1,47 @@
using dotnet_etcd.interfaces;
using DysonNetwork.Shared.Proto;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace DysonNetwork.Shared.Registry;
public static class ServiceHelper
{
public static IServiceCollection AddPusherService(this IServiceCollection services)
{
services.AddSingleton<PusherService.PusherServiceClient>(sp =>
{
var etcdClient = sp.GetRequiredService<IEtcdClient>();
var config = sp.GetRequiredService<IConfiguration>();
var clientCertPath = config["Service:ClientCert"];
var clientKeyPath = config["Service:ClientKey"];
var clientCertPassword = config["Service:CertPassword"];
return GrpcClientHelper
.CreatePusherServiceClient(etcdClient, clientCertPath, clientKeyPath, clientCertPassword)
.GetAwaiter()
.GetResult();
});
return services;
}
public static IServiceCollection AddAccountService(this IServiceCollection services)
{
services.AddSingleton<AccountService.AccountServiceClient>(sp =>
{
var etcdClient = sp.GetRequiredService<IEtcdClient>();
var config = sp.GetRequiredService<IConfiguration>();
var clientCertPath = config["Service:ClientCert"];
var clientKeyPath = config["Service:ClientKey"];
var clientCertPassword = config["Service:CertPassword"];
return GrpcClientHelper
.CreateAccountServiceClient(etcdClient, clientCertPath, clientKeyPath, clientCertPassword)
.GetAwaiter()
.GetResult();
});
return services;
}
}

View File

@ -8,16 +8,20 @@ namespace DysonNetwork.Shared.Registry;
public class ServiceRegistry(IEtcdClient etcd, ILogger<ServiceRegistry> logger) public class ServiceRegistry(IEtcdClient etcd, ILogger<ServiceRegistry> logger)
{ {
public async Task RegisterService(string serviceName, string serviceUrl, long leaseTtlSeconds = 60, CancellationToken cancellationToken = default) public async Task RegisterService(string serviceName, string serviceUrl, long leaseTtlSeconds = 60,
CancellationToken cancellationToken = default)
{ {
var key = $"/services/{serviceName}"; var key = $"/services/{serviceName}";
var leaseResponse = await etcd.LeaseGrantAsync(new LeaseGrantRequest { TTL = leaseTtlSeconds }); var leaseResponse = await etcd.LeaseGrantAsync(
new LeaseGrantRequest { TTL = leaseTtlSeconds },
cancellationToken: cancellationToken
);
await etcd.PutAsync(new PutRequest await etcd.PutAsync(new PutRequest
{ {
Key = ByteString.CopyFrom(key, Encoding.UTF8), Key = ByteString.CopyFrom(key, Encoding.UTF8),
Value = ByteString.CopyFrom(serviceUrl, Encoding.UTF8), Value = ByteString.CopyFrom(serviceUrl, Encoding.UTF8),
Lease = leaseResponse.ID Lease = leaseResponse.ID
}); }, cancellationToken: cancellationToken);
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {

View File

@ -190,7 +190,6 @@ public class NotificationService(
Content = notification.Content, Content = notification.Content,
Meta = notification.Meta, Meta = notification.Meta,
Priority = notification.Priority, Priority = notification.Priority,
Account = x,
AccountId = x.Id AccountId = x.Id
}; };
return newNotification; return newNotification;

View File

@ -30,7 +30,7 @@
<PackageReference Include="Markdig" Version="0.41.3" /> <PackageReference Include="Markdig" Version="0.41.3" />
<PackageReference Include="MaxMind.GeoIP2" Version="5.3.0" /> <PackageReference Include="MaxMind.GeoIP2" Version="5.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.7" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.7" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.2" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.3"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.3">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View File

@ -3,6 +3,7 @@
<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_003AAny_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F331aca3f6f414013b09964063341351379060_003F67_003F87f868e3_003FAny_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AApnSender_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F6aadc2cf048f477d8636fb2def7b73648200_003Fc5_003F2a1973a9_003FApnSender_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AApnSender_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F6aadc2cf048f477d8636fb2def7b73648200_003Fc5_003F2a1973a9_003FApnSender_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AApnSettings_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F6aadc2cf048f477d8636fb2def7b73648200_003F0f_003F51443844_003FApnSettings_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AApnSettings_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F6aadc2cf048f477d8636fb2def7b73648200_003F0f_003F51443844_003FApnSettings_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AArgumentNullException_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fe6898c1ddf974e16b95b114722270029e55000_003Faf_003F30ff0e5c_003FArgumentNullException_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAuthenticationHandler_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F1f1354e4dbf943ecb04840af5ff9a527fa20_003F5d_003F1fb111f6_003FAuthenticationHandler_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAuthenticationHandler_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F1f1354e4dbf943ecb04840af5ff9a527fa20_003F5d_003F1fb111f6_003FAuthenticationHandler_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAuthenticationMiddleware_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fe49de78932194d52a02b07486c6d023a24600_003F2f_003F7ab1cc57_003FAuthenticationMiddleware_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAuthenticationMiddleware_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fe49de78932194d52a02b07486c6d023a24600_003F2f_003F7ab1cc57_003FAuthenticationMiddleware_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAuthenticationSchemeOptions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F9b24a56e61ae4d86a9e8ba13482a2db924600_003F5b_003F9e854504_003FAuthenticationSchemeOptions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAuthenticationSchemeOptions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F9b24a56e61ae4d86a9e8ba13482a2db924600_003F5b_003F9e854504_003FAuthenticationSchemeOptions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
@ -28,6 +29,7 @@
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADiagnosticServiceCollectionExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F47e01f36dea14a23aaea6e0391c1347ace00_003F3c_003F140e6d8b_003FDiagnosticServiceCollectionExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADiagnosticServiceCollectionExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F47e01f36dea14a23aaea6e0391c1347ace00_003F3c_003F140e6d8b_003FDiagnosticServiceCollectionExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADirectory_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb6f0571a6bc744b0b551fd4578292582e54c00_003Fde_003F94973e27_003FDirectory_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADirectory_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb6f0571a6bc744b0b551fd4578292582e54c00_003Fde_003F94973e27_003FDirectory_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEndpointConventionBuilderExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F8bb08a178b5b43c5bac20a5a54159a5b2a800_003F8a_003F101938e3_003FEndpointConventionBuilderExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEndpointConventionBuilderExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F8bb08a178b5b43c5bac20a5a54159a5b2a800_003F8a_003F101938e3_003FEndpointConventionBuilderExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEndpointHttpContextExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fc181aff8c6ec418494a7efcfec578fc154e00_003F81_003F048fd513_003FEndpointHttpContextExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEnforcerExtension_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fbb4a120e56464fc6abd8c30969ef70864ba00_003Fb5_003F180850e0_003FEnforcerExtension_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEnforcerExtension_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fbb4a120e56464fc6abd8c30969ef70864ba00_003Fb5_003F180850e0_003FEnforcerExtension_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEnforcer_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fbb4a120e56464fc6abd8c30969ef70864ba00_003F47_003F3a6b6c4b_003FEnforcer_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEnforcer_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fbb4a120e56464fc6abd8c30969ef70864ba00_003F47_003F3a6b6c4b_003FEnforcer_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEntityFrameworkQueryableExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fe096e6f12c5d6b49356bc34ff1ea08738f910c0929c9d717c9cba7f44288_003FEntityFrameworkQueryableExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEntityFrameworkQueryableExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fe096e6f12c5d6b49356bc34ff1ea08738f910c0929c9d717c9cba7f44288_003FEntityFrameworkQueryableExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
@ -81,6 +83,7 @@
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AQueryable_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F42d8f09d6a294d00a6f49efc989927492fe00_003F4e_003F26d1ee34_003FQueryable_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AQueryable_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F42d8f09d6a294d00a6f49efc989927492fe00_003F4e_003F26d1ee34_003FQueryable_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AQueryable_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fcbafb95b4df34952928f87356db00c8f2fe00_003F9b_003F8ba036bb_003FQueryable_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AQueryable_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fcbafb95b4df34952928f87356db00c8f2fe00_003F9b_003F8ba036bb_003FQueryable_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARazorPage_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F81d2924a2bbd4b0c864a1d23cbf5f0893d200_003F5f_003Fc110be1c_003FRazorPage_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARazorPage_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F81d2924a2bbd4b0c864a1d23cbf5f0893d200_003F5f_003Fc110be1c_003FRazorPage_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARepeatedField_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F331aca3f6f414013b09964063341351379060_003Fc1_003F67c16263_003FRepeatedField_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AResizeOptions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fef3339e864a448e2b1ec6fa7bbf4c6661fee00_003F48_003F0209e410_003FResizeOptions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AResizeOptions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fef3339e864a448e2b1ec6fa7bbf4c6661fee00_003F48_003F0209e410_003FResizeOptions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AResourceManagerStringLocalizerFactory_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb62f365d06c44ad695ff75960cdf97a2a800_003Fe4_003Ff6ba93b7_003FResourceManagerStringLocalizerFactory_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AResourceManagerStringLocalizerFactory_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb62f365d06c44ad695ff75960cdf97a2a800_003Fe4_003Ff6ba93b7_003FResourceManagerStringLocalizerFactory_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARSA_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fee4f989f6b8042b59b2654fdc188e287243600_003F8b_003F44e5f855_003FRSA_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ARSA_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fee4f989f6b8042b59b2654fdc188e287243600_003F8b_003F44e5f855_003FRSA_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>