Enhance documentation with new features: added dark/light/system theme support, instructions page, and application catalog. Updated API and domain model for app management and automatic migrations on startup. Improved frontend structure with new routes and features for user instructions and app management.

This commit is contained in:
Leonid Pershin
2026-07-01 22:38:01 +03:00
parent d8930409fe
commit 1a8d33efa3
229 changed files with 9226 additions and 20 deletions
@@ -0,0 +1,6 @@
namespace PnvPanel.Api.Common;
public static class RateLimiting
{
public const string AuthPolicy = "auth";
}
@@ -0,0 +1,27 @@
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Api.Common;
public static class ResultExtensions
{
public static IResult ToHttpResult(this Result result)
=> result.IsSuccess ? Results.NoContent() : ToProblem(result.Error);
public static IResult ToHttpResult<T>(this Result<T> result)
=> result.IsSuccess ? Results.Ok(result.Value) : ToProblem(result.Error);
private static IResult ToProblem(Error error)
{
var statusCode = error.Type switch
{
ErrorType.Validation => StatusCodes.Status400BadRequest,
ErrorType.Unauthorized => StatusCodes.Status401Unauthorized,
ErrorType.Forbidden => StatusCodes.Status403Forbidden,
ErrorType.NotFound => StatusCodes.Status404NotFound,
ErrorType.Conflict => StatusCodes.Status409Conflict,
_ => StatusCodes.Status422UnprocessableEntity,
};
return Results.Problem(title: error.Code, detail: error.Message, statusCode: statusCode);
}
}
@@ -0,0 +1,65 @@
using PnvPanel.Api.Common;
using PnvPanel.Application.Activation;
using PnvPanel.Application.Admin.Activation;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Domain.Activation;
using PnvPanel.Infrastructure.Identity;
namespace PnvPanel.Api.Endpoints;
public static class ActivationEndpoints
{
public static IEndpointRouteBuilder MapActivationEndpoints(this IEndpointRouteBuilder app)
{
var user = app.MapGroup("/api/activation").WithTags("Activation").RequireAuthorization();
user.MapGet("/status", GetStatus);
user.MapPost("/request", RequestActivation);
var admin = app.MapGroup("/api/admin/activation-requests")
.WithTags("Admin.Activation")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", ListRequests);
admin.MapPost("/{id:guid}/approve", Approve);
admin.MapPost("/{id:guid}/reject", Reject);
return app;
}
private static async Task<IResult> GetStatus(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new GetActivationStatusQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> RequestActivation(RequestActivationCommand command, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ListRequests(
[AsParameters] ListActivationRequestsRequest request, ISender sender, CancellationToken cancellationToken)
{
var query = new ListActivationRequestsQuery(request.StatusFilter, request.Page, request.PageSize);
var result = await sender.Send(query, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> Approve(Guid id, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new ApproveActivationCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> Reject(
Guid id, RejectActivationBody body, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new RejectActivationCommand(id, body.Reason), cancellationToken);
return result.ToHttpResult();
}
}
public sealed record ListActivationRequestsRequest(ActivationStatus? StatusFilter, int Page = 1, int PageSize = 20);
public sealed record RejectActivationBody(string? Reason);
@@ -0,0 +1,20 @@
using PnvPanel.Api.Common;
using PnvPanel.Application.Apps;
using PnvPanel.Application.Common.Messaging;
namespace PnvPanel.Api.Endpoints;
public static class AppEndpoints
{
public static IEndpointRouteBuilder MapAppEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/apps", ListApps).WithTags("Apps").RequireAuthorization();
return app;
}
private static async Task<IResult> ListApps(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new ListAppsQuery(), cancellationToken);
return result.ToHttpResult();
}
}
@@ -0,0 +1,116 @@
using PnvPanel.Api.Common;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Auth.ChangePassword;
using PnvPanel.Application.Auth.DeleteMyAccount;
using PnvPanel.Application.Auth.Login;
using PnvPanel.Application.Auth.Logout;
using PnvPanel.Application.Auth.Me;
using PnvPanel.Application.Auth.Refresh;
using PnvPanel.Application.Auth.Register;
using PnvPanel.Application.Common.Messaging;
namespace PnvPanel.Api.Endpoints;
public static class AuthEndpoints
{
private const string RefreshCookieName = "pnv_refresh_token";
public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/auth")
.WithTags("Auth")
.RequireRateLimiting(RateLimiting.AuthPolicy);
group.MapPost("/register", Register);
group.MapPost("/login", Login);
group.MapPost("/refresh", Refresh);
group.MapPost("/logout", Logout).RequireAuthorization();
group.MapPost("/change-password", ChangePassword).RequireAuthorization();
group.MapGet("/me", Me).RequireAuthorization();
group.MapDelete("/me", DeleteMe).RequireAuthorization();
return app;
}
private static async Task<IResult> Register(RegisterCommand command, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> Login(LoginCommand command, ISender sender, HttpResponse response, CancellationToken cancellationToken)
{
var result = await sender.Send(command, cancellationToken);
if (!result.IsSuccess)
return result.ToHttpResult();
SetRefreshCookie(response, result.Value.RefreshToken, result.Value.RefreshTokenExpiresAt);
return Results.Ok(ToLoginResponse(result.Value));
}
private static async Task<IResult> Refresh(HttpRequest request, HttpResponse response, ISender sender, CancellationToken cancellationToken)
{
if (!request.Cookies.TryGetValue(RefreshCookieName, out var rawToken) || string.IsNullOrEmpty(rawToken))
return Results.Unauthorized();
var result = await sender.Send(new RefreshCommand(rawToken), cancellationToken);
if (!result.IsSuccess)
{
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions());
return result.ToHttpResult();
}
SetRefreshCookie(response, result.Value.RefreshToken, result.Value.RefreshTokenExpiresAt);
return Results.Ok(ToLoginResponse(result.Value));
}
private static async Task<IResult> Logout(HttpRequest request, HttpResponse response, ISender sender, CancellationToken cancellationToken)
{
if (request.Cookies.TryGetValue(RefreshCookieName, out var rawToken) && !string.IsNullOrEmpty(rawToken))
await sender.Send(new LogoutCommand(rawToken), cancellationToken);
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions());
return Results.NoContent();
}
private static async Task<IResult> ChangePassword(ChangePasswordCommand command, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> Me(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new GetCurrentUserQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteMe(HttpResponse response, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new DeleteMyAccountCommand(), cancellationToken);
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions());
return result.ToHttpResult();
}
private static object ToLoginResponse(AuthResult auth) => new
{
accessToken = auth.AccessToken,
expiresAt = auth.AccessTokenExpiresAt,
user = auth.User,
};
private static void SetRefreshCookie(HttpResponse response, string rawToken, DateTimeOffset expiresAt)
{
var options = BuildCookieOptions();
options.Expires = expiresAt;
response.Cookies.Append(RefreshCookieName, rawToken, options);
}
private static CookieOptions BuildCookieOptions() => new()
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Strict,
Path = "/api/auth",
};
}
@@ -0,0 +1,81 @@
using PnvPanel.Api.Common;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Configs.Create;
using PnvPanel.Application.Configs.Edit;
using PnvPanel.Application.Configs.GetConfigLink;
using PnvPanel.Application.Configs.GetMyConfigs;
using PnvPanel.Application.Configs.ListAvailableInbounds;
using PnvPanel.Application.Configs.Revoke;
using PnvPanel.Application.Configs.Rotate;
namespace PnvPanel.Api.Endpoints;
public static class ConfigEndpoints
{
public static IEndpointRouteBuilder MapConfigEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api").WithTags("Configs").RequireAuthorization();
group.MapGet("/inbounds/available", ListAvailableInbounds);
group.MapGet("/configs", GetMyConfigs);
group.MapPost("/configs", CreateConfig);
group.MapPatch("/configs/{id:guid}", EditConfig);
group.MapPost("/configs/{id:guid}/rotate", RotateConfig);
group.MapDelete("/configs/{id:guid}", RevokeConfig);
group.MapGet("/configs/{id:guid}/link", GetConfigLink);
return app;
}
private static async Task<IResult> ListAvailableInbounds(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new ListAvailableInboundsQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetMyConfigs(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new GetMyConfigsQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateConfig(CreateConfigBody body, ISender sender, CancellationToken cancellationToken)
{
var command = new CreateVpnConfigCommand(body.InboundId, body.Label, body.DeviceLimit);
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> EditConfig(Guid id, EditConfigBody body, ISender sender, CancellationToken cancellationToken)
{
var command = new EditVpnConfigCommand(id, body.Label, body.DeviceLimit);
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> RotateConfig(Guid id, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new RotateVpnConfigCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> RevokeConfig(Guid id, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new RevokeVpnConfigCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetConfigLink(Guid id, HttpRequest request, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new GetConfigLinkQuery(id), cancellationToken);
if (!result.IsSuccess)
return result.ToHttpResult();
var subscriptionUrl = $"{request.Scheme}://{request.Host}/sub/{result.Value.SubscriptionToken}";
return Results.Ok(new { connectionString = result.Value.ConnectionString, subscriptionUrl });
}
}
public sealed record CreateConfigBody(Guid InboundId, string? Label, int? DeviceLimit);
public sealed record EditConfigBody(string? Label, int? DeviceLimit);
@@ -0,0 +1,38 @@
using PnvPanel.Api.Common;
using PnvPanel.Application.Admin.Inbounds;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Infrastructure.Identity;
namespace PnvPanel.Api.Endpoints;
public static class InboundEndpoints
{
public static IEndpointRouteBuilder MapInboundEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/inbounds")
.WithTags("Admin.Inbounds")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", ListInbounds);
admin.MapPut("/{id:guid}/publish", PublishInbound);
return app;
}
private static async Task<IResult> ListInbounds(Guid? nodeId, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new ListInboundsQuery(nodeId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> PublishInbound(
Guid id, PublishInboundBody body, ISender sender, CancellationToken cancellationToken)
{
var command = new PublishInboundCommand(
id, body.IsPublished, body.DisplayName, body.AllowedRoleIds ?? [], body.MaxClients);
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
}
public sealed record PublishInboundBody(bool IsPublished, string? DisplayName, IReadOnlyList<Guid>? AllowedRoleIds, int? MaxClients);
@@ -0,0 +1,64 @@
using PnvPanel.Api.Common;
using PnvPanel.Application.Admin.Nodes;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Infrastructure.Identity;
namespace PnvPanel.Api.Endpoints;
public static class NodeEndpoints
{
public static IEndpointRouteBuilder MapNodeEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/nodes")
.WithTags("Admin.Nodes")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", ListNodes);
admin.MapPost("", RegisterNode);
admin.MapPut("/{id:guid}", UpdateNode);
admin.MapDelete("/{id:guid}", DeleteNode);
admin.MapPost("/{id:guid}/sync", SyncNode);
admin.MapPost("/{id:guid}/probe", ProbeNode);
return app;
}
private static async Task<IResult> ListNodes(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new ListNodesQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> RegisterNode(RegisterNodeCommand command, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateNode(Guid id, UpdateNodeBody body, ISender sender, CancellationToken cancellationToken)
{
var command = new UpdateNodeCommand(id, body.Name, body.Location, body.IsEnabled, body.Username, body.Password);
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteNode(Guid id, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new DeleteNodeCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> SyncNode(Guid id, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new SyncNodeCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ProbeNode(Guid id, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new ProbeNodeCommand(id), cancellationToken);
return result.ToHttpResult();
}
}
public sealed record UpdateNodeBody(string Name, string? Location, bool IsEnabled, string? Username, string? Password);
@@ -0,0 +1,59 @@
using PnvPanel.Api.Common;
using PnvPanel.Application.Admin.Roles;
using PnvPanel.Application.Admin.Users;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Infrastructure.Identity;
namespace PnvPanel.Api.Endpoints;
public static class RoleEndpoints
{
public static IEndpointRouteBuilder MapRoleEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin")
.WithTags("Admin.Roles")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("/roles", ListRoles);
admin.MapPost("/roles", CreateRole);
admin.MapPut("/roles/{id:guid}", UpdateRole);
admin.MapDelete("/roles/{id:guid}", DeleteRole);
admin.MapPatch("/users/{id:guid}/role", ChangeUserRole);
return app;
}
private static async Task<IResult> ListRoles(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new ListRolesQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateRole(CreateRoleCommand command, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateRole(Guid id, UpdateRoleBody body, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new UpdateRoleCommand(id, body.MaxConfigs), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteRole(Guid id, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new DeleteRoleCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ChangeUserRole(Guid id, ChangeUserRoleBody body, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new ChangeUserRoleCommand(id, body.RoleId), cancellationToken);
return result.ToHttpResult();
}
}
public sealed record UpdateRoleBody(int MaxConfigs);
public sealed record ChangeUserRoleBody(Guid RoleId);
@@ -0,0 +1,42 @@
using System.Text;
using PnvPanel.Api.Common;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Subscriptions;
namespace PnvPanel.Api.Endpoints;
public static class SubscriptionEndpoints
{
public static IEndpointRouteBuilder MapSubscriptionEndpoints(this IEndpointRouteBuilder app)
{
// Вне /api по дизайну (api-design.md) — публичный эндпоинт для VPN-клиентов.
app.MapGet("/sub/{token}", GetSubscription)
.WithTags("Subscription")
.RequireRateLimiting(RateLimiting.AuthPolicy);
return app;
}
private static async Task<IResult> GetSubscription(string token, HttpResponse response, ISender sender, CancellationToken cancellationToken)
{
// Токен — либо AppUser.SubscriptionToken (агрегированная подписка), либо VpnConfig.SubscriptionToken
// (один конфиг). Пробуем пользовательский токен первым.
var userResult = await sender.Send(new GetUserSubscriptionQuery(token), cancellationToken);
var result = userResult.IsSuccess ? userResult : await sender.Send(new GetConfigSubscriptionQuery(token), cancellationToken);
if (!result.IsSuccess)
return Results.NotFound();
var body = string.Join('\n', result.Value.ConnectionStrings);
var base64Body = Convert.ToBase64String(Encoding.UTF8.GetBytes(body));
var total = result.Value.UsedUpBytes + result.Value.UsedDownBytes;
var expire = result.Value.ExpiresAt is { } exp ? exp.ToUnixTimeSeconds().ToString() : "0";
response.Headers.Append(
"Subscription-Userinfo",
$"upload={result.Value.UsedUpBytes}; download={result.Value.UsedDownBytes}; total={total}; expire={expire}");
response.Headers.Append("Profile-Update-Interval", "12");
return Results.Text(base64Body, "text/plain; charset=utf-8");
}
}
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<ProjectReference Include="..\PnvPanel.Infrastructure\PnvPanel.Infrastructure.csproj" />
<ProjectReference Include="..\PnvPanel.Application\PnvPanel.Application.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="..\..\..\seed\client-apps.json" Link="seed\client-apps.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" />
<PackageReference Include="Microsoft.OpenApi" />
<PackageReference Include="Scalar.AspNetCore" />
<PackageReference Include="Serilog.AspNetCore" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
+83
View File
@@ -0,0 +1,83 @@
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using PnvPanel.Api.Common;
using PnvPanel.Api.Endpoints;
using PnvPanel.Application;
using PnvPanel.Infrastructure;
using PnvPanel.Infrastructure.Identity;
using PnvPanel.Infrastructure.Persistence;
using Scalar.AspNetCore;
using Serilog;
var builder = WebApplication.CreateBuilder(args);
// Структурное логирование (Serilog), конфигурация из appsettings/env.
builder.Services.AddSerilog((services, configuration) => configuration
.ReadFrom.Configuration(builder.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext());
// За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера).
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
});
builder.Services.AddHttpContextAccessor();
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter(RateLimiting.AuthPolicy, limiterOptions =>
{
limiterOptions.PermitLimit = 20;
limiterOptions.Window = TimeSpan.FromMinutes(1);
limiterOptions.QueueLimit = 0;
});
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});
builder.Services.AddProblemDetails();
builder.Services.AddOpenApi();
builder.Services.AddHealthChecks()
.AddDbContextCheck<AppDbContext>();
var app = builder.Build();
// Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте.
await app.Services.ApplyMigrationsAsync();
await app.Services.SeedDataAsync();
app.UseForwardedHeaders();
app.UseSerilogRequestLogging();
app.UseExceptionHandler();
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
// OpenAPI-схема (/openapi/v1.json) + современный UI Scalar (/scalar).
app.MapOpenApi();
app.MapScalarApiReference();
app.MapHealthChecks("/health");
app.MapAuthEndpoints();
app.MapActivationEndpoints();
app.MapRoleEndpoints();
app.MapNodeEndpoints();
app.MapInboundEndpoints();
app.MapConfigEndpoints();
app.MapSubscriptionEndpoints();
app.MapAppEndpoints();
// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов.
app.UseDefaultFiles();
app.UseStaticFiles();
app.MapFallbackToFile("index.html");
app.Run();
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5278",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7075;http://localhost:5278",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,12 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AdminSeed": {
"Username": "admin",
"Password": "Passw0rd!Dev"
}
}
+32
View File
@@ -0,0 +1,32 @@
{
"ConnectionStrings": {
"Default": "Host=localhost;Port=5432;Database=pnvpanel;Username=pnvpanel;Password=pnvpanel"
},
"Jwt": {
"Issuer": "PnvPanel",
"Audience": "PnvPanel",
"SigningKey": "change-me-min-32-chars-random-secret",
"AccessTokenMinutes": 15,
"RefreshTokenDays": 30
},
"AdminSeed": {
"Username": "",
"Password": ""
},
"Roles": {
"DefaultUserMaxConfigs": 3
},
"Serilog": {
"Using": [ "Serilog.Sinks.Console" ],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
},
"WriteTo": [ { "Name": "Console" } ],
"Enrich": [ "FromLogContext" ]
},
"AllowedHosts": "*"
}
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PnvPanel</title>
</head>
<body>
<main style="font-family: system-ui, sans-serif; max-width: 40rem; margin: 4rem auto; padding: 0 1rem;">
<h1>PnvPanel</h1>
<p>Каркас приложения (M0). Здесь будет собранное SPA (React + Vite).</p>
<p>
API: <a href="/scalar">/scalar</a> · Health: <a href="/health">/health</a>
</p>
</main>
</body>
</html>
@@ -0,0 +1,15 @@
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Activation;
public static class ActivationErrors
{
public static readonly Error AlreadyPending =
Error.Conflict("Activation.AlreadyPending", "У вас уже есть необработанный запрос на активацию.");
public static readonly Error NotFound =
Error.NotFound("Activation.NotFound", "Запрос на активацию не найден.");
public static readonly Error AlreadyDecided =
Error.Conflict("Activation.AlreadyDecided", "Запрос на активацию уже обработан.");
}
@@ -0,0 +1,3 @@
namespace PnvPanel.Application.Activation;
public sealed record ActivationRequestDto(Guid Id, string? Comment, DateTimeOffset CreatedAt);
@@ -0,0 +1,3 @@
namespace PnvPanel.Application.Activation;
public sealed record ActivationStatusDto(bool IsActivated, ActivationRequestDto? PendingRequest);
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Activation;
public sealed record GetActivationStatusQuery : IQuery<Result<ActivationStatusDto>>;
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Activation;
namespace PnvPanel.Application.Activation;
public sealed class GetActivationStatusQueryHandler(IIdentityService identityService, IAppDbContext dbContext, ICurrentUser currentUser)
: IQueryHandler<GetActivationStatusQuery, Result<ActivationStatusDto>>
{
public async Task<Result<ActivationStatusDto>> Handle(GetActivationStatusQuery query, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return Result.Failure<ActivationStatusDto>(AuthErrors.Unauthorized);
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
if (profile is null)
return Result.Failure<ActivationStatusDto>(AuthErrors.Unauthorized);
var pending = await dbContext.ActivationRequests
.Where(r => r.UserId == userId && r.Status == ActivationStatus.Pending)
.Select(r => new ActivationRequestDto(r.Id, r.Comment, r.CreatedAt))
.FirstOrDefaultAsync(cancellationToken);
return Result.Success(new ActivationStatusDto(profile.IsActivated, pending));
}
}
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Activation;
public sealed record RequestActivationCommand(string? Comment) : ICommand<Result<ActivationRequestDto>>;
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Activation;
namespace PnvPanel.Application.Activation;
public sealed class RequestActivationCommandHandler(IAppDbContext dbContext, ICurrentUser currentUser)
: ICommandHandler<RequestActivationCommand, Result<ActivationRequestDto>>
{
public async Task<Result<ActivationRequestDto>> Handle(RequestActivationCommand command, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return Result.Failure<ActivationRequestDto>(AuthErrors.Unauthorized);
var hasPending = await dbContext.ActivationRequests
.AnyAsync(r => r.UserId == userId && r.Status == ActivationStatus.Pending, cancellationToken);
if (hasPending)
return Result.Failure<ActivationRequestDto>(ActivationErrors.AlreadyPending);
var request = ActivationRequest.Create(userId, command.Comment);
dbContext.ActivationRequests.Add(request);
return Result.Success(new ActivationRequestDto(request.Id, request.Comment, request.CreatedAt));
}
}
@@ -0,0 +1,11 @@
using FluentValidation;
namespace PnvPanel.Application.Activation;
public sealed class RequestActivationCommandValidator : AbstractValidator<RequestActivationCommand>
{
public RequestActivationCommandValidator()
{
RuleFor(x => x.Comment).MaximumLength(500);
}
}
@@ -0,0 +1,11 @@
using PnvPanel.Domain.Activation;
namespace PnvPanel.Application.Admin.Activation;
public sealed record ActivationRequestAdminDto(
Guid Id,
Guid UserId,
string UserName,
string? Comment,
ActivationStatus Status,
DateTimeOffset CreatedAt);
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Activation;
public sealed record ApproveActivationCommand(Guid RequestId) : ICommand<Result>;
@@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Activation;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Activation;
namespace PnvPanel.Application.Admin.Activation;
public sealed class ApproveActivationCommandHandler(IAppDbContext dbContext, IIdentityService identityService, ICurrentUser currentUser)
: ICommandHandler<ApproveActivationCommand, Result>
{
public async Task<Result> Handle(ApproveActivationCommand command, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } adminId)
return Result.Failure(AuthErrors.Unauthorized);
var request = await dbContext.ActivationRequests
.FirstOrDefaultAsync(r => r.Id == command.RequestId, cancellationToken);
if (request is null)
return Result.Failure(ActivationErrors.NotFound);
if (request.Status != ActivationStatus.Pending)
return Result.Failure(ActivationErrors.AlreadyDecided);
request.Approve(adminId);
return await identityService.ActivateUserAsync(request.UserId, adminId, cancellationToken);
}
}
@@ -0,0 +1,8 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Activation;
namespace PnvPanel.Application.Admin.Activation;
public sealed record ListActivationRequestsQuery(ActivationStatus? StatusFilter, int Page, int PageSize)
: IQuery<Result<PagedList<ActivationRequestAdminDto>>>;
@@ -0,0 +1,35 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Activation;
public sealed class ListActivationRequestsQueryHandler(IAppDbContext dbContext, IIdentityService identityService)
: IQueryHandler<ListActivationRequestsQuery, Result<PagedList<ActivationRequestAdminDto>>>
{
public async Task<Result<PagedList<ActivationRequestAdminDto>>> Handle(ListActivationRequestsQuery query, CancellationToken cancellationToken)
{
var page = query.Page <= 0 ? 1 : query.Page;
var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize;
var requestsQuery = dbContext.ActivationRequests.AsNoTracking();
if (query.StatusFilter is { } status)
requestsQuery = requestsQuery.Where(r => r.Status == status);
var page1 = await requestsQuery
.OrderBy(r => r.CreatedAt)
.ToPagedListAsync(page, pageSize, cancellationToken);
var userNames = await identityService.GetUserNamesAsync(
page1.Items.Select(r => r.UserId).Distinct().ToList(),
cancellationToken);
var items = page1.Items
.Select(r => new ActivationRequestAdminDto(
r.Id, r.UserId, userNames.GetValueOrDefault(r.UserId, "?"), r.Comment, r.Status, r.CreatedAt))
.ToList();
return Result.Success(new PagedList<ActivationRequestAdminDto>(items, page1.Total, page1.Page, page1.PageSize));
}
}
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Activation;
public sealed record RejectActivationCommand(Guid RequestId, string? Reason) : ICommand<Result>;
@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Activation;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Activation;
namespace PnvPanel.Application.Admin.Activation;
public sealed class RejectActivationCommandHandler(IAppDbContext dbContext, ICurrentUser currentUser)
: ICommandHandler<RejectActivationCommand, Result>
{
public async Task<Result> Handle(RejectActivationCommand command, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } adminId)
return Result.Failure(AuthErrors.Unauthorized);
var request = await dbContext.ActivationRequests
.FirstOrDefaultAsync(r => r.Id == command.RequestId, cancellationToken);
if (request is null)
return Result.Failure(ActivationErrors.NotFound);
if (request.Status != ActivationStatus.Pending)
return Result.Failure(ActivationErrors.AlreadyDecided);
request.Reject(adminId, command.Reason);
return Result.Success();
}
}
@@ -0,0 +1,11 @@
using FluentValidation;
namespace PnvPanel.Application.Admin.Activation;
public sealed class RejectActivationCommandValidator : AbstractValidator<RejectActivationCommand>
{
public RejectActivationCommandValidator()
{
RuleFor(x => x.Reason).MaximumLength(500);
}
}
@@ -0,0 +1,13 @@
using PnvPanel.Domain.Inbounds;
namespace PnvPanel.Application.Admin.Inbounds;
public sealed record InboundDto(
Guid Id, Guid NodeId, string RemoteInboundId, VpnProtocol Protocol, string Remark, int Port,
bool IsPublished, string? DisplayName, int? MaxClients, IReadOnlyList<Guid> AllowedRoleIds,
DateTimeOffset? LastSyncAt)
{
public static InboundDto FromDomain(Inbound inbound) => new(
inbound.Id, inbound.NodeId, inbound.RemoteInboundId, inbound.Protocol, inbound.Remark, inbound.Port,
inbound.IsPublished, inbound.DisplayName, inbound.MaxClients, inbound.AllowedRoleIds, inbound.LastSyncAt);
}
@@ -0,0 +1,8 @@
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Inbounds;
public static class InboundErrors
{
public static readonly Error NotFound = Error.NotFound("Inbounds.NotFound", "Inbound не найден.");
}
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Inbounds;
public sealed record ListInboundsQuery(Guid? NodeId) : IQuery<Result<IReadOnlyList<InboundDto>>>;
@@ -0,0 +1,19 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Inbounds;
public sealed class ListInboundsQueryHandler(IAppDbContext dbContext) : IQueryHandler<ListInboundsQuery, Result<IReadOnlyList<InboundDto>>>
{
public async Task<Result<IReadOnlyList<InboundDto>>> Handle(ListInboundsQuery query, CancellationToken cancellationToken)
{
var inboundsQuery = dbContext.Inbounds.AsNoTracking();
if (query.NodeId is { } nodeId)
inboundsQuery = inboundsQuery.Where(i => i.NodeId == nodeId);
var inbounds = await inboundsQuery.OrderBy(i => i.Remark).ToListAsync(cancellationToken);
return Result.Success<IReadOnlyList<InboundDto>>(inbounds.Select(InboundDto.FromDomain).ToList());
}
}
@@ -0,0 +1,8 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Inbounds;
public sealed record PublishInboundCommand(
Guid InboundId, bool IsPublished, string? DisplayName, IReadOnlyList<Guid> AllowedRoleIds, int? MaxClients)
: ICommand<Result<InboundDto>>;
@@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Inbounds;
public sealed class PublishInboundCommandHandler(IAppDbContext dbContext)
: ICommandHandler<PublishInboundCommand, Result<InboundDto>>
{
public async Task<Result<InboundDto>> Handle(PublishInboundCommand command, CancellationToken cancellationToken)
{
var inbound = await dbContext.Inbounds.FirstOrDefaultAsync(i => i.Id == command.InboundId, cancellationToken);
if (inbound is null)
return Result.Failure<InboundDto>(InboundErrors.NotFound);
if (command.IsPublished)
inbound.Publish(command.DisplayName, command.AllowedRoleIds, command.MaxClients);
else
inbound.Unpublish();
return Result.Success(InboundDto.FromDomain(inbound));
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace PnvPanel.Application.Admin.Inbounds;
public sealed class PublishInboundCommandValidator : AbstractValidator<PublishInboundCommand>
{
public PublishInboundCommandValidator()
{
RuleFor(x => x.DisplayName).MaximumLength(100);
RuleFor(x => x.MaxClients).GreaterThan(0).When(x => x.MaxClients.HasValue);
}
}
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Nodes;
public sealed record DeleteNodeCommand(Guid NodeId) : ICommand<Result>;
@@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Nodes;
public sealed class DeleteNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway)
: ICommandHandler<DeleteNodeCommand, Result>
{
public async Task<Result> Handle(DeleteNodeCommand command, CancellationToken cancellationToken)
{
var node = await dbContext.Nodes.FirstOrDefaultAsync(n => n.Id == command.NodeId, cancellationToken);
if (node is null)
return Result.Failure(NodeErrors.NotFound);
var inbounds = await dbContext.Inbounds.Where(i => i.NodeId == node.Id).ToListAsync(cancellationToken);
dbContext.Inbounds.RemoveRange(inbounds);
dbContext.Nodes.Remove(node);
gateway.InvalidateClient(node.Id);
return Result.Success();
}
}
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Nodes;
public sealed record ListNodesQuery : IQuery<Result<IReadOnlyList<NodeDto>>>;
@@ -0,0 +1,15 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Nodes;
public sealed class ListNodesQueryHandler(IAppDbContext dbContext) : IQueryHandler<ListNodesQuery, Result<IReadOnlyList<NodeDto>>>
{
public async Task<Result<IReadOnlyList<NodeDto>>> Handle(ListNodesQuery query, CancellationToken cancellationToken)
{
var nodes = await dbContext.Nodes.AsNoTracking().OrderBy(n => n.Name).ToListAsync(cancellationToken);
return Result.Success<IReadOnlyList<NodeDto>>(nodes.Select(NodeDto.FromDomain).ToList());
}
}
@@ -0,0 +1,13 @@
using PnvPanel.Domain.Nodes;
namespace PnvPanel.Application.Admin.Nodes;
/// <summary>Админский DTO ноды. Пароль никогда не попадает в ответ API.</summary>
public sealed record NodeDto(
Guid Id, string Name, string BaseAddress, string Username, string? Location,
NodeStatus Status, bool IsEnabled, DateTimeOffset? LastSyncAt)
{
public static NodeDto FromDomain(Node node) => new(
node.Id, node.Name, node.BaseAddress.ToString(), node.Credentials.Username, node.Location,
node.Status, node.IsEnabled, node.LastSyncAt);
}
@@ -0,0 +1,9 @@
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Nodes;
public static class NodeErrors
{
public static readonly Error NotFound = Error.NotFound("Nodes.NotFound", "Нода не найдена.");
public static readonly Error InvalidBaseAddress = Error.Validation("Nodes.InvalidBaseAddress", "Некорректный адрес панели.");
}
@@ -0,0 +1,9 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Nodes;
namespace PnvPanel.Application.Admin.Nodes;
public sealed record ProbeNodeCommand(Guid NodeId) : ICommand<Result<NodeProbeResultDto>>;
public sealed record NodeProbeResultDto(bool IsReachable, string? ErrorMessage, NodeStatus Status);
@@ -0,0 +1,23 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Nodes;
namespace PnvPanel.Application.Admin.Nodes;
public sealed class ProbeNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway)
: ICommandHandler<ProbeNodeCommand, Result<NodeProbeResultDto>>
{
public async Task<Result<NodeProbeResultDto>> Handle(ProbeNodeCommand command, CancellationToken cancellationToken)
{
var node = await dbContext.Nodes.FirstOrDefaultAsync(n => n.Id == command.NodeId, cancellationToken);
if (node is null)
return Result.Failure<NodeProbeResultDto>(NodeErrors.NotFound);
var probe = await gateway.ProbeAsync(node, cancellationToken);
node.UpdateStatus(probe.IsReachable ? NodeStatus.Online : NodeStatus.Offline);
return Result.Success(new NodeProbeResultDto(probe.IsReachable, probe.ErrorMessage, node.Status));
}
}
@@ -0,0 +1,7 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Nodes;
public sealed record RegisterNodeCommand(string Name, string BaseAddress, string Username, string Password, string? Location)
: ICommand<Result<NodeDto>>;
@@ -0,0 +1,27 @@
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Nodes;
namespace PnvPanel.Application.Admin.Nodes;
public sealed class RegisterNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway, ISecretProtector secretProtector)
: ICommandHandler<RegisterNodeCommand, Result<NodeDto>>
{
public Task<Result<NodeDto>> Handle(RegisterNodeCommand command, CancellationToken cancellationToken)
{
if (!Uri.TryCreate(command.BaseAddress, UriKind.Absolute, out var baseAddress))
return Task.FromResult(Result.Failure<NodeDto>(NodeErrors.InvalidBaseAddress));
var validation = gateway.ValidateBaseAddress(baseAddress);
if (!validation.IsSuccess)
return Task.FromResult(Result.Failure<NodeDto>(validation.Error));
var credentials = new NodeCredentials(command.Username, secretProtector.Protect(command.Password));
var node = Node.Register(command.Name, baseAddress, credentials, command.Location);
dbContext.Nodes.Add(node);
return Task.FromResult(Result.Success(NodeDto.FromDomain(node)));
}
}
@@ -0,0 +1,15 @@
using FluentValidation;
namespace PnvPanel.Application.Admin.Nodes;
public sealed class RegisterNodeCommandValidator : AbstractValidator<RegisterNodeCommand>
{
public RegisterNodeCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
RuleFor(x => x.BaseAddress).NotEmpty().MaximumLength(500);
RuleFor(x => x.Username).NotEmpty().MaximumLength(200);
RuleFor(x => x.Password).NotEmpty();
RuleFor(x => x.Location).MaximumLength(100);
}
}
@@ -0,0 +1,9 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Nodes;
namespace PnvPanel.Application.Admin.Nodes;
public sealed record SyncNodeCommand(Guid NodeId) : ICommand<Result<SyncNodeResultDto>>;
public sealed record SyncNodeResultDto(int InboundsSynced, NodeStatus Status);
@@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Inbounds;
using PnvPanel.Domain.Nodes;
namespace PnvPanel.Application.Admin.Nodes;
public sealed class SyncNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway)
: ICommandHandler<SyncNodeCommand, Result<SyncNodeResultDto>>
{
public async Task<Result<SyncNodeResultDto>> Handle(SyncNodeCommand command, CancellationToken cancellationToken)
{
var node = await dbContext.Nodes.FirstOrDefaultAsync(n => n.Id == command.NodeId, cancellationToken);
if (node is null)
return Result.Failure<SyncNodeResultDto>(NodeErrors.NotFound);
var remoteResult = await gateway.ListInboundsAsync(node, cancellationToken);
if (!remoteResult.IsSuccess)
{
node.UpdateStatus(NodeStatus.Offline);
return Result.Failure<SyncNodeResultDto>(remoteResult.Error);
}
var existing = await dbContext.Inbounds.Where(i => i.NodeId == node.Id).ToListAsync(cancellationToken);
var existingByRemoteId = existing.ToDictionary(i => i.RemoteInboundId);
foreach (var remote in remoteResult.Value)
{
if (existingByRemoteId.TryGetValue(remote.RemoteInboundId, out var inbound))
inbound.UpdateFromRemote(remote.Protocol, remote.Remark, remote.Port);
else
dbContext.Inbounds.Add(Inbound.FromRemote(node.Id, remote.RemoteInboundId, remote.Protocol, remote.Remark, remote.Port));
}
// Inbound, пропавший на панели, снимаем с публикации (не удаляем — реконсиляция дрейфа,
// см. architecture.md); новые конфиги на нём создать будет нельзя, старые не трогаем.
var remoteIds = remoteResult.Value.Select(r => r.RemoteInboundId).ToHashSet();
foreach (var stale in existing.Where(i => i.IsPublished && !remoteIds.Contains(i.RemoteInboundId)))
stale.Unpublish();
node.UpdateStatus(NodeStatus.Online);
node.MarkSynced();
return Result.Success(new SyncNodeResultDto(remoteResult.Value.Count, node.Status));
}
}
@@ -0,0 +1,8 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Nodes;
public sealed record UpdateNodeCommand(
Guid NodeId, string Name, string? Location, bool IsEnabled, string? Username, string? Password)
: ICommand<Result<NodeDto>>;
@@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Nodes;
namespace PnvPanel.Application.Admin.Nodes;
public sealed class UpdateNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway, ISecretProtector secretProtector)
: ICommandHandler<UpdateNodeCommand, Result<NodeDto>>
{
public async Task<Result<NodeDto>> Handle(UpdateNodeCommand command, CancellationToken cancellationToken)
{
var node = await dbContext.Nodes.FirstOrDefaultAsync(n => n.Id == command.NodeId, cancellationToken);
if (node is null)
return Result.Failure<NodeDto>(NodeErrors.NotFound);
node.UpdateDetails(command.Name, command.Location);
if (command.IsEnabled)
node.Enable();
else
node.Disable();
if (!string.IsNullOrWhiteSpace(command.Username) && !string.IsNullOrWhiteSpace(command.Password))
{
node.UpdateCredentials(new NodeCredentials(command.Username, secretProtector.Protect(command.Password)));
gateway.InvalidateClient(node.Id);
}
return Result.Success(NodeDto.FromDomain(node));
}
}
@@ -0,0 +1,13 @@
using FluentValidation;
namespace PnvPanel.Application.Admin.Nodes;
public sealed class UpdateNodeCommandValidator : AbstractValidator<UpdateNodeCommand>
{
public UpdateNodeCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
RuleFor(x => x.Location).MaximumLength(100);
RuleFor(x => x.Username).MaximumLength(200);
}
}
@@ -0,0 +1,7 @@
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Roles;
public sealed record CreateRoleCommand(string Name, int MaxConfigs) : ICommand<Result<RoleDto>>;
@@ -0,0 +1,11 @@
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Roles;
public sealed class CreateRoleCommandHandler(IRoleService roleService) : ICommandHandler<CreateRoleCommand, Result<RoleDto>>
{
public Task<Result<RoleDto>> Handle(CreateRoleCommand command, CancellationToken cancellationToken)
=> roleService.CreateRoleAsync(command.Name, command.MaxConfigs, cancellationToken);
}
@@ -0,0 +1,16 @@
using FluentValidation;
namespace PnvPanel.Application.Admin.Roles;
public sealed class CreateRoleCommandValidator : AbstractValidator<CreateRoleCommand>
{
public CreateRoleCommandValidator()
{
RuleFor(x => x.Name)
.NotEmpty()
.Length(2, 32)
.Matches("^[a-zA-Z0-9_-]+$");
RuleFor(x => x.MaxConfigs).GreaterThanOrEqualTo(-1);
}
}
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Roles;
public sealed record DeleteRoleCommand(Guid RoleId) : ICommand<Result>;
@@ -0,0 +1,11 @@
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Roles;
public sealed class DeleteRoleCommandHandler(IRoleService roleService) : ICommandHandler<DeleteRoleCommand, Result>
{
public Task<Result> Handle(DeleteRoleCommand command, CancellationToken cancellationToken)
=> roleService.DeleteRoleAsync(command.RoleId, cancellationToken);
}
@@ -0,0 +1,7 @@
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Roles;
public sealed record ListRolesQuery : IQuery<Result<IReadOnlyList<RoleDto>>>;
@@ -0,0 +1,12 @@
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Roles;
public sealed class ListRolesQueryHandler(IRoleService roleService)
: IQueryHandler<ListRolesQuery, Result<IReadOnlyList<RoleDto>>>
{
public async Task<Result<IReadOnlyList<RoleDto>>> Handle(ListRolesQuery query, CancellationToken cancellationToken)
=> Result.Success(await roleService.ListRolesAsync(cancellationToken));
}
@@ -0,0 +1,11 @@
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Roles;
public static class RoleErrors
{
public static readonly Error NotFound = Error.NotFound("Roles.NotFound", "Роль не найдена.");
public static readonly Error DuplicateName = Error.Conflict("Roles.DuplicateName", "Роль с таким именем уже существует.");
public static readonly Error CannotModifySystemRole = Error.Forbidden("Roles.CannotModifySystemRole", "Системную роль нельзя удалить.");
public static readonly Error RoleInUse = Error.Conflict("Roles.RoleInUse", "Роль назначена пользователям — сначала переназначьте их.");
}
@@ -0,0 +1,7 @@
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Roles;
public sealed record UpdateRoleCommand(Guid RoleId, int MaxConfigs) : ICommand<Result<RoleDto>>;
@@ -0,0 +1,11 @@
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Roles;
public sealed class UpdateRoleCommandHandler(IRoleService roleService) : ICommandHandler<UpdateRoleCommand, Result<RoleDto>>
{
public Task<Result<RoleDto>> Handle(UpdateRoleCommand command, CancellationToken cancellationToken)
=> roleService.UpdateRoleAsync(command.RoleId, command.MaxConfigs, cancellationToken);
}
@@ -0,0 +1,11 @@
using FluentValidation;
namespace PnvPanel.Application.Admin.Roles;
public sealed class UpdateRoleCommandValidator : AbstractValidator<UpdateRoleCommand>
{
public UpdateRoleCommandValidator()
{
RuleFor(x => x.MaxConfigs).GreaterThanOrEqualTo(-1);
}
}
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Users;
public sealed record ChangeUserRoleCommand(Guid UserId, Guid RoleId) : ICommand<Result>;
@@ -0,0 +1,11 @@
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Users;
public sealed class ChangeUserRoleCommandHandler(IRoleService roleService) : ICommandHandler<ChangeUserRoleCommand, Result>
{
public Task<Result> Handle(ChangeUserRoleCommand command, CancellationToken cancellationToken)
=> roleService.ChangeUserRoleAsync(command.UserId, command.RoleId, cancellationToken);
}
@@ -0,0 +1,8 @@
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Users;
public static class UserErrors
{
public static readonly Error NotFound = Error.NotFound("Users.NotFound", "Пользователь не найден.");
}
@@ -0,0 +1,9 @@
using PnvPanel.Domain.Apps;
namespace PnvPanel.Application.Apps;
public sealed record ClientAppDto(Guid Id, string Name, string DownloadUrl, string? Description, string? IconUrl)
{
public static ClientAppDto FromDomain(ClientApp app) =>
new(app.Id, app.Name, app.DownloadUrl.ToString(), app.Description, app.IconUrl);
}
@@ -0,0 +1,7 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Apps;
namespace PnvPanel.Application.Apps;
public sealed record ListAppsQuery : IQuery<Result<IReadOnlyDictionary<OsPlatform, IReadOnlyList<ClientAppDto>>>>;
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Apps;
namespace PnvPanel.Application.Apps;
public sealed class ListAppsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListAppsQuery, Result<IReadOnlyDictionary<OsPlatform, IReadOnlyList<ClientAppDto>>>>
{
public async Task<Result<IReadOnlyDictionary<OsPlatform, IReadOnlyList<ClientAppDto>>>> Handle(
ListAppsQuery query, CancellationToken cancellationToken)
{
var apps = await dbContext.ClientApps.AsNoTracking()
.Where(a => a.IsEnabled)
.OrderBy(a => a.SortOrder)
.ToListAsync(cancellationToken);
var grouped = apps
.GroupBy(a => a.OperatingSystem)
.ToDictionary(
g => g.Key,
g => (IReadOnlyList<ClientAppDto>)g.Select(ClientAppDto.FromDomain).ToList());
return Result.Success<IReadOnlyDictionary<OsPlatform, IReadOnlyList<ClientAppDto>>>(grouped);
}
}
@@ -0,0 +1,21 @@
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Auth;
public static class AuthErrors
{
public static readonly Error DuplicateUserName =
Error.Conflict("Auth.DuplicateUserName", "Пользователь с таким именем уже существует.");
public static readonly Error InvalidCredentials =
Error.Unauthorized("Auth.InvalidCredentials", "Неверное имя пользователя или пароль.");
public static readonly Error LockedOut =
Error.Unauthorized("Auth.LockedOut", "Слишком много неудачных попыток входа. Попробуйте позже.");
public static readonly Error InvalidRefreshToken =
Error.Unauthorized("Auth.InvalidRefreshToken", "Недействительный refresh-токен.");
public static readonly Error Unauthorized =
Error.Unauthorized("Auth.Unauthorized", "Требуется аутентификация.");
}
@@ -0,0 +1,8 @@
namespace PnvPanel.Application.Auth;
public sealed record AuthResult(
string AccessToken,
DateTimeOffset AccessTokenExpiresAt,
string RefreshToken,
DateTimeOffset RefreshTokenExpiresAt,
CurrentUserDto User);
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Auth.ChangePassword;
public sealed record ChangePasswordCommand(string CurrentPassword, string NewPassword) : ICommand<Result>;
@@ -0,0 +1,17 @@
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Auth.ChangePassword;
public sealed class ChangePasswordCommandHandler(IIdentityService identityService, ICurrentUser currentUser)
: ICommandHandler<ChangePasswordCommand, Result>
{
public Task<Result> Handle(ChangePasswordCommand command, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return Task.FromResult(Result.Failure(AuthErrors.Unauthorized));
return identityService.ChangePasswordAsync(userId, command.CurrentPassword, command.NewPassword, cancellationToken);
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace PnvPanel.Application.Auth.ChangePassword;
public sealed class ChangePasswordCommandValidator : AbstractValidator<ChangePasswordCommand>
{
public ChangePasswordCommandValidator()
{
RuleFor(x => x.CurrentPassword).NotEmpty();
RuleFor(x => x.NewPassword).NotEmpty().MinimumLength(8);
}
}
@@ -0,0 +1,3 @@
namespace PnvPanel.Application.Auth;
public sealed record CurrentUserDto(Guid Id, string UserName, string Role, bool IsActivated);
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Auth.DeleteMyAccount;
public sealed record DeleteMyAccountCommand : ICommand<Result>;
@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Configs;
namespace PnvPanel.Application.Auth.DeleteMyAccount;
public sealed class DeleteMyAccountCommandHandler(IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway, ICurrentUser currentUser)
: ICommandHandler<DeleteMyAccountCommand, Result>
{
public async Task<Result> Handle(DeleteMyAccountCommand command, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return Result.Failure(AuthErrors.Unauthorized);
var configs = await dbContext.VpnConfigs
.Where(c => c.UserId == userId && c.Status != ConfigStatus.Revoked)
.ToListAsync(cancellationToken);
foreach (var config in configs)
{
var inbound = await dbContext.Inbounds.AsNoTracking().FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
var node = inbound is null
? null
: await dbContext.Nodes.AsNoTracking().FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
if (inbound is not null && node is not null)
await gateway.RemoveClientAsync(node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, cancellationToken);
config.Revoke();
}
await dbContext.SaveChangesAsync(cancellationToken);
return await identityService.DeleteUserAsync(userId, cancellationToken);
}
}
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Auth.Login;
public sealed record LoginCommand(string UserName, string Password) : ICommand<Result<AuthResult>>;
@@ -0,0 +1,35 @@
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Auth.Login;
public sealed class LoginCommandHandler(
IIdentityService identityService,
IJwtTokenService jwtTokenService,
IRefreshTokenService refreshTokenService) : ICommandHandler<LoginCommand, Result<AuthResult>>
{
public async Task<Result<AuthResult>> Handle(LoginCommand command, CancellationToken cancellationToken)
{
var credentialsResult = await identityService.ValidateCredentialsAsync(command.UserName, command.Password, cancellationToken);
if (!credentialsResult.IsSuccess)
return Result.Failure<AuthResult>(credentialsResult.Error);
var user = credentialsResult.Value;
var profile = await identityService.GetProfileAsync(user.Id, cancellationToken);
if (profile is null)
return Result.Failure<AuthResult>(AuthErrors.InvalidCredentials);
var (accessToken, accessExpiresAt) = jwtTokenService.GenerateAccessToken(user);
var refreshToken = await refreshTokenService.IssueAsync(user.Id, cancellationToken);
var dto = new CurrentUserDto(profile.Id, profile.UserName, profile.Role, profile.IsActivated);
return Result.Success(new AuthResult(
accessToken,
accessExpiresAt,
refreshToken.RawToken,
refreshToken.ExpiresAt,
dto));
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace PnvPanel.Application.Auth.Login;
public sealed class LoginCommandValidator : AbstractValidator<LoginCommand>
{
public LoginCommandValidator()
{
RuleFor(x => x.UserName).NotEmpty();
RuleFor(x => x.Password).NotEmpty();
}
}
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Auth.Logout;
public sealed record LogoutCommand(string RawRefreshToken) : ICommand<Result>;
@@ -0,0 +1,15 @@
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Auth.Logout;
public sealed class LogoutCommandHandler(IRefreshTokenService refreshTokenService)
: ICommandHandler<LogoutCommand, Result>
{
public async Task<Result> Handle(LogoutCommand command, CancellationToken cancellationToken)
{
await refreshTokenService.RevokeAsync(command.RawRefreshToken, cancellationToken);
return Result.Success();
}
}
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Auth.Me;
public sealed record GetCurrentUserQuery : IQuery<Result<CurrentUserDto>>;
@@ -0,0 +1,21 @@
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Auth.Me;
public sealed class GetCurrentUserQueryHandler(IIdentityService identityService, ICurrentUser currentUser)
: IQueryHandler<GetCurrentUserQuery, Result<CurrentUserDto>>
{
public async Task<Result<CurrentUserDto>> Handle(GetCurrentUserQuery query, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return Result.Failure<CurrentUserDto>(AuthErrors.Unauthorized);
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
if (profile is null)
return Result.Failure<CurrentUserDto>(AuthErrors.Unauthorized);
return Result.Success(new CurrentUserDto(profile.Id, profile.UserName, profile.Role, profile.IsActivated));
}
}
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Auth.Refresh;
public sealed record RefreshCommand(string RawRefreshToken) : ICommand<Result<AuthResult>>;
@@ -0,0 +1,34 @@
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Auth.Refresh;
public sealed class RefreshCommandHandler(
IIdentityService identityService,
IJwtTokenService jwtTokenService,
IRefreshTokenService refreshTokenService) : ICommandHandler<RefreshCommand, Result<AuthResult>>
{
public async Task<Result<AuthResult>> Handle(RefreshCommand command, CancellationToken cancellationToken)
{
var rotated = await refreshTokenService.RotateAsync(command.RawRefreshToken, cancellationToken);
if (!rotated.IsSuccess)
return Result.Failure<AuthResult>(rotated.Error);
var profile = await identityService.GetProfileAsync(rotated.Value.UserId, cancellationToken);
if (profile is null)
return Result.Failure<AuthResult>(AuthErrors.InvalidRefreshToken);
var authUser = new AuthenticatedUser(profile.Id, profile.UserName, profile.Role);
var (accessToken, accessExpiresAt) = jwtTokenService.GenerateAccessToken(authUser);
var dto = new CurrentUserDto(profile.Id, profile.UserName, profile.Role, profile.IsActivated);
return Result.Success(new AuthResult(
accessToken,
accessExpiresAt,
rotated.Value.RawToken,
rotated.Value.ExpiresAt,
dto));
}
}
@@ -0,0 +1,11 @@
using FluentValidation;
namespace PnvPanel.Application.Auth.Refresh;
public sealed class RefreshCommandValidator : AbstractValidator<RefreshCommand>
{
public RefreshCommandValidator()
{
RuleFor(x => x.RawRefreshToken).NotEmpty();
}
}
@@ -0,0 +1,8 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Auth.Register;
public sealed record RegisterCommand(string UserName, string Password) : ICommand<Result<RegisterResult>>;
public sealed record RegisterResult(Guid Id, string UserName);
@@ -0,0 +1,18 @@
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Auth.Register;
public sealed class RegisterCommandHandler(IIdentityService identityService)
: ICommandHandler<RegisterCommand, Result<RegisterResult>>
{
public async Task<Result<RegisterResult>> Handle(RegisterCommand command, CancellationToken cancellationToken)
{
var result = await identityService.CreateUserAsync(command.UserName, command.Password, cancellationToken);
return result.IsSuccess
? Result.Success(new RegisterResult(result.Value, command.UserName))
: Result.Failure<RegisterResult>(result.Error);
}
}
@@ -0,0 +1,19 @@
using FluentValidation;
namespace PnvPanel.Application.Auth.Register;
public sealed class RegisterCommandValidator : AbstractValidator<RegisterCommand>
{
public RegisterCommandValidator()
{
RuleFor(x => x.UserName)
.NotEmpty()
.Length(3, 32)
.Matches("^[a-zA-Z0-9_.-]+$")
.WithMessage("Имя пользователя может содержать только латиницу, цифры, '_', '.', '-'.");
RuleFor(x => x.Password)
.NotEmpty()
.MinimumLength(8);
}
}
@@ -0,0 +1,20 @@
using Microsoft.Extensions.Logging;
using PnvPanel.Application.Common.Messaging;
namespace PnvPanel.Application.Common.Behaviors;
public sealed class LoggingBehavior<TRequest, TResponse>(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
var requestName = typeof(TRequest).Name;
logger.LogInformation("Обработка {RequestName}", requestName);
var response = await next();
logger.LogInformation("Обработан {RequestName}", requestName);
return response;
}
}
@@ -0,0 +1,21 @@
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
namespace PnvPanel.Application.Common.Behaviors;
/// <summary>
/// Коммитит изменения после успешного выполнения команды. Применяется автоматически только
/// к запросам, реализующим <see cref="ICommand{TResponse}"/> — благодаря generic-ограничению
/// DI-контейнер не сможет сконструировать это поведение для запросов (IQuery).
/// </summary>
public sealed class UnitOfWorkBehavior<TRequest, TResponse>(IAppDbContext dbContext)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : ICommand<TResponse>
{
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
var response = await next();
await dbContext.SaveChangesAsync(cancellationToken);
return response;
}
}
@@ -0,0 +1,45 @@
using FluentValidation;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Common.Behaviors;
public sealed class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
where TResponse : Result
{
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
if (!validators.Any())
return await next();
var context = new ValidationContext<TRequest>(request);
var failures = validators
.Select(v => v.Validate(context))
.SelectMany(r => r.Errors)
.ToList();
if (failures.Count == 0)
return await next();
var error = Error.Validation(
"Validation.Failed",
string.Join("; ", failures.Select(f => f.ErrorMessage)));
return CreateFailure(error);
}
private static TResponse CreateFailure(Error error)
{
if (typeof(TResponse) == typeof(Result))
return (TResponse)(object)Result.Failure(error);
var valueType = typeof(TResponse).GetGenericArguments()[0];
var method = typeof(Result)
.GetMethod(nameof(Result.Failure), 1, [typeof(Error)])!
.MakeGenericMethod(valueType);
return (TResponse)method.Invoke(null, [error])!;
}
}
@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using PnvPanel.Domain.Activation;
using PnvPanel.Domain.Apps;
using PnvPanel.Domain.Configs;
using PnvPanel.Domain.Inbounds;
using PnvPanel.Domain.Nodes;
namespace PnvPanel.Application.Common.Interfaces;
public interface IAppDbContext
{
DbSet<ActivationRequest> ActivationRequests { get; }
DbSet<Node> Nodes { get; }
DbSet<Inbound> Inbounds { get; }
DbSet<VpnConfig> VpnConfigs { get; }
DbSet<ClientApp> ClientApps { get; }
/// <summary>Нужен для advisory-lock при проверке квоты конфигов (см. CreateVpnConfigCommandHandler).</summary>
DatabaseFacade Database { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
}
@@ -0,0 +1,8 @@
namespace PnvPanel.Application.Common.Interfaces;
public interface ICurrentUser
{
Guid? UserId { get; }
string? UserName { get; }
bool IsAuthenticated { get; }
}
@@ -0,0 +1,33 @@
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Common.Interfaces;
public sealed record AuthenticatedUser(Guid Id, string UserName, string Role);
public sealed record CurrentUserProfile(Guid Id, string UserName, Guid RoleId, string Role, bool IsActivated, int MaxConfigs);
public interface IIdentityService
{
Task<Result<Guid>> CreateUserAsync(string userName, string password, CancellationToken cancellationToken);
Task<Result<AuthenticatedUser>> ValidateCredentialsAsync(string userName, string password, CancellationToken cancellationToken);
Task<CurrentUserProfile?> GetProfileAsync(Guid userId, CancellationToken cancellationToken);
Task<Result> ChangePasswordAsync(Guid userId, string currentPassword, string newPassword, CancellationToken cancellationToken);
/// <summary>
/// Помечает пользователя активированным. Изменение не коммитится немедленно (в отличие от
/// CreateUserAsync/ChangePasswordAsync) — оно попадает в трекер того же DbContext и сохраняется
/// вместе с изменением ActivationRequest одной транзакцией через UnitOfWorkBehavior.
/// </summary>
Task<Result> ActivateUserAsync(Guid userId, Guid activatedBy, CancellationToken cancellationToken);
Task<IReadOnlyDictionary<Guid, string>> GetUserNamesAsync(IReadOnlyCollection<Guid> userIds, CancellationToken cancellationToken);
/// <summary>Удаляет аккаунт (самоудаление). Конфиги должны быть отозваны заранее вызывающей стороной.</summary>
Task<Result> DeleteUserAsync(Guid userId, CancellationToken cancellationToken);
/// <summary>Для агрегированной подписки /sub/{userToken} (все активные конфиги пользователя).</summary>
Task<Guid?> FindUserIdBySubscriptionTokenAsync(string token, CancellationToken cancellationToken);
}
@@ -0,0 +1,6 @@
namespace PnvPanel.Application.Common.Interfaces;
public interface IJwtTokenService
{
(string AccessToken, DateTimeOffset ExpiresAt) GenerateAccessToken(AuthenticatedUser user);
}

Some files were not shown because too many files have changed in this diff Show More