Refactor project files for improved readability and structure
CI / Backend (build + test) (push) Successful in 1m18s
CI / Frontend (lint + typecheck + build) (push) Successful in 31s

- Cleaned up whitespace in Directory.Build.props and Directory.Packages.props for consistency.
- Reformatted project file references in PnvPanel.Api.csproj for better clarity.
- Enhanced code readability in various endpoint files by adjusting line breaks and indentation.
- Standardized method signatures and improved formatting in ResultExtensions and multiple endpoint classes for better maintainability.
This commit is contained in:
Leonid Pershin
2026-07-14 07:24:13 +03:00
parent 9d5424bb9c
commit df137ca5a7
285 changed files with 6911 additions and 2063 deletions
-2
View File
@@ -1,5 +1,4 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
@@ -18,5 +17,4 @@
--> -->
<NoWarn>$(NoWarn);CA1711;CA1716;CA1848;CA1873</NoWarn> <NoWarn>$(NoWarn);CA1711;CA1716;CA1848;CA1873</NoWarn>
</PropertyGroup> </PropertyGroup>
</Project> </Project>
+8 -2
View File
@@ -10,7 +10,10 @@
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" /> <PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.9" /> <PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.9" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.9" /> <PackageVersion
Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore"
Version="10.0.9"
/>
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" /> <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
<PackageVersion Include="Microsoft.OpenApi" Version="2.9.0" /> <PackageVersion Include="Microsoft.OpenApi" Version="2.9.0" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.7" /> <PackageVersion Include="Scalar.AspNetCore" Version="2.16.7" />
@@ -22,7 +25,10 @@
</PackageVersion> </PackageVersion>
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" /> <PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.9" /> <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" /> <PackageVersion
Include="Microsoft.Extensions.DependencyInjection.Abstractions"
Version="10.0.9"
/>
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.19.1" /> <PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.19.1" />
<PackageVersion Include="Telegram.Bot" Version="22.10.1" /> <PackageVersion Include="Telegram.Bot" Version="22.10.1" />
<PackageVersion Include="ThreeXui.Net" Version="1.0.1" /> <PackageVersion Include="ThreeXui.Net" Version="1.0.1" />
@@ -4,11 +4,11 @@ namespace PnvPanel.Api.Common;
public static class ResultExtensions public static class ResultExtensions
{ {
public static IResult ToHttpResult(this Result result) public static IResult ToHttpResult(this Result result) =>
=> result.IsSuccess ? Results.NoContent() : ToProblem(result.Error); result.IsSuccess ? Results.NoContent() : ToProblem(result.Error);
public static IResult ToHttpResult<T>(this Result<T> result) public static IResult ToHttpResult<T>(this Result<T> result) =>
=> result.IsSuccess ? Results.Ok(result.Value) : ToProblem(result.Error); result.IsSuccess ? Results.Ok(result.Value) : ToProblem(result.Error);
private static IResult ToProblem(Error error) private static IResult ToProblem(Error error)
{ {
@@ -27,40 +27,69 @@ public static class ActivationEndpoints
return app; return app;
} }
private static async Task<IResult> GetStatus(ISender sender, CancellationToken cancellationToken) private static async Task<IResult> GetStatus(
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new GetActivationStatusQuery(), cancellationToken); var result = await sender.Send(new GetActivationStatusQuery(), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> RequestActivation(RequestActivationCommand command, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> RequestActivation(
RequestActivationCommand command,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(command, cancellationToken); var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> ListRequests( private static async Task<IResult> ListRequests(
[AsParameters] ListActivationRequestsRequest request, ISender sender, CancellationToken cancellationToken) [AsParameters] ListActivationRequestsRequest request,
ISender sender,
CancellationToken cancellationToken
)
{ {
var query = new ListActivationRequestsQuery(request.StatusFilter, request.Page, request.PageSize); var query = new ListActivationRequestsQuery(
request.StatusFilter,
request.Page,
request.PageSize
);
var result = await sender.Send(query, cancellationToken); var result = await sender.Send(query, cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> Approve(Guid id, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> Approve(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new ApproveActivationCommand(id), cancellationToken); var result = await sender.Send(new ApproveActivationCommand(id), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> Reject( private static async Task<IResult> Reject(
Guid id, RejectActivationBody body, ISender sender, CancellationToken cancellationToken) Guid id,
RejectActivationBody body,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new RejectActivationCommand(id, body.Reason), cancellationToken); var result = await sender.Send(
new RejectActivationCommand(id, body.Reason),
cancellationToken
);
return result.ToHttpResult(); return result.ToHttpResult();
} }
} }
public sealed record ListActivationRequestsRequest(ActivationStatus? StatusFilter, int Page = 1, int PageSize = 20); public sealed record ListActivationRequestsRequest(
ActivationStatus? StatusFilter,
int Page = 1,
int PageSize = 20
);
public sealed record RejectActivationBody(string? Reason); public sealed record RejectActivationBody(string? Reason);
@@ -28,21 +28,42 @@ public static class AdminAppEndpoints
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> CreateApp(CreateAppCommand command, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> CreateApp(
CreateAppCommand command,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(command, cancellationToken); var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> UpdateApp(Guid id, UpdateAppBody body, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> UpdateApp(
Guid id,
UpdateAppBody body,
ISender sender,
CancellationToken cancellationToken
)
{ {
var command = new UpdateAppCommand( var command = new UpdateAppCommand(
id, body.Name, body.DownloadUrl, body.OperatingSystem, body.Description, body.IconUrl, body.SortOrder, body.IsEnabled); id,
body.Name,
body.DownloadUrl,
body.OperatingSystem,
body.Description,
body.IconUrl,
body.SortOrder,
body.IsEnabled
);
var result = await sender.Send(command, cancellationToken); var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> DeleteApp(Guid id, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> DeleteApp(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new DeleteAppCommand(id), cancellationToken); var result = await sender.Send(new DeleteAppCommand(id), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
@@ -50,5 +71,11 @@ public static class AdminAppEndpoints
} }
public sealed record UpdateAppBody( public sealed record UpdateAppBody(
string Name, string DownloadUrl, OsPlatform OperatingSystem, string? Description, string? IconUrl, string Name,
int SortOrder, bool IsEnabled); string DownloadUrl,
OsPlatform OperatingSystem,
string? Description,
string? IconUrl,
int SortOrder,
bool IsEnabled
);
@@ -23,26 +23,44 @@ public static class AdminNewsEndpoints
return app; return app;
} }
private static async Task<IResult> ListAdminNews(int page, int pageSize, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> ListAdminNews(
int page,
int pageSize,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new ListAdminNewsQuery(page, pageSize), cancellationToken); var result = await sender.Send(new ListAdminNewsQuery(page, pageSize), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> CreatePost(CreatePostCommand command, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> CreatePost(
CreatePostCommand command,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(command, cancellationToken); var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> UpdatePost(Guid id, UpdatePostBody body, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> UpdatePost(
Guid id,
UpdatePostBody body,
ISender sender,
CancellationToken cancellationToken
)
{ {
var command = new UpdatePostCommand(id, body.Title, body.Body); var command = new UpdatePostCommand(id, body.Title, body.Body);
var result = await sender.Send(command, cancellationToken); var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> DeletePost(Guid id, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> DeletePost(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new DeletePostCommand(id), cancellationToken); var result = await sender.Send(new DeletePostCommand(id), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
@@ -27,7 +27,12 @@ public static class AdminStatsEndpoints
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> GetAudit(int page, int pageSize, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> GetAudit(
int page,
int pageSize,
ISender sender,
CancellationToken cancellationToken
)
{ {
var query = new ListAuditLogsQuery(page <= 0 ? 1 : page, pageSize <= 0 ? 50 : pageSize); var query = new ListAuditLogsQuery(page <= 0 ? 1 : page, pageSize <= 0 ? 50 : pageSize);
var result = await sender.Send(query, cancellationToken); var result = await sender.Send(query, cancellationToken);
@@ -19,59 +19,104 @@ public static class AdminSupportEndpoints
admin.MapGet("/tickets", ListTickets).Produces<PagedList<TicketSummaryDto>>(); admin.MapGet("/tickets", ListTickets).Produces<PagedList<TicketSummaryDto>>();
admin.MapGet("/tickets/{id:guid}", GetTicket).Produces<TicketDetailDto>(); admin.MapGet("/tickets/{id:guid}", GetTicket).Produces<TicketDetailDto>();
admin.MapPost("/tickets/{id:guid}/comments", AddComment).DisableAntiforgery().Produces<TicketCommentDto>(); admin
admin.MapPost("/tickets/{id:guid}/resolve", Resolve).Produces(StatusCodes.Status204NoContent); .MapPost("/tickets/{id:guid}/comments", AddComment)
.DisableAntiforgery()
.Produces<TicketCommentDto>();
admin
.MapPost("/tickets/{id:guid}/resolve", Resolve)
.Produces(StatusCodes.Status204NoContent);
admin.MapPost("/tickets/{id:guid}/close", Close).Produces(StatusCodes.Status204NoContent); admin.MapPost("/tickets/{id:guid}/close", Close).Produces(StatusCodes.Status204NoContent);
admin.MapPost("/tickets/{id:guid}/approve", ApproveRoleRequest).Produces(StatusCodes.Status204NoContent); admin
admin.MapPost("/tickets/{id:guid}/reject", RejectRoleRequest).Produces(StatusCodes.Status204NoContent); .MapPost("/tickets/{id:guid}/approve", ApproveRoleRequest)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/tickets/{id:guid}/reject", RejectRoleRequest)
.Produces(StatusCodes.Status204NoContent);
return app; return app;
} }
private static async Task<IResult> ListTickets( private static async Task<IResult> ListTickets(
[AsParameters] ListTicketsRequest request, ISender sender, CancellationToken cancellationToken) [AsParameters] ListTicketsRequest request,
ISender sender,
CancellationToken cancellationToken
)
{ {
var query = new ListAllTicketsQuery(request.Type, request.Status, request.Page, request.PageSize); var query = new ListAllTicketsQuery(
request.Type,
request.Status,
request.Page,
request.PageSize
);
var result = await sender.Send(query, cancellationToken); var result = await sender.Send(query, cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> GetTicket(Guid id, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> GetTicket(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new GetTicketAdminQuery(id), cancellationToken); var result = await sender.Send(new GetTicketAdminQuery(id), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> AddComment( private static async Task<IResult> AddComment(
Guid id, [FromForm] string body, IFormFileCollection? files, ISender sender, CancellationToken cancellationToken) Guid id,
[FromForm] string body,
IFormFileCollection? files,
ISender sender,
CancellationToken cancellationToken
)
{ {
var command = new AddTicketCommentCommand(id, body, SupportEndpoints.ToUploads(files)); var command = new AddTicketCommentCommand(id, body, SupportEndpoints.ToUploads(files));
var result = await sender.Send(command, cancellationToken); var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> Resolve(Guid id, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> Resolve(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new ResolveTicketCommand(id), cancellationToken); var result = await sender.Send(new ResolveTicketCommand(id), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> Close(Guid id, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> Close(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new CloseTicketCommand(id), cancellationToken); var result = await sender.Send(new CloseTicketCommand(id), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> ApproveRoleRequest(Guid id, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> ApproveRoleRequest(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new ApproveRoleRequestCommand(id), cancellationToken); var result = await sender.Send(new ApproveRoleRequestCommand(id), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> RejectRoleRequest( private static async Task<IResult> RejectRoleRequest(
Guid id, RejectRoleRequestBody body, ISender sender, CancellationToken cancellationToken) Guid id,
RejectRoleRequestBody body,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new RejectRoleRequestCommand(id, body.Reason), cancellationToken); var result = await sender.Send(
new RejectRoleRequestCommand(id, body.Reason),
cancellationToken
);
return result.ToHttpResult(); return result.ToHttpResult();
} }
} }
@@ -19,64 +19,118 @@ public static class AdminUserEndpoints
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("/users", ListUsers).Produces<PagedList<UserSummaryDto>>(); admin.MapGet("/users", ListUsers).Produces<PagedList<UserSummaryDto>>();
admin.MapPatch("/users/{id:guid}/block", BlockUser).Produces(StatusCodes.Status204NoContent); admin
admin.MapPatch("/users/{id:guid}/unblock", UnblockUser).Produces(StatusCodes.Status204NoContent); .MapPatch("/users/{id:guid}/block", BlockUser)
admin.MapPost("/users/{id:guid}/reset-password", ResetPassword).Produces(StatusCodes.Status204NoContent); .Produces(StatusCodes.Status204NoContent);
admin
.MapPatch("/users/{id:guid}/unblock", UnblockUser)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/users/{id:guid}/reset-password", ResetPassword)
.Produces(StatusCodes.Status204NoContent);
admin.MapDelete("/users/{id:guid}", DeleteUser).Produces(StatusCodes.Status204NoContent); admin.MapDelete("/users/{id:guid}", DeleteUser).Produces(StatusCodes.Status204NoContent);
admin.MapGet("/users/{id:guid}/configs", GetUserConfigs).Produces<IReadOnlyList<VpnConfigDto>>(); admin
.MapGet("/users/{id:guid}/configs", GetUserConfigs)
.Produces<IReadOnlyList<VpnConfigDto>>();
admin.MapGet("/configs", ListAllConfigs).Produces<PagedList<AdminVpnConfigDto>>(); admin.MapGet("/configs", ListAllConfigs).Produces<PagedList<AdminVpnConfigDto>>();
admin.MapDelete("/configs/{id:guid}", ForceRevokeConfig).Produces(StatusCodes.Status204NoContent); admin
.MapDelete("/configs/{id:guid}", ForceRevokeConfig)
.Produces(StatusCodes.Status204NoContent);
return app; return app;
} }
private static async Task<IResult> ListUsers( private static async Task<IResult> ListUsers(
int page, int pageSize, string? search, ISender sender, CancellationToken cancellationToken) int page,
int pageSize,
string? search,
ISender sender,
CancellationToken cancellationToken
)
{ {
var query = new ListUsersQuery(page <= 0 ? 1 : page, pageSize <= 0 ? 20 : pageSize, search); var query = new ListUsersQuery(page <= 0 ? 1 : page, pageSize <= 0 ? 20 : pageSize, search);
var result = await sender.Send(query, cancellationToken); var result = await sender.Send(query, cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> BlockUser(Guid id, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> BlockUser(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new BlockUserCommand(id), cancellationToken); var result = await sender.Send(new BlockUserCommand(id), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> UnblockUser(Guid id, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> UnblockUser(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new UnblockUserCommand(id), cancellationToken); var result = await sender.Send(new UnblockUserCommand(id), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> ResetPassword(Guid id, ResetPasswordBody body, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> ResetPassword(
Guid id,
ResetPasswordBody body,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new ResetUserPasswordCommand(id, body.NewPassword), cancellationToken); var result = await sender.Send(
new ResetUserPasswordCommand(id, body.NewPassword),
cancellationToken
);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> DeleteUser(Guid id, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> DeleteUser(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new DeleteUserCommand(id), cancellationToken); var result = await sender.Send(new DeleteUserCommand(id), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> GetUserConfigs(Guid id, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> GetUserConfigs(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new GetUserConfigsQuery(id), cancellationToken); var result = await sender.Send(new GetUserConfigsQuery(id), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> ListAllConfigs( private static async Task<IResult> ListAllConfigs(
int page, int pageSize, string? search, ConfigStatus? status, ISender sender, CancellationToken cancellationToken) int page,
int pageSize,
string? search,
ConfigStatus? status,
ISender sender,
CancellationToken cancellationToken
)
{ {
var query = new ListAllConfigsQuery(page <= 0 ? 1 : page, pageSize <= 0 ? 20 : pageSize, search, status); var query = new ListAllConfigsQuery(
page <= 0 ? 1 : page,
pageSize <= 0 ? 20 : pageSize,
search,
status
);
var result = await sender.Send(query, cancellationToken); var result = await sender.Send(query, cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> ForceRevokeConfig(Guid id, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> ForceRevokeConfig(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new ForceRevokeConfigCommand(id), cancellationToken); var result = await sender.Send(new ForceRevokeConfigCommand(id), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
@@ -25,34 +25,69 @@ public static class AuthEndpoints
group.MapPost("/register", Register).Produces<RegisterResult>(); group.MapPost("/register", Register).Produces<RegisterResult>();
group.MapPost("/login", Login).Produces<AuthResponseDto>(); group.MapPost("/login", Login).Produces<AuthResponseDto>();
group.MapPost("/refresh", Refresh).Produces<AuthResponseDto>(); group.MapPost("/refresh", Refresh).Produces<AuthResponseDto>();
group.MapPost("/logout", Logout).RequireAuthorization().Produces(StatusCodes.Status204NoContent); group
group.MapPost("/change-password", ChangePassword).RequireAuthorization().Produces(StatusCodes.Status204NoContent); .MapPost("/logout", Logout)
group.MapPost("/change-username", ChangeUserName).RequireAuthorization().Produces(StatusCodes.Status204NoContent); .RequireAuthorization()
.Produces(StatusCodes.Status204NoContent);
group
.MapPost("/change-password", ChangePassword)
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent);
group
.MapPost("/change-username", ChangeUserName)
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent);
group.MapGet("/me", Me).RequireAuthorization().Produces<CurrentUserDto>(); group.MapGet("/me", Me).RequireAuthorization().Produces<CurrentUserDto>();
group.MapDelete("/me", DeleteMe).RequireAuthorization().Produces(StatusCodes.Status204NoContent); group
.MapDelete("/me", DeleteMe)
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent);
return app; return app;
} }
private static async Task<IResult> Register(RegisterCommand command, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> Register(
RegisterCommand command,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(command, cancellationToken); var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> Login(LoginCommand command, ISender sender, HttpRequest request, HttpResponse response, CancellationToken cancellationToken) private static async Task<IResult> Login(
LoginCommand command,
ISender sender,
HttpRequest request,
HttpResponse response,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(command, cancellationToken); var result = await sender.Send(command, cancellationToken);
if (!result.IsSuccess) if (!result.IsSuccess)
return result.ToHttpResult(); return result.ToHttpResult();
SetRefreshCookie(request, response, result.Value.RefreshToken, result.Value.RefreshTokenExpiresAt); SetRefreshCookie(
request,
response,
result.Value.RefreshToken,
result.Value.RefreshTokenExpiresAt
);
return Results.Ok(ToLoginResponse(result.Value)); return Results.Ok(ToLoginResponse(result.Value));
} }
private static async Task<IResult> Refresh(HttpRequest request, HttpResponse response, ISender sender, CancellationToken cancellationToken) 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)) if (
!request.Cookies.TryGetValue(RefreshCookieName, out var rawToken)
|| string.IsNullOrEmpty(rawToken)
)
return Results.Unauthorized(); return Results.Unauthorized();
var result = await sender.Send(new RefreshCommand(rawToken), cancellationToken); var result = await sender.Send(new RefreshCommand(rawToken), cancellationToken);
@@ -62,29 +97,50 @@ public static class AuthEndpoints
return result.ToHttpResult(); return result.ToHttpResult();
} }
SetRefreshCookie(request, response, result.Value.RefreshToken, result.Value.RefreshTokenExpiresAt); SetRefreshCookie(
request,
response,
result.Value.RefreshToken,
result.Value.RefreshTokenExpiresAt
);
return Results.Ok(ToLoginResponse(result.Value)); return Results.Ok(ToLoginResponse(result.Value));
} }
internal static AuthResponseDto ToLoginResponse(AuthResult auth) => internal static AuthResponseDto ToLoginResponse(AuthResult auth) =>
new(auth.AccessToken, auth.AccessTokenExpiresAt, auth.User); new(auth.AccessToken, auth.AccessTokenExpiresAt, auth.User);
private static async Task<IResult> Logout(HttpRequest request, HttpResponse response, ISender sender, CancellationToken cancellationToken) 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)) if (
request.Cookies.TryGetValue(RefreshCookieName, out var rawToken)
&& !string.IsNullOrEmpty(rawToken)
)
await sender.Send(new LogoutCommand(rawToken), cancellationToken); await sender.Send(new LogoutCommand(rawToken), cancellationToken);
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request)); response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request));
return Results.NoContent(); return Results.NoContent();
} }
private static async Task<IResult> ChangePassword(ChangePasswordCommand command, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> ChangePassword(
ChangePasswordCommand command,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(command, cancellationToken); var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> ChangeUserName(ChangeUserNameCommand command, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> ChangeUserName(
ChangeUserNameCommand command,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(command, cancellationToken); var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
@@ -96,14 +152,24 @@ public static class AuthEndpoints
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> DeleteMe(HttpRequest request, HttpResponse response, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> DeleteMe(
HttpRequest request,
HttpResponse response,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new DeleteMyAccountCommand(), cancellationToken); var result = await sender.Send(new DeleteMyAccountCommand(), cancellationToken);
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request)); response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request));
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static void SetRefreshCookie(HttpRequest request, HttpResponse response, string rawToken, DateTimeOffset expiresAt) private static void SetRefreshCookie(
HttpRequest request,
HttpResponse response,
string rawToken,
DateTimeOffset expiresAt
)
{ {
var options = BuildCookieOptions(request); var options = BuildCookieOptions(request);
options.Expires = expiresAt; options.Expires = expiresAt;
@@ -112,13 +178,18 @@ public static class AuthEndpoints
// Secure = IsHttps запроса (учитывает ForwardedHeaders за внешним TLS-прокси, см. CLAUDE.md) — // Secure = IsHttps запроса (учитывает ForwardedHeaders за внешним TLS-прокси, см. CLAUDE.md) —
// иначе браузер/HttpClient не пришлёт cookie обратно на plain-http (локальный dev, TestServer). // иначе браузер/HttpClient не пришлёт cookie обратно на plain-http (локальный dev, TestServer).
private static CookieOptions BuildCookieOptions(HttpRequest request) => new() private static CookieOptions BuildCookieOptions(HttpRequest request) =>
{ new()
HttpOnly = true, {
Secure = request.IsHttps, HttpOnly = true,
SameSite = SameSiteMode.Strict, Secure = request.IsHttps,
Path = "/api/auth", SameSite = SameSiteMode.Strict,
}; Path = "/api/auth",
};
} }
public sealed record AuthResponseDto(string AccessToken, DateTimeOffset ExpiresAt, CurrentUserDto User); public sealed record AuthResponseDto(
string AccessToken,
DateTimeOffset ExpiresAt,
CurrentUserDto User
);
@@ -18,73 +18,113 @@ public static class ConfigEndpoints
{ {
var group = app.MapGroup("/api").WithTags("Configs").RequireAuthorization(); var group = app.MapGroup("/api").WithTags("Configs").RequireAuthorization();
group.MapGet("/inbounds/available", ListAvailableInbounds).Produces<IReadOnlyList<AvailableInboundDto>>(); group
.MapGet("/inbounds/available", ListAvailableInbounds)
.Produces<IReadOnlyList<AvailableInboundDto>>();
group.MapGet("/configs", GetMyConfigs).Produces<GetMyConfigsResult>(); group.MapGet("/configs", GetMyConfigs).Produces<GetMyConfigsResult>();
group.MapPost("/configs", CreateConfig).Produces<VpnConfigDto>(); group.MapPost("/configs", CreateConfig).Produces<VpnConfigDto>();
group.MapPatch("/configs/{id:guid}", EditConfig).Produces<VpnConfigDto>(); group.MapPatch("/configs/{id:guid}", EditConfig).Produces<VpnConfigDto>();
group.MapPost("/configs/{id:guid}/rotate", RotateConfig).Produces<VpnConfigDto>(); group.MapPost("/configs/{id:guid}/rotate", RotateConfig).Produces<VpnConfigDto>();
group.MapDelete("/configs/{id:guid}", RevokeConfig).Produces(StatusCodes.Status204NoContent); group
.MapDelete("/configs/{id:guid}", RevokeConfig)
.Produces(StatusCodes.Status204NoContent);
group.MapGet("/configs/{id:guid}/link", GetConfigLink).Produces<ConfigLinkResponseDto>(); group.MapGet("/configs/{id:guid}/link", GetConfigLink).Produces<ConfigLinkResponseDto>();
group.MapGet("/subscription", GetMySubscription).Produces<MySubscriptionResponseDto>(); group.MapGet("/subscription", GetMySubscription).Produces<MySubscriptionResponseDto>();
return app; return app;
} }
private static async Task<IResult> ListAvailableInbounds(ISender sender, CancellationToken cancellationToken) private static async Task<IResult> ListAvailableInbounds(
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new ListAvailableInboundsQuery(), cancellationToken); var result = await sender.Send(new ListAvailableInboundsQuery(), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> GetMyConfigs(ISender sender, CancellationToken cancellationToken) private static async Task<IResult> GetMyConfigs(
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new GetMyConfigsQuery(), cancellationToken); var result = await sender.Send(new GetMyConfigsQuery(), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> CreateConfig(CreateConfigBody body, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> CreateConfig(
CreateConfigBody body,
ISender sender,
CancellationToken cancellationToken
)
{ {
var command = new CreateVpnConfigCommand(body.InboundId, body.Label); var command = new CreateVpnConfigCommand(body.InboundId, body.Label);
var result = await sender.Send(command, cancellationToken); var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> EditConfig(Guid id, EditConfigBody body, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> EditConfig(
Guid id,
EditConfigBody body,
ISender sender,
CancellationToken cancellationToken
)
{ {
var command = new EditVpnConfigCommand(id, body.Label); var command = new EditVpnConfigCommand(id, body.Label);
var result = await sender.Send(command, cancellationToken); var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> RotateConfig(Guid id, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> RotateConfig(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new RotateVpnConfigCommand(id), cancellationToken); var result = await sender.Send(new RotateVpnConfigCommand(id), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> RevokeConfig(Guid id, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> RevokeConfig(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new RevokeVpnConfigCommand(id), cancellationToken); var result = await sender.Send(new RevokeVpnConfigCommand(id), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> GetConfigLink(Guid id, HttpRequest request, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> GetConfigLink(
Guid id,
HttpRequest request,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new GetConfigLinkQuery(id), cancellationToken); var result = await sender.Send(new GetConfigLinkQuery(id), cancellationToken);
if (!result.IsSuccess) if (!result.IsSuccess)
return result.ToHttpResult(); return result.ToHttpResult();
var subscriptionUrl = $"{request.Scheme}://{request.Host}/sub/{result.Value.SubscriptionToken}"; var subscriptionUrl =
return Results.Ok(new ConfigLinkResponseDto(result.Value.ConnectionString, subscriptionUrl)); $"{request.Scheme}://{request.Host}/sub/{result.Value.SubscriptionToken}";
return Results.Ok(
new ConfigLinkResponseDto(result.Value.ConnectionString, subscriptionUrl)
);
} }
private static async Task<IResult> GetMySubscription(HttpRequest request, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> GetMySubscription(
HttpRequest request,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new GetMySubscriptionQuery(), cancellationToken); var result = await sender.Send(new GetMySubscriptionQuery(), cancellationToken);
if (!result.IsSuccess) if (!result.IsSuccess)
return result.ToHttpResult(); return result.ToHttpResult();
var subscriptionUrl = $"{request.Scheme}://{request.Host}/sub/{result.Value.SubscriptionToken}"; var subscriptionUrl =
$"{request.Scheme}://{request.Host}/sub/{result.Value.SubscriptionToken}";
return Results.Ok(new MySubscriptionResponseDto(subscriptionUrl)); return Results.Ok(new MySubscriptionResponseDto(subscriptionUrl));
} }
} }
@@ -19,20 +19,38 @@ public static class InboundEndpoints
return app; return app;
} }
private static async Task<IResult> ListInbounds(Guid? nodeId, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> ListInbounds(
Guid? nodeId,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new ListInboundsQuery(nodeId), cancellationToken); var result = await sender.Send(new ListInboundsQuery(nodeId), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> PublishInbound( private static async Task<IResult> PublishInbound(
Guid id, PublishInboundBody body, ISender sender, CancellationToken cancellationToken) Guid id,
PublishInboundBody body,
ISender sender,
CancellationToken cancellationToken
)
{ {
var command = new PublishInboundCommand( var command = new PublishInboundCommand(
id, body.IsPublished, body.DisplayName, body.AllowedRoleIds ?? [], body.MaxClients); id,
body.IsPublished,
body.DisplayName,
body.AllowedRoleIds ?? [],
body.MaxClients
);
var result = await sender.Send(command, cancellationToken); var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
} }
public sealed record PublishInboundBody(bool IsPublished, string? DisplayName, IReadOnlyList<Guid>? AllowedRoleIds, int? MaxClients); public sealed record PublishInboundBody(
bool IsPublished,
string? DisplayName,
IReadOnlyList<Guid>? AllowedRoleIds,
int? MaxClients
);
@@ -16,7 +16,12 @@ public static class NewsEndpoints
return app; return app;
} }
private static async Task<IResult> ListNews(int page, int pageSize, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> ListNews(
int page,
int pageSize,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new ListNewsQuery(page, pageSize), cancellationToken); var result = await sender.Send(new ListNewsQuery(page, pageSize), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
@@ -23,42 +23,79 @@ public static class NodeEndpoints
return app; return app;
} }
private static async Task<IResult> ListNodes(ISender sender, CancellationToken cancellationToken) private static async Task<IResult> ListNodes(
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new ListNodesQuery(), cancellationToken); var result = await sender.Send(new ListNodesQuery(), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> RegisterNode(RegisterNodeCommand command, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> RegisterNode(
RegisterNodeCommand command,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(command, cancellationToken); var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> UpdateNode(Guid id, UpdateNodeBody body, ISender sender, CancellationToken cancellationToken) 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 command = new UpdateNodeCommand(
id,
body.Name,
body.Location,
body.IsEnabled,
body.Username,
body.Password
);
var result = await sender.Send(command, cancellationToken); var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> DeleteNode(Guid id, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> DeleteNode(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new DeleteNodeCommand(id), cancellationToken); var result = await sender.Send(new DeleteNodeCommand(id), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> SyncNode(Guid id, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> SyncNode(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new SyncNodeCommand(id), cancellationToken); var result = await sender.Send(new SyncNodeCommand(id), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> ProbeNode(Guid id, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> ProbeNode(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new ProbeNodeCommand(id), cancellationToken); var result = await sender.Send(new ProbeNodeCommand(id), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
} }
public sealed record UpdateNodeBody(string Name, string? Location, bool IsEnabled, string? Username, string? Password); public sealed record UpdateNodeBody(
string Name,
string? Location,
bool IsEnabled,
string? Username,
string? Password
);
@@ -19,38 +19,67 @@ public static class RoleEndpoints
admin.MapPost("/roles", CreateRole).Produces<RoleDto>(); admin.MapPost("/roles", CreateRole).Produces<RoleDto>();
admin.MapPut("/roles/{id:guid}", UpdateRole).Produces<RoleDto>(); admin.MapPut("/roles/{id:guid}", UpdateRole).Produces<RoleDto>();
admin.MapDelete("/roles/{id:guid}", DeleteRole).Produces(StatusCodes.Status204NoContent); admin.MapDelete("/roles/{id:guid}", DeleteRole).Produces(StatusCodes.Status204NoContent);
admin.MapPatch("/users/{id:guid}/role", ChangeUserRole).Produces(StatusCodes.Status204NoContent); admin
.MapPatch("/users/{id:guid}/role", ChangeUserRole)
.Produces(StatusCodes.Status204NoContent);
return app; return app;
} }
private static async Task<IResult> ListRoles(ISender sender, CancellationToken cancellationToken) private static async Task<IResult> ListRoles(
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new ListRolesQuery(), cancellationToken); var result = await sender.Send(new ListRolesQuery(), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> CreateRole(CreateRoleCommand command, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> CreateRole(
CreateRoleCommand command,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(command, cancellationToken); var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> UpdateRole(Guid id, UpdateRoleBody body, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> UpdateRole(
Guid id,
UpdateRoleBody body,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new UpdateRoleCommand(id, body.MaxConfigs, body.MaxIpLimit), cancellationToken); var result = await sender.Send(
new UpdateRoleCommand(id, body.MaxConfigs, body.MaxIpLimit),
cancellationToken
);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> DeleteRole(Guid id, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> DeleteRole(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new DeleteRoleCommand(id), cancellationToken); var result = await sender.Send(new DeleteRoleCommand(id), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> ChangeUserRole(Guid id, ChangeUserRoleBody body, ISender sender, CancellationToken cancellationToken) 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); var result = await sender.Send(
new ChangeUserRoleCommand(id, body.RoleId),
cancellationToken
);
return result.ToHttpResult(); return result.ToHttpResult();
} }
} }
@@ -19,12 +19,19 @@ public static class SubscriptionEndpoints
return app; return app;
} }
private static async Task<IResult> GetSubscription(string token, HttpResponse response, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> GetSubscription(
string token,
HttpResponse response,
ISender sender,
CancellationToken cancellationToken
)
{ {
// Токен — либо AppUser.SubscriptionToken (агрегированная подписка), либо VpnConfig.SubscriptionToken // Токен — либо AppUser.SubscriptionToken (агрегированная подписка), либо VpnConfig.SubscriptionToken
// (один конфиг). Пробуем пользовательский токен первым. // (один конфиг). Пробуем пользовательский токен первым.
var userResult = await sender.Send(new GetUserSubscriptionQuery(token), cancellationToken); var userResult = await sender.Send(new GetUserSubscriptionQuery(token), cancellationToken);
var result = userResult.IsSuccess ? userResult : await sender.Send(new GetConfigSubscriptionQuery(token), cancellationToken); var result = userResult.IsSuccess
? userResult
: await sender.Send(new GetConfigSubscriptionQuery(token), cancellationToken);
if (!result.IsSuccess) if (!result.IsSuccess)
return Results.NotFound(); return Results.NotFound();
@@ -36,7 +43,8 @@ public static class SubscriptionEndpoints
var expire = result.Value.ExpiresAt is { } exp ? exp.ToUnixTimeSeconds().ToString() : "0"; var expire = result.Value.ExpiresAt is { } exp ? exp.ToUnixTimeSeconds().ToString() : "0";
response.Headers.Append( response.Headers.Append(
"Subscription-Userinfo", "Subscription-Userinfo",
$"upload={result.Value.UsedUpBytes}; download={result.Value.UsedDownBytes}; total={total}; expire={expire}"); $"upload={result.Value.UsedUpBytes}; download={result.Value.UsedDownBytes}; total={total}; expire={expire}"
);
response.Headers.Append("Profile-Update-Interval", "12"); response.Headers.Append("Profile-Update-Interval", "12");
return Results.Text(base64Body, "text/plain; charset=utf-8"); return Results.Text(base64Body, "text/plain; charset=utf-8");
@@ -23,25 +23,38 @@ public static class SupportEndpoints
var group = app.MapGroup("/api/support").WithTags("Support").RequireAuthorization(); var group = app.MapGroup("/api/support").WithTags("Support").RequireAuthorization();
group.MapGet("/roles", ListSelectableRoles).Produces<IReadOnlyList<RoleDto>>(); group.MapGet("/roles", ListSelectableRoles).Produces<IReadOnlyList<RoleDto>>();
group.MapPost("/tickets/bug-reports", CreateBugReport).DisableAntiforgery().Produces<TicketDetailDto>(); group
.MapPost("/tickets/bug-reports", CreateBugReport)
.DisableAntiforgery()
.Produces<TicketDetailDto>();
group.MapPost("/tickets/role-requests", CreateRoleRequest).Produces<TicketDetailDto>(); group.MapPost("/tickets/role-requests", CreateRoleRequest).Produces<TicketDetailDto>();
group.MapGet("/tickets", ListMyTickets).Produces<PagedList<TicketSummaryDto>>(); group.MapGet("/tickets", ListMyTickets).Produces<PagedList<TicketSummaryDto>>();
group.MapGet("/tickets/{id:guid}", GetTicket).Produces<TicketDetailDto>(); group.MapGet("/tickets/{id:guid}", GetTicket).Produces<TicketDetailDto>();
group.MapPost("/tickets/{id:guid}/comments", AddComment).DisableAntiforgery().Produces<TicketCommentDto>(); group
.MapPost("/tickets/{id:guid}/comments", AddComment)
.DisableAntiforgery()
.Produces<TicketCommentDto>();
group.MapPost("/tickets/{id:guid}/reopen", Reopen).Produces(StatusCodes.Status204NoContent); group.MapPost("/tickets/{id:guid}/reopen", Reopen).Produces(StatusCodes.Status204NoContent);
group.MapGet("/attachments/{id:guid}", GetAttachment); group.MapGet("/attachments/{id:guid}", GetAttachment);
return app; return app;
} }
private static async Task<IResult> ListSelectableRoles(ISender sender, CancellationToken cancellationToken) private static async Task<IResult> ListSelectableRoles(
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new ListSelectableRolesQuery(), cancellationToken); var result = await sender.Send(new ListSelectableRolesQuery(), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> CreateBugReport( private static async Task<IResult> CreateBugReport(
[FromForm] string message, IFormFileCollection? files, ISender sender, CancellationToken cancellationToken) [FromForm] string message,
IFormFileCollection? files,
ISender sender,
CancellationToken cancellationToken
)
{ {
var command = new CreateBugReportTicketCommand(message, ToUploads(files)); var command = new CreateBugReportTicketCommand(message, ToUploads(files));
var result = await sender.Send(command, cancellationToken); var result = await sender.Send(command, cancellationToken);
@@ -49,43 +62,76 @@ public static class SupportEndpoints
} }
private static async Task<IResult> CreateRoleRequest( private static async Task<IResult> CreateRoleRequest(
CreateRoleRequestBody body, ISender sender, CancellationToken cancellationToken) CreateRoleRequestBody body,
ISender sender,
CancellationToken cancellationToken
)
{ {
var command = new CreateRoleRequestTicketCommand( var command = new CreateRoleRequestTicketCommand(
body.ExistingRoleId, body.NewRoleName, body.NewRoleMaxConfigs, body.NewRoleMaxIpLimit, body.Justification); body.ExistingRoleId,
body.NewRoleName,
body.NewRoleMaxConfigs,
body.NewRoleMaxIpLimit,
body.Justification
);
var result = await sender.Send(command, cancellationToken); var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> ListMyTickets( private static async Task<IResult> ListMyTickets(
[AsParameters] ListTicketsRequest request, ISender sender, CancellationToken cancellationToken) [AsParameters] ListTicketsRequest request,
ISender sender,
CancellationToken cancellationToken
)
{ {
var query = new ListMyTicketsQuery(request.Type, request.Status, request.Page, request.PageSize); var query = new ListMyTicketsQuery(
request.Type,
request.Status,
request.Page,
request.PageSize
);
var result = await sender.Send(query, cancellationToken); var result = await sender.Send(query, cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> GetTicket(Guid id, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> GetTicket(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new GetTicketQuery(id), cancellationToken); var result = await sender.Send(new GetTicketQuery(id), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> AddComment( private static async Task<IResult> AddComment(
Guid id, [FromForm] string body, IFormFileCollection? files, ISender sender, CancellationToken cancellationToken) Guid id,
[FromForm] string body,
IFormFileCollection? files,
ISender sender,
CancellationToken cancellationToken
)
{ {
var command = new AddTicketCommentCommand(id, body, ToUploads(files)); var command = new AddTicketCommentCommand(id, body, ToUploads(files));
var result = await sender.Send(command, cancellationToken); var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> Reopen(Guid id, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> Reopen(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new ReopenTicketCommand(id), cancellationToken); var result = await sender.Send(new ReopenTicketCommand(id), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> GetAttachment(Guid id, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> GetAttachment(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new GetTicketAttachmentQuery(id), cancellationToken); var result = await sender.Send(new GetTicketAttachmentQuery(id), cancellationToken);
if (!result.IsSuccess) if (!result.IsSuccess)
@@ -100,12 +146,27 @@ public static class SupportEndpoints
return []; return [];
return files return files
.Select(f => new TicketAttachmentUpload(f.OpenReadStream(), f.FileName, f.ContentType, f.Length)) .Select(f => new TicketAttachmentUpload(
f.OpenReadStream(),
f.FileName,
f.ContentType,
f.Length
))
.ToList(); .ToList();
} }
} }
public sealed record CreateRoleRequestBody( public sealed record CreateRoleRequestBody(
Guid? ExistingRoleId, string? NewRoleName, int? NewRoleMaxConfigs, int? NewRoleMaxIpLimit, string Justification); Guid? ExistingRoleId,
string? NewRoleName,
int? NewRoleMaxConfigs,
int? NewRoleMaxIpLimit,
string Justification
);
public sealed record ListTicketsRequest(TicketType? Type, TicketStatus? Status, int Page = 1, int PageSize = 20); public sealed record ListTicketsRequest(
TicketType? Type,
TicketStatus? Status,
int Page = 1,
int PageSize = 20
);
@@ -15,23 +15,38 @@ public static class TelegramEndpoints
.WithTags("Auth.Telegram") .WithTags("Auth.Telegram")
.RequireRateLimiting(RateLimiting.AuthPolicy); .RequireRateLimiting(RateLimiting.AuthPolicy);
group.MapPost("/link-token", CreateLinkToken).RequireAuthorization().Produces<LinkTokenResponseDto>(); group
group.MapPost("/unlink", Unlink).RequireAuthorization().Produces(StatusCodes.Status204NoContent); .MapPost("/link-token", CreateLinkToken)
group.MapPost("/login-request", CreateLoginRequest).Produces<TelegramLoginRequestResponseDto>(); .RequireAuthorization()
group.MapGet("/login-request/{id:guid}", GetLoginRequestStatus).Produces<TelegramLoginStatusResponseDto>(); .Produces<LinkTokenResponseDto>();
group
.MapPost("/unlink", Unlink)
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent);
group
.MapPost("/login-request", CreateLoginRequest)
.Produces<TelegramLoginRequestResponseDto>();
group
.MapGet("/login-request/{id:guid}", GetLoginRequestStatus)
.Produces<TelegramLoginStatusResponseDto>();
return app; return app;
} }
private static async Task<IResult> CreateLinkToken( private static async Task<IResult> CreateLinkToken(
ISender sender, ITelegramBotInfo botInfo, CancellationToken cancellationToken) ISender sender,
ITelegramBotInfo botInfo,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new CreateLinkTokenCommand(), cancellationToken); var result = await sender.Send(new CreateLinkTokenCommand(), cancellationToken);
if (!result.IsSuccess) if (!result.IsSuccess)
return result.ToHttpResult(); return result.ToHttpResult();
var botUsername = await botInfo.GetUsernameAsync(cancellationToken); var botUsername = await botInfo.GetUsernameAsync(cancellationToken);
var deepLink = botUsername is null ? null : $"https://t.me/{botUsername}?start=link_{result.Value.Token}"; var deepLink = botUsername is null
? null
: $"https://t.me/{botUsername}?start=link_{result.Value.Token}";
return Results.Ok(new LinkTokenResponseDto(deepLink, result.Value.ExpiresAt)); return Results.Ok(new LinkTokenResponseDto(deepLink, result.Value.ExpiresAt));
} }
@@ -43,7 +58,11 @@ public static class TelegramEndpoints
} }
private static async Task<IResult> CreateLoginRequest( private static async Task<IResult> CreateLoginRequest(
HttpRequest request, ISender sender, ITelegramBotInfo botInfo, CancellationToken cancellationToken) HttpRequest request,
ISender sender,
ITelegramBotInfo botInfo,
CancellationToken cancellationToken
)
{ {
var context = request.HttpContext.Connection.RemoteIpAddress?.ToString(); var context = request.HttpContext.Connection.RemoteIpAddress?.ToString();
var result = await sender.Send(new CreateLoginRequestCommand(context), cancellationToken); var result = await sender.Send(new CreateLoginRequestCommand(context), cancellationToken);
@@ -51,13 +70,26 @@ public static class TelegramEndpoints
return result.ToHttpResult(); return result.ToHttpResult();
var botUsername = await botInfo.GetUsernameAsync(cancellationToken); var botUsername = await botInfo.GetUsernameAsync(cancellationToken);
var deepLink = botUsername is null ? null : $"https://t.me/{botUsername}?start=login_{result.Value.RequestId}"; var deepLink = botUsername is null
? null
: $"https://t.me/{botUsername}?start=login_{result.Value.RequestId}";
return Results.Ok(new TelegramLoginRequestResponseDto(result.Value.RequestId, deepLink, result.Value.ExpiresAt)); return Results.Ok(
new TelegramLoginRequestResponseDto(
result.Value.RequestId,
deepLink,
result.Value.ExpiresAt
)
);
} }
private static async Task<IResult> GetLoginRequestStatus( private static async Task<IResult> GetLoginRequestStatus(
Guid id, HttpRequest request, HttpResponse response, ISender sender, CancellationToken cancellationToken) Guid id,
HttpRequest request,
HttpResponse response,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new GetLoginRequestStatusQuery(id), cancellationToken); var result = await sender.Send(new GetLoginRequestStatusQuery(id), cancellationToken);
if (!result.IsSuccess) if (!result.IsSuccess)
@@ -77,17 +109,33 @@ public static class TelegramEndpoints
}; };
response.Cookies.Append("pnv_refresh_token", auth.RefreshToken, cookieOptions); response.Cookies.Append("pnv_refresh_token", auth.RefreshToken, cookieOptions);
return Results.Ok(new TelegramLoginStatusResponseDto( return Results.Ok(
result.Value.Status, auth.AccessToken, auth.AccessTokenExpiresAt, auth.User)); new TelegramLoginStatusResponseDto(
result.Value.Status,
auth.AccessToken,
auth.AccessTokenExpiresAt,
auth.User
)
);
} }
return Results.Ok(new TelegramLoginStatusResponseDto(result.Value.Status, null, null, null)); return Results.Ok(
new TelegramLoginStatusResponseDto(result.Value.Status, null, null, null)
);
} }
} }
public sealed record LinkTokenResponseDto(string? DeepLink, DateTimeOffset ExpiresAt); public sealed record LinkTokenResponseDto(string? DeepLink, DateTimeOffset ExpiresAt);
public sealed record TelegramLoginRequestResponseDto(Guid RequestId, string? DeepLink, DateTimeOffset ExpiresAt); public sealed record TelegramLoginRequestResponseDto(
Guid RequestId,
string? DeepLink,
DateTimeOffset ExpiresAt
);
public sealed record TelegramLoginStatusResponseDto( public sealed record TelegramLoginStatusResponseDto(
TelegramLoginStatus Status, string? AccessToken, DateTimeOffset? ExpiresAt, CurrentUserDto? User); TelegramLoginStatus Status,
string? AccessToken,
DateTimeOffset? ExpiresAt,
CurrentUserDto? User
);
@@ -9,68 +9,146 @@ namespace PnvPanel.Api.Hubs;
internal sealed class SignalRRealtimeNotifier(IHubContext<PanelHub> hubContext) : IRealtimeNotifier internal sealed class SignalRRealtimeNotifier(IHubContext<PanelHub> hubContext) : IRealtimeNotifier
{ {
public Task NotifyConfigTrafficUpdatedAsync( public Task NotifyConfigTrafficUpdatedAsync(
Guid userId, Guid configId, long usedUpBytes, long usedDownBytes, CancellationToken cancellationToken) Guid userId,
Guid configId,
long usedUpBytes,
long usedDownBytes,
CancellationToken cancellationToken
)
{ {
return hubContext.Clients.Group(GroupNames.User(userId)).SendAsync( return hubContext
"configTrafficUpdated", .Clients.Group(GroupNames.User(userId))
new { configId, usedUpBytes, usedDownBytes }, .SendAsync(
cancellationToken); "configTrafficUpdated",
new
{
configId,
usedUpBytes,
usedDownBytes,
},
cancellationToken
);
} }
public Task NotifyConfigStatusChangedAsync(Guid userId, Guid configId, ConfigStatus status, CancellationToken cancellationToken) public Task NotifyConfigStatusChangedAsync(
Guid userId,
Guid configId,
ConfigStatus status,
CancellationToken cancellationToken
)
{ {
return hubContext.Clients.Group(GroupNames.User(userId)).SendAsync( return hubContext
"configStatusChanged", .Clients.Group(GroupNames.User(userId))
new { configId, status = status.ToString() }, .SendAsync(
cancellationToken); "configStatusChanged",
new { configId, status = status.ToString() },
cancellationToken
);
} }
public Task NotifyNodeStatusChangedAsync(Guid nodeId, NodeStatus status, DateTimeOffset? lastSyncAt, CancellationToken cancellationToken) public Task NotifyNodeStatusChangedAsync(
Guid nodeId,
NodeStatus status,
DateTimeOffset? lastSyncAt,
CancellationToken cancellationToken
)
{ {
return hubContext.Clients.Group(GroupNames.Admins).SendAsync( return hubContext
"nodeStatusChanged", .Clients.Group(GroupNames.Admins)
new { nodeId, status = status.ToString(), lastSyncAt }, .SendAsync(
cancellationToken); "nodeStatusChanged",
new
{
nodeId,
status = status.ToString(),
lastSyncAt,
},
cancellationToken
);
} }
public Task NotifyActivationRequestedAsync( public Task NotifyActivationRequestedAsync(
Guid requestId, Guid userId, string userName, string? comment, DateTimeOffset createdAt, CancellationToken cancellationToken) Guid requestId,
Guid userId,
string userName,
string? comment,
DateTimeOffset createdAt,
CancellationToken cancellationToken
)
{ {
return hubContext.Clients.Group(GroupNames.Admins).SendAsync( return hubContext
"activationRequested", .Clients.Group(GroupNames.Admins)
new { requestId, userId, userName, comment, createdAt }, .SendAsync(
cancellationToken); "activationRequested",
new
{
requestId,
userId,
userName,
comment,
createdAt,
},
cancellationToken
);
} }
public Task NotifyUserActivatedAsync(Guid userId, CancellationToken cancellationToken) public Task NotifyUserActivatedAsync(Guid userId, CancellationToken cancellationToken)
{ {
return hubContext.Clients.Group(GroupNames.User(userId)).SendAsync( return hubContext
"userActivated", .Clients.Group(GroupNames.User(userId))
new { userId }, .SendAsync("userActivated", new { userId }, cancellationToken);
cancellationToken);
} }
public Task NotifyNewsPublishedAsync(Guid postId, string title, DateTimeOffset createdAt, CancellationToken cancellationToken) public Task NotifyNewsPublishedAsync(
Guid postId,
string title,
DateTimeOffset createdAt,
CancellationToken cancellationToken
)
{ {
return hubContext.Clients.All.SendAsync( return hubContext.Clients.All.SendAsync(
"newsPublished", "newsPublished",
new { id = postId, title, createdAt }, new
cancellationToken); {
id = postId,
title,
createdAt,
},
cancellationToken
);
} }
public Task NotifyTicketCreatedAsync(Guid ticketId, Guid userId, string userName, TicketType type, CancellationToken cancellationToken) public Task NotifyTicketCreatedAsync(
Guid ticketId,
Guid userId,
string userName,
TicketType type,
CancellationToken cancellationToken
)
{ {
return hubContext.Clients.Group(GroupNames.Admins).SendAsync( return hubContext
"ticketCreated", .Clients.Group(GroupNames.Admins)
new { ticketId, userId, userName, type = type.ToString() }, .SendAsync(
cancellationToken); "ticketCreated",
new
{
ticketId,
userId,
userName,
type = type.ToString(),
},
cancellationToken
);
} }
public Task NotifyTicketUpdatedAsync(Guid ticketId, Guid userId, CancellationToken cancellationToken) public Task NotifyTicketUpdatedAsync(
Guid ticketId,
Guid userId,
CancellationToken cancellationToken
)
{ {
return hubContext.Clients.Group(GroupNames.User(userId)).SendAsync( return hubContext
"ticketUpdated", .Clients.Group(GroupNames.User(userId))
new { ticketId }, .SendAsync("ticketUpdated", new { ticketId }, cancellationToken);
cancellationToken);
} }
} }
+5 -3
View File
@@ -1,12 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\PnvPanel.Infrastructure\PnvPanel.Infrastructure.csproj" /> <ProjectReference Include="..\PnvPanel.Infrastructure\PnvPanel.Infrastructure.csproj" />
<ProjectReference Include="..\PnvPanel.Application\PnvPanel.Application.csproj" /> <ProjectReference Include="..\PnvPanel.Application\PnvPanel.Application.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Content Include="..\..\..\seed\client-apps.json" Link="seed\client-apps.json" CopyToOutputDirectory="PreserveNewest" /> <Content
Include="..\..\..\seed\client-apps.json"
Link="seed\client-apps.json"
CopyToOutputDirectory="PreserveNewest"
/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -27,5 +30,4 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
</Project> </Project>
+56 -23
View File
@@ -20,10 +20,13 @@ using Telegram.Bot;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// Структурное логирование (Serilog), конфигурация из appsettings/env. // Структурное логирование (Serilog), конфигурация из appsettings/env.
builder.Services.AddSerilog((services, configuration) => configuration builder.Services.AddSerilog(
.ReadFrom.Configuration(builder.Configuration) (services, configuration) =>
.ReadFrom.Services(services) configuration
.Enrich.FromLogContext()); .ReadFrom.Configuration(builder.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
);
// За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера), но ТОЛЬКО от явно // За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера), но ТОЛЬКО от явно
// перечисленных адресов/сетей прокси — иначе клиент может подделать свой IP/схему напрямую, минуя // перечисленных адресов/сетей прокси — иначе клиент может подделать свой IP/схему напрямую, минуя
@@ -34,13 +37,25 @@ builder.Services.Configure<ForwardedHeadersOptions>(options =>
{ {
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
foreach (var proxy in builder.Configuration.GetSection("ForwardedHeaders:KnownProxies").Get<string[]>() ?? []) foreach (
var proxy in builder
.Configuration.GetSection("ForwardedHeaders:KnownProxies")
.Get<string[]>()
?? []
)
options.KnownProxies.Add(IPAddress.Parse(proxy)); options.KnownProxies.Add(IPAddress.Parse(proxy));
foreach (var network in builder.Configuration.GetSection("ForwardedHeaders:KnownNetworks").Get<string[]>() ?? []) foreach (
var network in builder
.Configuration.GetSection("ForwardedHeaders:KnownNetworks")
.Get<string[]>()
?? []
)
{ {
var parts = network.Split('/'); var parts = network.Split('/');
options.KnownIPNetworks.Add(new System.Net.IPNetwork(IPAddress.Parse(parts[0]), int.Parse(parts[1]))); options.KnownIPNetworks.Add(
new System.Net.IPNetwork(IPAddress.Parse(parts[0]), int.Parse(parts[1]))
);
} }
}); });
@@ -49,6 +64,7 @@ builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration); builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddSignalR(); builder.Services.AddSignalR();
// В Api, не в Infrastructure — реализации нужен IHubContext<PanelHub>, а Hub определён здесь же. // В Api, не в Infrastructure — реализации нужен IHubContext<PanelHub>, а Hub определён здесь же.
builder.Services.AddSingleton<IRealtimeNotifier, SignalRRealtimeNotifier>(); builder.Services.AddSingleton<IRealtimeNotifier, SignalRRealtimeNotifier>();
@@ -74,17 +90,27 @@ builder.Services.AddSingleton<ITelegramBotClient>(sp =>
if (!string.IsNullOrEmpty(proxyUri.UserInfo)) if (!string.IsNullOrEmpty(proxyUri.UserInfo))
{ {
var credentials = proxyUri.UserInfo.Split(':', 2); var credentials = proxyUri.UserInfo.Split(':', 2);
proxy.Credentials = new NetworkCredential(credentials[0], credentials.Length > 1 ? credentials[1] : string.Empty); proxy.Credentials = new NetworkCredential(
credentials[0],
credentials.Length > 1 ? credentials[1] : string.Empty
);
} }
sp.GetRequiredService<ILogger<Program>>().LogInformation( sp.GetRequiredService<ILogger<Program>>()
"Telegram bot using proxy {Scheme}://{Host}:{Port}", proxyUri.Scheme, proxyUri.Host, proxyUri.Port); .LogInformation(
"Telegram bot using proxy {Scheme}://{Host}:{Port}",
proxyUri.Scheme,
proxyUri.Host,
proxyUri.Port
);
var handler = new SocketsHttpHandler { Proxy = proxy, UseProxy = true }; var handler = new SocketsHttpHandler { Proxy = proxy, UseProxy = true };
return new TelegramBotClient(token, new HttpClient(handler)); return new TelegramBotClient(token, new HttpClient(handler));
}); });
// Scoped — зависит от IIdentityService (scoped), не Singleton. // Scoped — зависит от IIdentityService (scoped), не Singleton.
builder.Services.AddScoped<ITelegramNotifier, TelegramNotifier>(); builder.Services.AddScoped<ITelegramNotifier, TelegramNotifier>();
// Singleton — кэширует username бота (getMe) на весь процесс, не из ручного env (см. TelegramBotInfo). // Singleton — кэширует username бота (getMe) на весь процесс, не из ручного env (см. TelegramBotInfo).
builder.Services.AddSingleton<ITelegramBotInfo, TelegramBotInfo>(); builder.Services.AddSingleton<ITelegramBotInfo, TelegramBotInfo>();
builder.Services.AddSingleton<PnvBotUpdateHandler>(); builder.Services.AddSingleton<PnvBotUpdateHandler>();
@@ -92,26 +118,32 @@ builder.Services.AddHostedService<TelegramBotHostedService>();
builder.Services.AddRateLimiter(options => builder.Services.AddRateLimiter(options =>
{ {
options.AddFixedWindowLimiter(RateLimiting.AuthPolicy, limiterOptions => options.AddFixedWindowLimiter(
{ RateLimiting.AuthPolicy,
// Настраиваемо через конфиг, чтобы интеграционные тесты (общий TestServer/host на весь limiterOptions =>
// collection, все запросы — от одного "клиента") могли поднять лимит и не ловить 429. {
limiterOptions.PermitLimit = builder.Configuration.GetValue("RateLimiting:AuthPermitLimit", 20); // Настраиваемо через конфиг, чтобы интеграционные тесты (общий TestServer/host на весь
limiterOptions.Window = TimeSpan.FromMinutes(1); // collection, все запросы — от одного "клиента") могли поднять лимит и не ловить 429.
limiterOptions.QueueLimit = 0; limiterOptions.PermitLimit = builder.Configuration.GetValue(
}); "RateLimiting:AuthPermitLimit",
20
);
limiterOptions.Window = TimeSpan.FromMinutes(1);
limiterOptions.QueueLimit = 0;
}
);
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
}); });
// Энумы сериализуются строками ("Vless", "Active", ...), не числами — самодокументируемый JSON, // Энумы сериализуются строками ("Vless", "Active", ...), не числами — самодокументируемый JSON,
// корректные строковые литералы при генерации TS-типов из OpenAPI-схемы (см. docs/frontend.md). // корректные строковые литералы при генерации TS-типов из OpenAPI-схемы (см. docs/frontend.md).
builder.Services.ConfigureHttpJsonOptions(options => builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())); options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())
);
builder.Services.AddProblemDetails(); builder.Services.AddProblemDetails();
builder.Services.AddOpenApi(); builder.Services.AddOpenApi();
builder.Services.AddHealthChecks() builder.Services.AddHealthChecks().AddDbContextCheck<AppDbContext>();
.AddDbContextCheck<AppDbContext>();
var app = builder.Build(); var app = builder.Build();
@@ -120,8 +152,9 @@ var app = builder.Build();
if (string.IsNullOrWhiteSpace(builder.Configuration["DataProtection:KeyRingPath"])) if (string.IsNullOrWhiteSpace(builder.Configuration["DataProtection:KeyRingPath"]))
{ {
app.Logger.LogWarning( app.Logger.LogWarning(
"DataProtection:KeyRingPath is not set — node secret encryption keys are not persistent " + "DataProtection:KeyRingPath is not set — node secret encryption keys are not persistent "
"and will be lost when the container is recreated. Mount a volume and set the path in production."); + "and will be lost when the container is recreated. Mount a volume and set the path in production."
);
} }
// Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте. // Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте.
@@ -23,24 +23,41 @@ namespace PnvPanel.Api.Telegram;
/// свежие scoped-сервисы (ISender, ICurrentUserSetter, ...). Бот — read-only по конфигам в MVP. /// свежие scoped-сервисы (ISender, ICurrentUserSetter, ...). Бот — read-only по конфигам в MVP.
/// </summary> /// </summary>
public sealed class PnvBotUpdateHandler( public sealed class PnvBotUpdateHandler(
IServiceScopeFactory scopeFactory, IOptions<TelegramOptions> options, ILogger<PnvBotUpdateHandler> logger) IServiceScopeFactory scopeFactory,
: IUpdateHandler IOptions<TelegramOptions> options,
ILogger<PnvBotUpdateHandler> logger
) : IUpdateHandler
{ {
// Показывается везде, где боту нужен привязанный аккаунт, а его нет — явно проговариваем оба шага, // Показывается везде, где боту нужен привязанный аккаунт, а его нет — явно проговариваем оба шага,
// иначе новые пользователи не понимают, что сначала нужен обычный аккаунт на сайте. // иначе новые пользователи не понимают, что сначала нужен обычный аккаунт на сайте.
private const string NotLinkedMessage = private const string NotLinkedMessage =
"Сначала зарегистрируйтесь и войдите на сайте, затем привяжите Telegram: Настройки → «Привязать Telegram»."; "Сначала зарегистрируйтесь и войдите на сайте, затем привяжите Telegram: Настройки → «Привязать Telegram».";
public async Task HandleUpdateAsync(ITelegramBotClient botClient, Update update, CancellationToken cancellationToken) public async Task HandleUpdateAsync(
ITelegramBotClient botClient,
Update update,
CancellationToken cancellationToken
)
{ {
await using var scope = scopeFactory.CreateAsyncScope(); await using var scope = scopeFactory.CreateAsyncScope();
try try
{ {
if (update.Message is { Text: { } text } message) if (update.Message is { Text: { } text } message)
await HandleMessageAsync(botClient, scope.ServiceProvider, message, text, cancellationToken); await HandleMessageAsync(
botClient,
scope.ServiceProvider,
message,
text,
cancellationToken
);
else if (update.CallbackQuery is { } callback) else if (update.CallbackQuery is { } callback)
await HandleCallbackAsync(botClient, scope.ServiceProvider, callback, cancellationToken); await HandleCallbackAsync(
botClient,
scope.ServiceProvider,
callback,
cancellationToken
);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -48,14 +65,24 @@ public sealed class PnvBotUpdateHandler(
} }
} }
public Task HandleErrorAsync(ITelegramBotClient botClient, Exception exception, HandleErrorSource source, CancellationToken cancellationToken) public Task HandleErrorAsync(
ITelegramBotClient botClient,
Exception exception,
HandleErrorSource source,
CancellationToken cancellationToken
)
{ {
logger.LogError(exception, "Telegram bot error (source {Source})", source); logger.LogError(exception, "Telegram bot error (source {Source})", source);
return Task.CompletedTask; return Task.CompletedTask;
} }
private async Task HandleMessageAsync( private async Task HandleMessageAsync(
ITelegramBotClient botClient, IServiceProvider services, Message message, string text, CancellationToken cancellationToken) ITelegramBotClient botClient,
IServiceProvider services,
Message message,
string text,
CancellationToken cancellationToken
)
{ {
var chatId = message.Chat.Id; var chatId = message.Chat.Id;
var fromId = message.From?.Id; var fromId = message.From?.Id;
@@ -67,11 +94,32 @@ public sealed class PnvBotUpdateHandler(
var payload = text.Length > 7 ? text[7..].Trim() : string.Empty; var payload = text.Length > 7 ? text[7..].Trim() : string.Empty;
if (payload.StartsWith("link_", StringComparison.Ordinal)) if (payload.StartsWith("link_", StringComparison.Ordinal))
await HandleLinkAsync(botClient, services, chatId, fromId.Value, message.From?.Username, payload[5..], cancellationToken); await HandleLinkAsync(
botClient,
services,
chatId,
fromId.Value,
message.From?.Username,
payload[5..],
cancellationToken
);
else if (payload.StartsWith("login_", StringComparison.Ordinal)) else if (payload.StartsWith("login_", StringComparison.Ordinal))
await HandleLoginPromptAsync(botClient, services, chatId, fromId.Value, payload[6..], cancellationToken); await HandleLoginPromptAsync(
botClient,
services,
chatId,
fromId.Value,
payload[6..],
cancellationToken
);
else else
await SendWelcomeAsync(botClient, services, chatId, fromId.Value, cancellationToken); await SendWelcomeAsync(
botClient,
services,
chatId,
fromId.Value,
cancellationToken
);
return; return;
} }
@@ -79,25 +127,57 @@ public sealed class PnvBotUpdateHandler(
switch (text) switch (text)
{ {
case "/configs": case "/configs":
await HandleConfigsAsync(botClient, services, chatId, fromId.Value, cancellationToken); await HandleConfigsAsync(
botClient,
services,
chatId,
fromId.Value,
cancellationToken
);
break; break;
case "/unlink": case "/unlink":
await HandleUnlinkAsync(botClient, services, chatId, fromId.Value, cancellationToken); await HandleUnlinkAsync(
botClient,
services,
chatId,
fromId.Value,
cancellationToken
);
break; break;
case "/requests": case "/requests":
await HandleRequestsAsync(botClient, services, chatId, fromId.Value, cancellationToken); await HandleRequestsAsync(
botClient,
services,
chatId,
fromId.Value,
cancellationToken
);
break; break;
case "/help": case "/help":
await SendWelcomeAsync(botClient, services, chatId, fromId.Value, cancellationToken); await SendWelcomeAsync(
botClient,
services,
chatId,
fromId.Value,
cancellationToken
);
break; break;
default: default:
await botClient.SendMessage(chatId, "Не понимаю эту команду. /help — список команд.", cancellationToken: cancellationToken); await botClient.SendMessage(
chatId,
"Не понимаю эту команду. /help — список команд.",
cancellationToken: cancellationToken
);
break; break;
} }
} }
private async Task HandleCallbackAsync( private async Task HandleCallbackAsync(
ITelegramBotClient botClient, IServiceProvider services, CallbackQuery callback, CancellationToken cancellationToken) ITelegramBotClient botClient,
IServiceProvider services,
CallbackQuery callback,
CancellationToken cancellationToken
)
{ {
var data = callback.Data; var data = callback.Data;
var chatId = callback.Message?.Chat.Id; var chatId = callback.Message?.Chat.Id;
@@ -107,7 +187,15 @@ public sealed class PnvBotUpdateHandler(
if (data == "reg:new") if (data == "reg:new")
{ {
await HandleRegisterCallbackAsync(botClient, services, chatId.Value, fromId, callback.From.Username, callback.Id, cancellationToken); await HandleRegisterCallbackAsync(
botClient,
services,
chatId.Value,
fromId,
callback.From.Username,
callback.Id,
cancellationToken
);
return; return;
} }
@@ -115,15 +203,28 @@ public sealed class PnvBotUpdateHandler(
{ {
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken)) if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
{ {
await botClient.AnswerCallbackQuery(callback.Id, "Telegram не привязан.", cancellationToken: cancellationToken); await botClient.AnswerCallbackQuery(
callback.Id,
"Telegram не привязан.",
cancellationToken: cancellationToken
);
return; return;
} }
await botClient.AnswerCallbackQuery(callback.Id, cancellationToken: cancellationToken); await botClient.AnswerCallbackQuery(callback.Id, cancellationToken: cancellationToken);
var (configsText, configsKeyboard) = await BuildConfigsMenuAsync(services, cancellationToken); var (configsText, configsKeyboard) = await BuildConfigsMenuAsync(
services,
cancellationToken
);
if (callback.Message is not null) if (callback.Message is not null)
await botClient.EditMessageText(chatId.Value, callback.Message.Id, configsText, replyMarkup: configsKeyboard, cancellationToken: cancellationToken); await botClient.EditMessageText(
chatId.Value,
callback.Message.Id,
configsText,
replyMarkup: configsKeyboard,
cancellationToken: cancellationToken
);
return; return;
} }
@@ -135,9 +236,18 @@ public sealed class PnvBotUpdateHandler(
return; return;
var identityService = services.GetRequiredService<IIdentityService>(); var identityService = services.GetRequiredService<IIdentityService>();
var linkedUserId = await identityService.FindUserIdByTelegramUserIdAsync(fromId, cancellationToken); var linkedUserId = await identityService.FindUserIdByTelegramUserIdAsync(
fromId,
cancellationToken
);
var (menuText, menuKeyboard) = BuildMainMenu(isLinked: linkedUserId is not null); var (menuText, menuKeyboard) = BuildMainMenu(isLinked: linkedUserId is not null);
await botClient.EditMessageText(chatId.Value, callback.Message.Id, menuText, replyMarkup: menuKeyboard, cancellationToken: cancellationToken); await botClient.EditMessageText(
chatId.Value,
callback.Message.Id,
menuText,
replyMarkup: menuKeyboard,
cancellationToken: cancellationToken
);
return; return;
} }
@@ -146,12 +256,19 @@ public sealed class PnvBotUpdateHandler(
{ {
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken)) if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
{ {
await botClient.AnswerCallbackQuery(callback.Id, "Telegram не привязан.", cancellationToken: cancellationToken); await botClient.AnswerCallbackQuery(
callback.Id,
"Telegram не привязан.",
cancellationToken: cancellationToken
);
return; return;
} }
var unlinkSender = services.GetRequiredService<ISender>(); var unlinkSender = services.GetRequiredService<ISender>();
var unlinkResult = await unlinkSender.Send(new UnlinkTelegramCommand(), cancellationToken); var unlinkResult = await unlinkSender.Send(
new UnlinkTelegramCommand(),
cancellationToken
);
await botClient.AnswerCallbackQuery(callback.Id, cancellationToken: cancellationToken); await botClient.AnswerCallbackQuery(callback.Id, cancellationToken: cancellationToken);
if (callback.Message is null) if (callback.Message is null)
@@ -160,15 +277,23 @@ public sealed class PnvBotUpdateHandler(
if (!unlinkResult.IsSuccess) if (!unlinkResult.IsSuccess)
{ {
await botClient.EditMessageText( await botClient.EditMessageText(
chatId.Value, callback.Message.Id, $"❌ Ошибка: {unlinkResult.Error.Message}", chatId.Value,
replyMarkup: BackToMenuKeyboard(), cancellationToken: cancellationToken); callback.Message.Id,
$"❌ Ошибка: {unlinkResult.Error.Message}",
replyMarkup: BackToMenuKeyboard(),
cancellationToken: cancellationToken
);
return; return;
} }
var (unlinkedText, unlinkedKeyboard) = BuildMainMenu(isLinked: false); var (unlinkedText, unlinkedKeyboard) = BuildMainMenu(isLinked: false);
await botClient.EditMessageText( await botClient.EditMessageText(
chatId.Value, callback.Message.Id, "✅ Telegram отвязан от аккаунта.\n\n" + unlinkedText, chatId.Value,
replyMarkup: unlinkedKeyboard, cancellationToken: cancellationToken); callback.Message.Id,
"✅ Telegram отвязан от аккаунта.\n\n" + unlinkedText,
replyMarkup: unlinkedKeyboard,
cancellationToken: cancellationToken
);
return; return;
} }
@@ -183,11 +308,22 @@ public sealed class PnvBotUpdateHandler(
{ {
case "login": case "login":
{ {
var result = parts[1] == "approve" var result =
? await sender.Send(new ApproveTelegramLoginCommand(requestId, fromId), cancellationToken) parts[1] == "approve"
: await sender.Send(new RejectTelegramLoginCommand(requestId, fromId), cancellationToken); ? await sender.Send(
new ApproveTelegramLoginCommand(requestId, fromId),
cancellationToken
)
: await sender.Send(
new RejectTelegramLoginCommand(requestId, fromId),
cancellationToken
);
await botClient.AnswerCallbackQuery(callback.Id, result.IsSuccess ? "Готово" : result.Error.Message, cancellationToken: cancellationToken); await botClient.AnswerCallbackQuery(
callback.Id,
result.IsSuccess ? "Готово" : result.Error.Message,
cancellationToken: cancellationToken
);
if (callback.Message is not null) if (callback.Message is not null)
{ {
var statusText = result.IsSuccess var statusText = result.IsSuccess
@@ -195,7 +331,12 @@ public sealed class PnvBotUpdateHandler(
: $"⚠️ {result.Error.Message}"; : $"⚠️ {result.Error.Message}";
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}"; var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
await botClient.EditMessageText( await botClient.EditMessageText(
chatId.Value, callback.Message.Id, text, parseMode: ParseMode.Html, cancellationToken: cancellationToken); chatId.Value,
callback.Message.Id,
text,
parseMode: ParseMode.Html,
cancellationToken: cancellationToken
);
} }
break; break;
@@ -204,26 +345,50 @@ public sealed class PnvBotUpdateHandler(
{ {
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken)) if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
{ {
await botClient.AnswerCallbackQuery(callback.Id, "Недостаточно прав.", cancellationToken: cancellationToken); await botClient.AnswerCallbackQuery(
callback.Id,
"Недостаточно прав.",
cancellationToken: cancellationToken
);
return; return;
} }
var result = parts[1] == "approve" var result =
? await sender.Send(new ApproveActivationCommand(requestId), cancellationToken) parts[1] == "approve"
: await sender.Send(new RejectActivationCommand(requestId, Reason: null), cancellationToken); ? await sender.Send(
new ApproveActivationCommand(requestId),
cancellationToken
)
: await sender.Send(
new RejectActivationCommand(requestId, Reason: null),
cancellationToken
);
await botClient.AnswerCallbackQuery(callback.Id, result.IsSuccess ? "Готово" : result.Error.Message, cancellationToken: cancellationToken); await botClient.AnswerCallbackQuery(
callback.Id,
result.IsSuccess ? "Готово" : result.Error.Message,
cancellationToken: cancellationToken
);
if (callback.Message is not null) if (callback.Message is not null)
{ {
// Редактируем исходное сообщение с запросом вместо отдельного — иначе кнопки // Редактируем исходное сообщение с запросом вместо отдельного — иначе кнопки
// «Активировать/Отклонить» остаются висеть под уже обработанным запросом (в т.ч. // «Активировать/Отклонить» остаются висеть под уже обработанным запросом (в т.ч.
// если его обработали в другом месте — на сайте или из другого чата). // если его обработали в другом месте — на сайте или из другого чата).
var statusText = result.IsSuccess var statusText = result.IsSuccess
? (parts[1] == "approve" ? "✅ Пользователь активирован." : "❌ Запрос отклонён.") ? (
parts[1] == "approve"
? "✅ Пользователь активирован."
: "❌ Запрос отклонён."
)
: $"⚠️ {result.Error.Message}"; : $"⚠️ {result.Error.Message}";
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}"; var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
await botClient.EditMessageText( await botClient.EditMessageText(
chatId.Value, callback.Message.Id, text, parseMode: ParseMode.Html, cancellationToken: cancellationToken); chatId.Value,
callback.Message.Id,
text,
parseMode: ParseMode.Html,
cancellationToken: cancellationToken
);
} }
break; break;
@@ -232,23 +397,47 @@ public sealed class PnvBotUpdateHandler(
{ {
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken)) if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
{ {
await botClient.AnswerCallbackQuery(callback.Id, "Недостаточно прав.", cancellationToken: cancellationToken); await botClient.AnswerCallbackQuery(
callback.Id,
"Недостаточно прав.",
cancellationToken: cancellationToken
);
return; return;
} }
var result = parts[1] == "approve" var result =
? await sender.Send(new ApproveRoleRequestCommand(requestId), cancellationToken) parts[1] == "approve"
: await sender.Send(new RejectRoleRequestCommand(requestId, Reason: null), cancellationToken); ? await sender.Send(
new ApproveRoleRequestCommand(requestId),
cancellationToken
)
: await sender.Send(
new RejectRoleRequestCommand(requestId, Reason: null),
cancellationToken
);
await botClient.AnswerCallbackQuery(callback.Id, result.IsSuccess ? "Готово" : result.Error.Message, cancellationToken: cancellationToken); await botClient.AnswerCallbackQuery(
callback.Id,
result.IsSuccess ? "Готово" : result.Error.Message,
cancellationToken: cancellationToken
);
if (callback.Message is not null) if (callback.Message is not null)
{ {
var statusText = result.IsSuccess var statusText = result.IsSuccess
? (parts[1] == "approve" ? "✅ Заявка одобрена, роль выдана." : "❌ Заявка отклонена.") ? (
parts[1] == "approve"
? "✅ Заявка одобрена, роль выдана."
: "❌ Заявка отклонена."
)
: $"⚠️ {result.Error.Message}"; : $"⚠️ {result.Error.Message}";
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}"; var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
await botClient.EditMessageText( await botClient.EditMessageText(
chatId.Value, callback.Message.Id, text, parseMode: ParseMode.Html, cancellationToken: cancellationToken); chatId.Value,
callback.Message.Id,
text,
parseMode: ParseMode.Html,
cancellationToken: cancellationToken
);
} }
break; break;
@@ -257,16 +446,30 @@ public sealed class PnvBotUpdateHandler(
{ {
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken)) if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
{ {
await botClient.AnswerCallbackQuery(callback.Id, "Telegram не привязан.", cancellationToken: cancellationToken); await botClient.AnswerCallbackQuery(
callback.Id,
"Telegram не привязан.",
cancellationToken: cancellationToken
);
return; return;
} }
var linkResult = await sender.Send(new GetConfigLinkQuery(requestId), cancellationToken); var linkResult = await sender.Send(
await botClient.AnswerCallbackQuery(callback.Id, cancellationToken: cancellationToken); new GetConfigLinkQuery(requestId),
cancellationToken
);
await botClient.AnswerCallbackQuery(
callback.Id,
cancellationToken: cancellationToken
);
if (!linkResult.IsSuccess) if (!linkResult.IsSuccess)
{ {
await botClient.SendMessage(chatId.Value, $"Не удалось получить ссылку: {linkResult.Error.Message}", cancellationToken: cancellationToken); await botClient.SendMessage(
chatId.Value,
$"Не удалось получить ссылку: {linkResult.Error.Message}",
cancellationToken: cancellationToken
);
return; return;
} }
@@ -276,15 +479,22 @@ public sealed class PnvBotUpdateHandler(
// Редактируем то же сообщение (не плодим отдельное с сырым URL) — ссылка моноширинным // Редактируем то же сообщение (не плодим отдельное с сырым URL) — ссылка моноширинным
// блоком, по нему в Telegram можно тапнуть и скопировать целиком одним движением. // блоком, по нему в Telegram можно тапнуть и скопировать целиком одним движением.
// Убираем именно эту кнопку из клавиатуры — остальные конфиги и «В меню» остаются на месте. // Убираем именно эту кнопку из клавиатуры — остальные конфиги и «В меню» остаются на месте.
var text = $"{Escape(callback.Message.Text ?? "")}\n\n<code>{Escape(linkResult.Value.ConnectionString)}</code>"; var text =
$"{Escape(callback.Message.Text ?? "")}\n\n<code>{Escape(linkResult.Value.ConnectionString)}</code>";
var remainingRows = (callback.Message.ReplyMarkup?.InlineKeyboard ?? []) var remainingRows = (callback.Message.ReplyMarkup?.InlineKeyboard ?? [])
.Where(row => row.All(b => b.CallbackData != data)) .Where(row => row.All(b => b.CallbackData != data))
.ToArray(); .ToArray();
await botClient.EditMessageText( await botClient.EditMessageText(
chatId.Value, callback.Message.Id, text, parseMode: ParseMode.Html, chatId.Value,
replyMarkup: remainingRows.Length > 0 ? new InlineKeyboardMarkup(remainingRows) : null, callback.Message.Id,
cancellationToken: cancellationToken); text,
parseMode: ParseMode.Html,
replyMarkup: remainingRows.Length > 0
? new InlineKeyboardMarkup(remainingRows)
: null,
cancellationToken: cancellationToken
);
break; break;
} }
@@ -292,11 +502,20 @@ public sealed class PnvBotUpdateHandler(
} }
private async Task HandleLinkAsync( private async Task HandleLinkAsync(
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, string? username, string token, ITelegramBotClient botClient,
CancellationToken cancellationToken) IServiceProvider services,
long chatId,
long fromId,
string? username,
string token,
CancellationToken cancellationToken
)
{ {
var sender = services.GetRequiredService<ISender>(); var sender = services.GetRequiredService<ISender>();
var result = await sender.Send(new LinkTelegramCommand(token, fromId, username), cancellationToken); var result = await sender.Send(
new LinkTelegramCommand(token, fromId, username),
cancellationToken
);
var text = result.IsSuccess var text = result.IsSuccess
? "✅ Telegram успешно привязан к вашему аккаунту." ? "✅ Telegram успешно привязан к вашему аккаунту."
@@ -306,56 +525,103 @@ public sealed class PnvBotUpdateHandler(
} }
private async Task HandleLoginPromptAsync( private async Task HandleLoginPromptAsync(
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, string requestIdRaw, CancellationToken cancellationToken) ITelegramBotClient botClient,
IServiceProvider services,
long chatId,
long fromId,
string requestIdRaw,
CancellationToken cancellationToken
)
{ {
if (!Guid.TryParse(requestIdRaw, out var requestId)) if (!Guid.TryParse(requestIdRaw, out var requestId))
{ {
await botClient.SendMessage(chatId, "Некорректная ссылка входа.", cancellationToken: cancellationToken); await botClient.SendMessage(
chatId,
"Некорректная ссылка входа.",
cancellationToken: cancellationToken
);
return; return;
} }
var identityService = services.GetRequiredService<IIdentityService>(); var identityService = services.GetRequiredService<IIdentityService>();
var userId = await identityService.FindUserIdByTelegramUserIdAsync(fromId, cancellationToken); var userId = await identityService.FindUserIdByTelegramUserIdAsync(
fromId,
cancellationToken
);
if (userId is null) if (userId is null)
{ {
await botClient.SendMessage( await botClient.SendMessage(
chatId, NotLinkedMessage, chatId,
replyMarkup: new InlineKeyboardMarkup(new[] { InlineKeyboardButton.WithCallbackData("📝 Зарегистрироваться", "reg:new") }), NotLinkedMessage,
cancellationToken: cancellationToken); replyMarkup: new InlineKeyboardMarkup(
new[]
{
InlineKeyboardButton.WithCallbackData("📝 Зарегистрироваться", "reg:new"),
}
),
cancellationToken: cancellationToken
);
return; return;
} }
var keyboard = new InlineKeyboardMarkup(new[] var keyboard = new InlineKeyboardMarkup(
{ new[]
InlineKeyboardButton.WithCallbackData("✅ Подтвердить вход", $"login:approve:{requestId}"), {
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"login:reject:{requestId}"), InlineKeyboardButton.WithCallbackData(
}); "✅ Подтвердить вход",
$"login:approve:{requestId}"
),
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"login:reject:{requestId}"),
}
);
await botClient.SendMessage( await botClient.SendMessage(
chatId, "Кто-то пытается войти в PnvPanel через ваш аккаунт. Подтвердить вход?", chatId,
replyMarkup: keyboard, cancellationToken: cancellationToken); "Кто-то пытается войти в PnvPanel через ваш аккаунт. Подтвердить вход?",
replyMarkup: keyboard,
cancellationToken: cancellationToken
);
} }
private async Task HandleConfigsAsync( private async Task HandleConfigsAsync(
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, CancellationToken cancellationToken) ITelegramBotClient botClient,
IServiceProvider services,
long chatId,
long fromId,
CancellationToken cancellationToken
)
{ {
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken)) if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
{ {
await botClient.SendMessage( await botClient.SendMessage(
chatId, NotLinkedMessage, chatId,
replyMarkup: new InlineKeyboardMarkup(new[] { InlineKeyboardButton.WithCallbackData("📝 Зарегистрироваться", "reg:new") }), NotLinkedMessage,
cancellationToken: cancellationToken); replyMarkup: new InlineKeyboardMarkup(
new[]
{
InlineKeyboardButton.WithCallbackData("📝 Зарегистрироваться", "reg:new"),
}
),
cancellationToken: cancellationToken
);
return; return;
} }
var (text, keyboard) = await BuildConfigsMenuAsync(services, cancellationToken); var (text, keyboard) = await BuildConfigsMenuAsync(services, cancellationToken);
await botClient.SendMessage(chatId, text, replyMarkup: keyboard, cancellationToken: cancellationToken); await botClient.SendMessage(
chatId,
text,
replyMarkup: keyboard,
cancellationToken: cancellationToken
);
} }
/// <summary>Список конфигов одним сообщением: строка на конфиг + кнопка «🔗 {Label}» на каждый /// <summary>Список конфигов одним сообщением: строка на конфиг + кнопка «🔗 {Label}» на каждый
/// не отозванный, плюс «🔙 В меню» внизу.</summary> /// не отозванный, плюс «🔙 В меню» внизу.</summary>
private static async Task<(string Text, InlineKeyboardMarkup Keyboard)> BuildConfigsMenuAsync( private static async Task<(string Text, InlineKeyboardMarkup Keyboard)> BuildConfigsMenuAsync(
IServiceProvider services, CancellationToken cancellationToken) IServiceProvider services,
CancellationToken cancellationToken
)
{ {
var sender = services.GetRequiredService<ISender>(); var sender = services.GetRequiredService<ISender>();
var result = await sender.Send(new GetMyConfigsQuery(), cancellationToken); var result = await sender.Send(new GetMyConfigsQuery(), cancellationToken);
@@ -363,29 +629,53 @@ public sealed class PnvBotUpdateHandler(
if (!result.IsSuccess || result.Value.Configs.Count == 0) if (!result.IsSuccess || result.Value.Configs.Count == 0)
return ("У вас пока нет конфигов.", BackToMenuKeyboard()); return ("У вас пока нет конфигов.", BackToMenuKeyboard());
var text = "Ваши конфиги:\n" + string.Join('\n', result.Value.Configs.Select(c => var text =
$"• {c.Label ?? c.Location} ({c.Protocol}) — {c.Status}")); "Ваши конфиги:\n"
+ string.Join(
'\n',
result.Value.Configs.Select(c =>
$"• {c.Label ?? c.Location} ({c.Protocol}) — {c.Status}"
)
);
// Отозванному конфигу нечего показывать — кнопку не даём. // Отозванному конфигу нечего показывать — кнопку не даём.
var rows = result.Value.Configs var rows = result
.Where(c => c.Status != ConfigStatus.Revoked) .Value.Configs.Where(c => c.Status != ConfigStatus.Revoked)
.Select(c => new[] { InlineKeyboardButton.WithCallbackData($"🔗 {c.Label ?? c.Location}", $"cfg:link:{c.Id}") }) .Select(c =>
new[]
{
InlineKeyboardButton.WithCallbackData(
$"🔗 {c.Label ?? c.Location}",
$"cfg:link:{c.Id}"
),
}
)
.Append(BackToMenuRow()) .Append(BackToMenuRow())
.ToArray(); .ToArray();
return (text, new InlineKeyboardMarkup(rows)); return (text, new InlineKeyboardMarkup(rows));
} }
private static InlineKeyboardButton[] BackToMenuRow() => new[] { InlineKeyboardButton.WithCallbackData("🔙 В меню", "menu:back") }; private static InlineKeyboardButton[] BackToMenuRow() =>
new[] { InlineKeyboardButton.WithCallbackData("🔙 В меню", "menu:back") };
private static InlineKeyboardMarkup BackToMenuKeyboard() => new(new[] { BackToMenuRow() }); private static InlineKeyboardMarkup BackToMenuKeyboard() => new(new[] { BackToMenuRow() });
private async Task HandleUnlinkAsync( private async Task HandleUnlinkAsync(
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, CancellationToken cancellationToken) ITelegramBotClient botClient,
IServiceProvider services,
long chatId,
long fromId,
CancellationToken cancellationToken
)
{ {
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken)) if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
{ {
await botClient.SendMessage(chatId, "Telegram не привязан.", cancellationToken: cancellationToken); await botClient.SendMessage(
chatId,
"Telegram не привязан.",
cancellationToken: cancellationToken
);
return; return;
} }
@@ -393,105 +683,191 @@ public sealed class PnvBotUpdateHandler(
var result = await sender.Send(new UnlinkTelegramCommand(), cancellationToken); var result = await sender.Send(new UnlinkTelegramCommand(), cancellationToken);
await botClient.SendMessage( await botClient.SendMessage(
chatId, result.IsSuccess ? "Telegram отвязан от аккаунта." : $"Ошибка: {result.Error.Message}", chatId,
cancellationToken: cancellationToken); result.IsSuccess ? "Telegram отвязан от аккаунта." : $"Ошибка: {result.Error.Message}",
cancellationToken: cancellationToken
);
} }
private async Task HandleRequestsAsync( private async Task HandleRequestsAsync(
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, CancellationToken cancellationToken) ITelegramBotClient botClient,
IServiceProvider services,
long chatId,
long fromId,
CancellationToken cancellationToken
)
{ {
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken)) if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
{ {
await botClient.SendMessage(chatId, "Недостаточно прав.", cancellationToken: cancellationToken); await botClient.SendMessage(
chatId,
"Недостаточно прав.",
cancellationToken: cancellationToken
);
return; return;
} }
var sender = services.GetRequiredService<ISender>(); var sender = services.GetRequiredService<ISender>();
var result = await sender.Send(new ListActivationRequestsQuery(ActivationStatus.Pending, 1, 10), cancellationToken); var result = await sender.Send(
new ListActivationRequestsQuery(ActivationStatus.Pending, 1, 10),
cancellationToken
);
if (!result.IsSuccess || result.Value.Items.Count == 0) if (!result.IsSuccess || result.Value.Items.Count == 0)
{ {
await botClient.SendMessage(chatId, "Нет ожидающих запросов на активацию.", cancellationToken: cancellationToken); await botClient.SendMessage(
chatId,
"Нет ожидающих запросов на активацию.",
cancellationToken: cancellationToken
);
return; return;
} }
foreach (var item in result.Value.Items) foreach (var item in result.Value.Items)
{ {
var text = $"Запрос от <b>{item.UserName}</b>" + (string.IsNullOrWhiteSpace(item.Comment) ? "" : $"\n{item.Comment}"); var text =
var keyboard = new InlineKeyboardMarkup(new[] $"Запрос от <b>{item.UserName}</b>"
{ + (string.IsNullOrWhiteSpace(item.Comment) ? "" : $"\n{item.Comment}");
InlineKeyboardButton.WithCallbackData("✅ Активировать", $"act:approve:{item.Id}"), var keyboard = new InlineKeyboardMarkup(
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"act:reject:{item.Id}"), new[]
}); {
await botClient.SendMessage(chatId, text, parseMode: ParseMode.Html, replyMarkup: keyboard, cancellationToken: cancellationToken); InlineKeyboardButton.WithCallbackData(
"✅ Активировать",
$"act:approve:{item.Id}"
),
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"act:reject:{item.Id}"),
}
);
await botClient.SendMessage(
chatId,
text,
parseMode: ParseMode.Html,
replyMarkup: keyboard,
cancellationToken: cancellationToken
);
} }
} }
private async Task SendWelcomeAsync( private async Task SendWelcomeAsync(
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, CancellationToken cancellationToken) ITelegramBotClient botClient,
IServiceProvider services,
long chatId,
long fromId,
CancellationToken cancellationToken
)
{ {
var identityService = services.GetRequiredService<IIdentityService>(); var identityService = services.GetRequiredService<IIdentityService>();
var userId = await identityService.FindUserIdByTelegramUserIdAsync(fromId, cancellationToken); var userId = await identityService.FindUserIdByTelegramUserIdAsync(
fromId,
cancellationToken
);
var (text, keyboard) = BuildMainMenu(isLinked: userId is not null); var (text, keyboard) = BuildMainMenu(isLinked: userId is not null);
await botClient.SendMessage(chatId, text, replyMarkup: keyboard, cancellationToken: cancellationToken); await botClient.SendMessage(
chatId,
text,
replyMarkup: keyboard,
cancellationToken: cancellationToken
);
} }
/// <summary>Привязанному аккаунту — кнопки-действия вместо текстовых команд; непривязанному — /// <summary>Привязанному аккаунту — кнопки-действия вместо текстовых команд; непривязанному —
/// только регистрация (остальное ему всё равно недоступно). Кнопка на сайт — если задан PublicSiteUrl.</summary> /// только регистрация (остальное ему всё равно недоступно). Кнопка на сайт — если задан PublicSiteUrl.</summary>
private (string Text, InlineKeyboardMarkup Keyboard) BuildMainMenu(bool isLinked) private (string Text, InlineKeyboardMarkup Keyboard) BuildMainMenu(bool isLinked)
{ {
const string text = "Привет! Это бот PnvPanel.\n\n" const string text =
"Привет! Это бот PnvPanel.\n\n"
+ "Вход без пароля запускается кнопкой «Войти через Telegram» на сайте — бот пришлёт запрос на подтверждение."; + "Вход без пароля запускается кнопкой «Войти через Telegram» на сайте — бот пришлёт запрос на подтверждение.";
var rows = new List<InlineKeyboardButton[]>(); var rows = new List<InlineKeyboardButton[]>();
if (isLinked) if (isLinked)
{ {
rows.Add(new[] { InlineKeyboardButton.WithCallbackData("📋 Мои конфиги", "menu:configs") }); rows.Add(
rows.Add(new[] { InlineKeyboardButton.WithCallbackData("🔓 Отвязать Telegram", "menu:unlink") }); new[] { InlineKeyboardButton.WithCallbackData("📋 Мои конфиги", "menu:configs") }
);
rows.Add(
new[]
{
InlineKeyboardButton.WithCallbackData("🔓 Отвязать Telegram", "menu:unlink"),
}
);
} }
else else
{ {
rows.Add(new[] { InlineKeyboardButton.WithCallbackData("📝 Зарегистрироваться", "reg:new") }); rows.Add(
new[] { InlineKeyboardButton.WithCallbackData("📝 Зарегистрироваться", "reg:new") }
);
} }
if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl)) if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl))
rows.Add(new[] { InlineKeyboardButton.WithUrl("🌐 Сайт панели", options.Value.PublicSiteUrl) }); rows.Add(
new[]
{
InlineKeyboardButton.WithUrl("🌐 Сайт панели", options.Value.PublicSiteUrl),
}
);
return (text, new InlineKeyboardMarkup(rows)); return (text, new InlineKeyboardMarkup(rows));
} }
private static async Task HandleRegisterCallbackAsync( private static async Task HandleRegisterCallbackAsync(
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, string? username, string callbackId, ITelegramBotClient botClient,
CancellationToken cancellationToken) IServiceProvider services,
long chatId,
long fromId,
string? username,
string callbackId,
CancellationToken cancellationToken
)
{ {
var sender = services.GetRequiredService<ISender>(); var sender = services.GetRequiredService<ISender>();
var result = await sender.Send(new RegisterViaTelegramCommand(fromId, username), cancellationToken); var result = await sender.Send(
new RegisterViaTelegramCommand(fromId, username),
cancellationToken
);
await botClient.AnswerCallbackQuery(callbackId, cancellationToken: cancellationToken); await botClient.AnswerCallbackQuery(callbackId, cancellationToken: cancellationToken);
if (!result.IsSuccess) if (!result.IsSuccess)
{ {
await botClient.SendMessage(chatId, $"Не удалось зарегистрироваться: {result.Error.Message}", cancellationToken: cancellationToken); await botClient.SendMessage(
chatId,
$"Не удалось зарегистрироваться: {result.Error.Message}",
cancellationToken: cancellationToken
);
return; return;
} }
var text = "✅ Аккаунт создан.\n\n" var text =
"✅ Аккаунт создан.\n\n"
+ $"Логин: <code>{result.Value.UserName}</code>\n" + $"Логин: <code>{result.Value.UserName}</code>\n"
+ $"Пароль: <code>{result.Value.Password}</code>\n\n" + $"Пароль: <code>{result.Value.Password}</code>\n\n"
+ "Сохраните пароль — он присылается только один раз. Логин можно сменить в Настройках на сайте.\n\n" + "Сохраните пароль — он присылается только один раз. Логин можно сменить в Настройках на сайте.\n\n"
+ "Дальше нужно дождаться активации администратором — после неё будут доступны конфиги. " + "Дальше нужно дождаться активации администратором — после неё будут доступны конфиги. "
+ "Входить можно как по паролю, так и кнопкой «Войти через Telegram»."; + "Входить можно как по паролю, так и кнопкой «Войти через Telegram».";
await botClient.SendMessage(chatId, text, parseMode: ParseMode.Html, cancellationToken: cancellationToken); await botClient.SendMessage(
chatId,
text,
parseMode: ParseMode.Html,
cancellationToken: cancellationToken
);
} }
private static string Escape(string text) => text.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;"); private static string Escape(string text) =>
text.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
private static async Task<bool> TrySetCurrentUserAsync(IServiceProvider services, long telegramUserId, CancellationToken cancellationToken) private static async Task<bool> TrySetCurrentUserAsync(
IServiceProvider services,
long telegramUserId,
CancellationToken cancellationToken
)
{ {
var identityService = services.GetRequiredService<IIdentityService>(); var identityService = services.GetRequiredService<IIdentityService>();
var userId = await identityService.FindUserIdByTelegramUserIdAsync(telegramUserId, cancellationToken); var userId = await identityService.FindUserIdByTelegramUserIdAsync(
telegramUserId,
cancellationToken
);
if (userId is null) if (userId is null)
return false; return false;
@@ -503,7 +879,11 @@ public sealed class PnvBotUpdateHandler(
return true; return true;
} }
private async Task<bool> TrySetAdminCurrentUserAsync(IServiceProvider services, long telegramUserId, CancellationToken cancellationToken) private async Task<bool> TrySetAdminCurrentUserAsync(
IServiceProvider services,
long telegramUserId,
CancellationToken cancellationToken
)
{ {
if (!options.Value.ParseAdminTelegramUserIds().Contains(telegramUserId)) if (!options.Value.ParseAdminTelegramUserIds().Contains(telegramUserId))
return false; return false;
@@ -13,9 +13,11 @@ namespace PnvPanel.Api.Telegram;
/// вызывает те же CQRS-команды, что и веб, через собственный ISender. /// вызывает те же CQRS-команды, что и веб, через собственный ISender.
/// </summary> /// </summary>
public sealed class TelegramBotHostedService( public sealed class TelegramBotHostedService(
ITelegramBotClient botClient, PnvBotUpdateHandler updateHandler, IOptions<TelegramOptions> options, ITelegramBotClient botClient,
ILogger<TelegramBotHostedService> logger) PnvBotUpdateHandler updateHandler,
: BackgroundService IOptions<TelegramOptions> options,
ILogger<TelegramBotHostedService> logger
) : BackgroundService
{ {
private static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(30); private static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(30);
@@ -6,7 +6,10 @@ using Telegram.Bot;
namespace PnvPanel.Api.Telegram; namespace PnvPanel.Api.Telegram;
/// <summary>Кэширует username бота на время жизни процесса (getMe не меняется, повторный запрос не нужен).</summary> /// <summary>Кэширует username бота на время жизни процесса (getMe не меняется, повторный запрос не нужен).</summary>
internal sealed class TelegramBotInfo(ITelegramBotClient botClient, IOptions<TelegramOptions> options) : ITelegramBotInfo internal sealed class TelegramBotInfo(
ITelegramBotClient botClient,
IOptions<TelegramOptions> options
) : ITelegramBotInfo
{ {
private readonly SemaphoreSlim _lock = new(1, 1); private readonly SemaphoreSlim _lock = new(1, 1);
private string? _cachedUsername; private string? _cachedUsername;
@@ -8,30 +8,49 @@ using Telegram.Bot.Types.ReplyMarkups;
namespace PnvPanel.Api.Telegram; namespace PnvPanel.Api.Telegram;
internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentityService identityService, IOptions<TelegramOptions> options) internal sealed class TelegramNotifier(
: ITelegramNotifier ITelegramBotClient botClient,
IIdentityService identityService,
IOptions<TelegramOptions> options
) : ITelegramNotifier
{ {
public async Task NotifyAdminsActivationRequestedAsync( public async Task NotifyAdminsActivationRequestedAsync(
Guid requestId, string userName, string? comment, CancellationToken cancellationToken) Guid requestId,
string userName,
string? comment,
CancellationToken cancellationToken
)
{ {
if (string.IsNullOrWhiteSpace(options.Value.BotToken)) if (string.IsNullOrWhiteSpace(options.Value.BotToken))
return; return;
var text = $"🆕 Запрос на активацию от <b>{Escape(userName)}</b>" var text =
+ (string.IsNullOrWhiteSpace(comment) ? string.Empty : $"\nКомментарий: {Escape(comment)}"); $"🆕 Запрос на активацию от <b>{Escape(userName)}</b>"
+ (
string.IsNullOrWhiteSpace(comment)
? string.Empty
: $"\nКомментарий: {Escape(comment)}"
);
var keyboard = new InlineKeyboardMarkup(new[] var keyboard = new InlineKeyboardMarkup(
{ new[]
InlineKeyboardButton.WithCallbackData("✅ Активировать", $"act:approve:{requestId}"), {
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"act:reject:{requestId}"), InlineKeyboardButton.WithCallbackData("✅ Активировать", $"act:approve:{requestId}"),
}); InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"act:reject:{requestId}"),
}
);
foreach (var adminId in options.Value.ParseAdminTelegramUserIds()) foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
{ {
try try
{ {
await botClient.SendMessage( await botClient.SendMessage(
adminId, text, parseMode: ParseMode.Html, replyMarkup: keyboard, cancellationToken: cancellationToken); adminId,
text,
parseMode: ParseMode.Html,
replyMarkup: keyboard,
cancellationToken: cancellationToken
);
} }
catch catch
{ {
@@ -40,20 +59,28 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
} }
} }
public async Task NotifyAdminsBugReportCreatedAsync(Guid ticketId, string userName, string message, CancellationToken cancellationToken) public async Task NotifyAdminsBugReportCreatedAsync(
Guid ticketId,
string userName,
string message,
CancellationToken cancellationToken
)
{ {
if (string.IsNullOrWhiteSpace(options.Value.BotToken)) if (string.IsNullOrWhiteSpace(options.Value.BotToken))
return; return;
var preview = message.Length > 300 ? message[..300] + "…" : message; var preview = message.Length > 300 ? message[..300] + "…" : message;
var text = $"🐞 Новый тикет (баг/предложение) от <b>{Escape(userName)}</b>\n{Escape(preview)}"; var text =
$"🐞 Новый тикет (баг/предложение) от <b>{Escape(userName)}</b>\n{Escape(preview)}";
// Только ссылка на сайт — переписка и картинки удобнее там, инлайн-действий для баг-тикетов нет. // Только ссылка на сайт — переписка и картинки удобнее там, инлайн-действий для баг-тикетов нет.
InlineKeyboardMarkup? keyboard = null; InlineKeyboardMarkup? keyboard = null;
if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl)) if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl))
{ {
var url = $"{options.Value.PublicSiteUrl.TrimEnd('/')}/admin/support?ticket={ticketId}"; var url = $"{options.Value.PublicSiteUrl.TrimEnd('/')}/admin/support?ticket={ticketId}";
keyboard = new InlineKeyboardMarkup(new[] { InlineKeyboardButton.WithUrl("🌐 Открыть на сайте", url) }); keyboard = new InlineKeyboardMarkup(
new[] { InlineKeyboardButton.WithUrl("🌐 Открыть на сайте", url) }
);
} }
foreach (var adminId in options.Value.ParseAdminTelegramUserIds()) foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
@@ -61,7 +88,12 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
try try
{ {
await botClient.SendMessage( await botClient.SendMessage(
adminId, text, parseMode: ParseMode.Html, replyMarkup: keyboard, cancellationToken: cancellationToken); adminId,
text,
parseMode: ParseMode.Html,
replyMarkup: keyboard,
cancellationToken: cancellationToken
);
} }
catch catch
{ {
@@ -71,25 +103,38 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
} }
public async Task NotifyAdminsRoleRequestCreatedAsync( public async Task NotifyAdminsRoleRequestCreatedAsync(
Guid ticketId, string userName, string roleDescription, string justification, CancellationToken cancellationToken) Guid ticketId,
string userName,
string roleDescription,
string justification,
CancellationToken cancellationToken
)
{ {
if (string.IsNullOrWhiteSpace(options.Value.BotToken)) if (string.IsNullOrWhiteSpace(options.Value.BotToken))
return; return;
var text = $"🆕 Заявка на роль от <b>{Escape(userName)}</b>\n{Escape(roleDescription)}\nОбоснование: {Escape(justification)}"; var text =
$"🆕 Заявка на роль от <b>{Escape(userName)}</b>\n{Escape(roleDescription)}\nОбоснование: {Escape(justification)}";
var keyboard = new InlineKeyboardMarkup(new[] var keyboard = new InlineKeyboardMarkup(
{ new[]
InlineKeyboardButton.WithCallbackData("✅ Одобрить", $"rrq:approve:{ticketId}"), {
InlineKeyboardButton.WithCallbackData(" Отклонить", $"rrq:reject:{ticketId}"), InlineKeyboardButton.WithCallbackData(" Одобрить", $"rrq:approve:{ticketId}"),
}); InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"rrq:reject:{ticketId}"),
}
);
foreach (var adminId in options.Value.ParseAdminTelegramUserIds()) foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
{ {
try try
{ {
await botClient.SendMessage( await botClient.SendMessage(
adminId, text, parseMode: ParseMode.Html, replyMarkup: keyboard, cancellationToken: cancellationToken); adminId,
text,
parseMode: ParseMode.Html,
replyMarkup: keyboard,
cancellationToken: cancellationToken
);
} }
catch catch
{ {
@@ -98,7 +143,12 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
} }
} }
public async Task NotifyAdminsTicketReopenedAsync(Guid ticketId, string userName, TicketType type, CancellationToken cancellationToken) public async Task NotifyAdminsTicketReopenedAsync(
Guid ticketId,
string userName,
TicketType type,
CancellationToken cancellationToken
)
{ {
if (string.IsNullOrWhiteSpace(options.Value.BotToken)) if (string.IsNullOrWhiteSpace(options.Value.BotToken))
return; return;
@@ -110,7 +160,9 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl)) if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl))
{ {
var url = $"{options.Value.PublicSiteUrl.TrimEnd('/')}/admin/support?ticket={ticketId}"; var url = $"{options.Value.PublicSiteUrl.TrimEnd('/')}/admin/support?ticket={ticketId}";
keyboard = new InlineKeyboardMarkup(new[] { InlineKeyboardButton.WithUrl("🌐 Открыть на сайте", url) }); keyboard = new InlineKeyboardMarkup(
new[] { InlineKeyboardButton.WithUrl("🌐 Открыть на сайте", url) }
);
} }
foreach (var adminId in options.Value.ParseAdminTelegramUserIds()) foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
@@ -118,7 +170,12 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
try try
{ {
await botClient.SendMessage( await botClient.SendMessage(
adminId, text, parseMode: ParseMode.Html, replyMarkup: keyboard, cancellationToken: cancellationToken); adminId,
text,
parseMode: ParseMode.Html,
replyMarkup: keyboard,
cancellationToken: cancellationToken
);
} }
catch catch
{ {
@@ -127,7 +184,10 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
} }
} }
public async Task NotifyUsersNewsPublishedAsync(string title, CancellationToken cancellationToken) public async Task NotifyUsersNewsPublishedAsync(
string title,
CancellationToken cancellationToken
)
{ {
if (string.IsNullOrWhiteSpace(options.Value.BotToken)) if (string.IsNullOrWhiteSpace(options.Value.BotToken))
return; return;
@@ -138,16 +198,25 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl)) if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl))
{ {
var url = $"{options.Value.PublicSiteUrl.TrimEnd('/')}/news"; var url = $"{options.Value.PublicSiteUrl.TrimEnd('/')}/news";
keyboard = new InlineKeyboardMarkup(new[] { InlineKeyboardButton.WithUrl("🌐 Открыть на сайте", url) }); keyboard = new InlineKeyboardMarkup(
new[] { InlineKeyboardButton.WithUrl("🌐 Открыть на сайте", url) }
);
} }
var telegramUserIds = await identityService.GetActivatedLinkedTelegramUserIdsAsync(cancellationToken); var telegramUserIds = await identityService.GetActivatedLinkedTelegramUserIdsAsync(
cancellationToken
);
foreach (var telegramUserId in telegramUserIds) foreach (var telegramUserId in telegramUserIds)
{ {
try try
{ {
await botClient.SendMessage( await botClient.SendMessage(
telegramUserId, text, parseMode: ParseMode.Html, replyMarkup: keyboard, cancellationToken: cancellationToken); telegramUserId,
text,
parseMode: ParseMode.Html,
replyMarkup: keyboard,
cancellationToken: cancellationToken
);
} }
catch catch
{ {
@@ -156,7 +225,11 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
} }
} }
public async Task NotifyUserAsync(Guid userId, string message, CancellationToken cancellationToken) public async Task NotifyUserAsync(
Guid userId,
string message,
CancellationToken cancellationToken
)
{ {
if (string.IsNullOrWhiteSpace(options.Value.BotToken)) if (string.IsNullOrWhiteSpace(options.Value.BotToken))
return; return;
@@ -167,11 +240,24 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
InlineKeyboardMarkup? keyboard = null; InlineKeyboardMarkup? keyboard = null;
if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl)) if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl))
keyboard = new InlineKeyboardMarkup(new[] { InlineKeyboardButton.WithUrl("🌐 Открыть на сайте", options.Value.PublicSiteUrl) }); keyboard = new InlineKeyboardMarkup(
new[]
{
InlineKeyboardButton.WithUrl(
"🌐 Открыть на сайте",
options.Value.PublicSiteUrl
),
}
);
try try
{ {
await botClient.SendMessage(telegramUserId, message, replyMarkup: keyboard, cancellationToken: cancellationToken); await botClient.SendMessage(
telegramUserId,
message,
replyMarkup: keyboard,
cancellationToken: cancellationToken
);
} }
catch catch
{ {
@@ -179,5 +265,6 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
} }
} }
private static string Escape(string text) => text.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;"); private static string Escape(string text) =>
text.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
} }
@@ -4,12 +4,18 @@ namespace PnvPanel.Application.Activation;
public static class ActivationErrors public static class ActivationErrors
{ {
public static readonly Error AlreadyPending = public static readonly Error AlreadyPending = Error.Conflict(
Error.Conflict("Activation.AlreadyPending", "У вас уже есть необработанный запрос на активацию."); "Activation.AlreadyPending",
"У вас уже есть необработанный запрос на активацию."
);
public static readonly Error NotFound = public static readonly Error NotFound = Error.NotFound(
Error.NotFound("Activation.NotFound", "Запрос на активацию не найден."); "Activation.NotFound",
"Запрос на активацию не найден."
);
public static readonly Error AlreadyDecided = public static readonly Error AlreadyDecided = Error.Conflict(
Error.Conflict("Activation.AlreadyDecided", "Запрос на активацию уже обработан."); "Activation.AlreadyDecided",
"Запрос на активацию уже обработан."
);
} }
@@ -7,10 +7,16 @@ using PnvPanel.Domain.Activation;
namespace PnvPanel.Application.Activation; namespace PnvPanel.Application.Activation;
public sealed class GetActivationStatusQueryHandler(IIdentityService identityService, IAppDbContext dbContext, ICurrentUser currentUser) public sealed class GetActivationStatusQueryHandler(
: IQueryHandler<GetActivationStatusQuery, Result<ActivationStatusDto>> IIdentityService identityService,
IAppDbContext dbContext,
ICurrentUser currentUser
) : IQueryHandler<GetActivationStatusQuery, Result<ActivationStatusDto>>
{ {
public async Task<Result<ActivationStatusDto>> Handle(GetActivationStatusQuery query, CancellationToken cancellationToken) public async Task<Result<ActivationStatusDto>> Handle(
GetActivationStatusQuery query,
CancellationToken cancellationToken
)
{ {
if (currentUser.UserId is not { } userId) if (currentUser.UserId is not { } userId)
return Result.Failure<ActivationStatusDto>(AuthErrors.Unauthorized); return Result.Failure<ActivationStatusDto>(AuthErrors.Unauthorized);
@@ -19,8 +25,10 @@ public sealed class GetActivationStatusQueryHandler(IIdentityService identitySer
if (profile is null) if (profile is null)
return Result.Failure<ActivationStatusDto>(AuthErrors.Unauthorized); return Result.Failure<ActivationStatusDto>(AuthErrors.Unauthorized);
var pending = await dbContext.ActivationRequests var pending = await dbContext
.Where(r => r.UserId == userId && r.Status == ActivationStatus.Pending) .ActivationRequests.Where(r =>
r.UserId == userId && r.Status == ActivationStatus.Pending
)
.Select(r => new ActivationRequestDto(r.Id, r.Comment, r.CreatedAt)) .Select(r => new ActivationRequestDto(r.Id, r.Comment, r.CreatedAt))
.FirstOrDefaultAsync(cancellationToken); .FirstOrDefaultAsync(cancellationToken);
@@ -3,4 +3,5 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Activation; namespace PnvPanel.Application.Activation;
public sealed record RequestActivationCommand(string? Comment) : ICommand<Result<ActivationRequestDto>>; public sealed record RequestActivationCommand(string? Comment)
: ICommand<Result<ActivationRequestDto>>;
@@ -8,16 +8,24 @@ using PnvPanel.Domain.Activation;
namespace PnvPanel.Application.Activation; namespace PnvPanel.Application.Activation;
public sealed class RequestActivationCommandHandler( public sealed class RequestActivationCommandHandler(
IAppDbContext dbContext, IRealtimeNotifier notifier, ITelegramNotifier telegramNotifier, ICurrentUser currentUser) IAppDbContext dbContext,
: ICommandHandler<RequestActivationCommand, Result<ActivationRequestDto>> IRealtimeNotifier notifier,
ITelegramNotifier telegramNotifier,
ICurrentUser currentUser
) : ICommandHandler<RequestActivationCommand, Result<ActivationRequestDto>>
{ {
public async Task<Result<ActivationRequestDto>> Handle(RequestActivationCommand command, CancellationToken cancellationToken) public async Task<Result<ActivationRequestDto>> Handle(
RequestActivationCommand command,
CancellationToken cancellationToken
)
{ {
if (currentUser.UserId is not { } userId) if (currentUser.UserId is not { } userId)
return Result.Failure<ActivationRequestDto>(AuthErrors.Unauthorized); return Result.Failure<ActivationRequestDto>(AuthErrors.Unauthorized);
var hasPending = await dbContext.ActivationRequests var hasPending = await dbContext.ActivationRequests.AnyAsync(
.AnyAsync(r => r.UserId == userId && r.Status == ActivationStatus.Pending, cancellationToken); r => r.UserId == userId && r.Status == ActivationStatus.Pending,
cancellationToken
);
if (hasPending) if (hasPending)
return Result.Failure<ActivationRequestDto>(ActivationErrors.AlreadyPending); return Result.Failure<ActivationRequestDto>(ActivationErrors.AlreadyPending);
@@ -27,9 +35,23 @@ public sealed class RequestActivationCommandHandler(
var userName = currentUser.UserName ?? userId.ToString(); var userName = currentUser.UserName ?? userId.ToString();
await notifier.NotifyActivationRequestedAsync(request.Id, userId, userName, request.Comment, request.CreatedAt, cancellationToken); await notifier.NotifyActivationRequestedAsync(
await telegramNotifier.NotifyAdminsActivationRequestedAsync(request.Id, userName, request.Comment, cancellationToken); request.Id,
userId,
userName,
request.Comment,
request.CreatedAt,
cancellationToken
);
await telegramNotifier.NotifyAdminsActivationRequestedAsync(
request.Id,
userName,
request.Comment,
cancellationToken
);
return Result.Success(new ActivationRequestDto(request.Id, request.Comment, request.CreatedAt)); return Result.Success(
new ActivationRequestDto(request.Id, request.Comment, request.CreatedAt)
);
} }
} }
@@ -8,4 +8,5 @@ public sealed record ActivationRequestAdminDto(
string UserName, string UserName,
string? Comment, string? Comment,
ActivationStatus Status, ActivationStatus Status,
DateTimeOffset CreatedAt); DateTimeOffset CreatedAt
);
@@ -10,17 +10,25 @@ using PnvPanel.Domain.Audit;
namespace PnvPanel.Application.Admin.Activation; namespace PnvPanel.Application.Admin.Activation;
public sealed class ApproveActivationCommandHandler( public sealed class ApproveActivationCommandHandler(
IAppDbContext dbContext, IIdentityService identityService, IRealtimeNotifier notifier, IAppDbContext dbContext,
ITelegramNotifier telegramNotifier, ICurrentUser currentUser) IIdentityService identityService,
: ICommandHandler<ApproveActivationCommand, Result> IRealtimeNotifier notifier,
ITelegramNotifier telegramNotifier,
ICurrentUser currentUser
) : ICommandHandler<ApproveActivationCommand, Result>
{ {
public async Task<Result> Handle(ApproveActivationCommand command, CancellationToken cancellationToken) public async Task<Result> Handle(
ApproveActivationCommand command,
CancellationToken cancellationToken
)
{ {
if (currentUser.UserId is not { } adminId) if (currentUser.UserId is not { } adminId)
return Result.Failure(AuthErrors.Unauthorized); return Result.Failure(AuthErrors.Unauthorized);
var request = await dbContext.ActivationRequests var request = await dbContext.ActivationRequests.FirstOrDefaultAsync(
.FirstOrDefaultAsync(r => r.Id == command.RequestId, cancellationToken); r => r.Id == command.RequestId,
cancellationToken
);
if (request is null) if (request is null)
return Result.Failure(ActivationErrors.NotFound); return Result.Failure(ActivationErrors.NotFound);
@@ -30,15 +38,31 @@ public sealed class ApproveActivationCommandHandler(
request.Approve(adminId); request.Approve(adminId);
var activateResult = await identityService.ActivateUserAsync(request.UserId, adminId, cancellationToken); var activateResult = await identityService.ActivateUserAsync(
request.UserId,
adminId,
cancellationToken
);
if (!activateResult.IsSuccess) if (!activateResult.IsSuccess)
return activateResult; return activateResult;
dbContext.AuditLogs.Add(AuditLog.Create( dbContext.AuditLogs.Add(
adminId, "ActivationApproved", "User", request.UserId.ToString(), metadata: null, AuditSource.Web)); AuditLog.Create(
adminId,
"ActivationApproved",
"User",
request.UserId.ToString(),
metadata: null,
AuditSource.Web
)
);
await notifier.NotifyUserActivatedAsync(request.UserId, cancellationToken); await notifier.NotifyUserActivatedAsync(request.UserId, cancellationToken);
await telegramNotifier.NotifyUserAsync(request.UserId, "✅ Ваш аккаунт активирован администратором.", cancellationToken); await telegramNotifier.NotifyUserAsync(
request.UserId,
"✅ Ваш аккаунт активирован администратором.",
cancellationToken
);
return Result.Success(); return Result.Success();
} }
} }
@@ -4,5 +4,8 @@ using PnvPanel.Domain.Activation;
namespace PnvPanel.Application.Admin.Activation; namespace PnvPanel.Application.Admin.Activation;
public sealed record ListActivationRequestsQuery(ActivationStatus? StatusFilter, int Page, int PageSize) public sealed record ListActivationRequestsQuery(
: IQuery<Result<PagedList<ActivationRequestAdminDto>>>; ActivationStatus? StatusFilter,
int Page,
int PageSize
) : IQuery<Result<PagedList<ActivationRequestAdminDto>>>;
@@ -5,10 +5,15 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Activation; namespace PnvPanel.Application.Admin.Activation;
public sealed class ListActivationRequestsQueryHandler(IAppDbContext dbContext, IIdentityService identityService) public sealed class ListActivationRequestsQueryHandler(
: IQueryHandler<ListActivationRequestsQuery, Result<PagedList<ActivationRequestAdminDto>>> IAppDbContext dbContext,
IIdentityService identityService
) : IQueryHandler<ListActivationRequestsQuery, Result<PagedList<ActivationRequestAdminDto>>>
{ {
public async Task<Result<PagedList<ActivationRequestAdminDto>>> Handle(ListActivationRequestsQuery query, CancellationToken cancellationToken) public async Task<Result<PagedList<ActivationRequestAdminDto>>> Handle(
ListActivationRequestsQuery query,
CancellationToken cancellationToken
)
{ {
var page = query.Page <= 0 ? 1 : query.Page; var page = query.Page <= 0 ? 1 : query.Page;
var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize; var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize;
@@ -23,13 +28,22 @@ public sealed class ListActivationRequestsQueryHandler(IAppDbContext dbContext,
var userNames = await identityService.GetUserNamesAsync( var userNames = await identityService.GetUserNamesAsync(
page1.Items.Select(r => r.UserId).Distinct().ToList(), page1.Items.Select(r => r.UserId).Distinct().ToList(),
cancellationToken); cancellationToken
);
var items = page1.Items var items = page1
.Select(r => new ActivationRequestAdminDto( .Items.Select(r => new ActivationRequestAdminDto(
r.Id, r.UserId, userNames.GetValueOrDefault(r.UserId, "?"), r.Comment, r.Status, r.CreatedAt)) r.Id,
r.UserId,
userNames.GetValueOrDefault(r.UserId, "?"),
r.Comment,
r.Status,
r.CreatedAt
))
.ToList(); .ToList();
return Result.Success(new PagedList<ActivationRequestAdminDto>(items, page1.Total, page1.Page, page1.PageSize)); return Result.Success(
new PagedList<ActivationRequestAdminDto>(items, page1.Total, page1.Page, page1.PageSize)
);
} }
} }
@@ -9,16 +9,23 @@ using PnvPanel.Domain.Audit;
namespace PnvPanel.Application.Admin.Activation; namespace PnvPanel.Application.Admin.Activation;
public sealed class RejectActivationCommandHandler(IAppDbContext dbContext, ICurrentUser currentUser) public sealed class RejectActivationCommandHandler(
: ICommandHandler<RejectActivationCommand, Result> IAppDbContext dbContext,
ICurrentUser currentUser
) : ICommandHandler<RejectActivationCommand, Result>
{ {
public async Task<Result> Handle(RejectActivationCommand command, CancellationToken cancellationToken) public async Task<Result> Handle(
RejectActivationCommand command,
CancellationToken cancellationToken
)
{ {
if (currentUser.UserId is not { } adminId) if (currentUser.UserId is not { } adminId)
return Result.Failure(AuthErrors.Unauthorized); return Result.Failure(AuthErrors.Unauthorized);
var request = await dbContext.ActivationRequests var request = await dbContext.ActivationRequests.FirstOrDefaultAsync(
.FirstOrDefaultAsync(r => r.Id == command.RequestId, cancellationToken); r => r.Id == command.RequestId,
cancellationToken
);
if (request is null) if (request is null)
return Result.Failure(ActivationErrors.NotFound); return Result.Failure(ActivationErrors.NotFound);
@@ -28,8 +35,16 @@ public sealed class RejectActivationCommandHandler(IAppDbContext dbContext, ICur
request.Reject(adminId, command.Reason); request.Reject(adminId, command.Reason);
dbContext.AuditLogs.Add(AuditLog.Create( dbContext.AuditLogs.Add(
adminId, "ActivationRejected", "User", request.UserId.ToString(), metadata: null, AuditSource.Web)); AuditLog.Create(
adminId,
"ActivationRejected",
"User",
request.UserId.ToString(),
metadata: null,
AuditSource.Web
)
);
return Result.Success(); return Result.Success();
} }
@@ -3,10 +3,25 @@ using PnvPanel.Domain.Apps;
namespace PnvPanel.Application.Admin.Apps; namespace PnvPanel.Application.Admin.Apps;
public sealed record AdminAppDto( public sealed record AdminAppDto(
Guid Id, string Name, string DownloadUrl, OsPlatform OperatingSystem, string? Description, Guid Id,
string? IconUrl, int SortOrder, bool IsEnabled) string Name,
string DownloadUrl,
OsPlatform OperatingSystem,
string? Description,
string? IconUrl,
int SortOrder,
bool IsEnabled
)
{ {
public static AdminAppDto FromDomain(ClientApp app) => new( public static AdminAppDto FromDomain(ClientApp app) =>
app.Id, app.Name, app.DownloadUrl.ToString(), app.OperatingSystem, app.Description, new(
app.IconUrl, app.SortOrder, app.IsEnabled); app.Id,
app.Name,
app.DownloadUrl.ToString(),
app.OperatingSystem,
app.Description,
app.IconUrl,
app.SortOrder,
app.IsEnabled
);
} }
@@ -4,5 +4,8 @@ namespace PnvPanel.Application.Admin.Apps;
public static class AppErrors public static class AppErrors
{ {
public static readonly Error NotFound = Error.NotFound("Apps.NotFound", "Приложение не найдено."); public static readonly Error NotFound = Error.NotFound(
"Apps.NotFound",
"Приложение не найдено."
);
} }
@@ -5,5 +5,10 @@ using PnvPanel.Domain.Apps;
namespace PnvPanel.Application.Admin.Apps; namespace PnvPanel.Application.Admin.Apps;
public sealed record CreateAppCommand( public sealed record CreateAppCommand(
string Name, string DownloadUrl, OsPlatform OperatingSystem, string? Description, string? IconUrl, int SortOrder) string Name,
: ICommand<Result<AdminAppDto>>; string DownloadUrl,
OsPlatform OperatingSystem,
string? Description,
string? IconUrl,
int SortOrder
) : ICommand<Result<AdminAppDto>>;
@@ -5,13 +5,22 @@ using PnvPanel.Domain.Apps;
namespace PnvPanel.Application.Admin.Apps; namespace PnvPanel.Application.Admin.Apps;
public sealed class CreateAppCommandHandler(IAppDbContext dbContext) : ICommandHandler<CreateAppCommand, Result<AdminAppDto>> public sealed class CreateAppCommandHandler(IAppDbContext dbContext)
: ICommandHandler<CreateAppCommand, Result<AdminAppDto>>
{ {
public Task<Result<AdminAppDto>> Handle(CreateAppCommand command, CancellationToken cancellationToken) public Task<Result<AdminAppDto>> Handle(
CreateAppCommand command,
CancellationToken cancellationToken
)
{ {
var app = ClientApp.Create( var app = ClientApp.Create(
command.Name, new Uri(command.DownloadUrl, UriKind.Absolute), command.OperatingSystem, command.Name,
command.Description, command.IconUrl, command.SortOrder); new Uri(command.DownloadUrl, UriKind.Absolute),
command.OperatingSystem,
command.Description,
command.IconUrl,
command.SortOrder
);
dbContext.ClientApps.Add(app); dbContext.ClientApps.Add(app);
@@ -5,11 +5,15 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Apps; namespace PnvPanel.Application.Admin.Apps;
public sealed class DeleteAppCommandHandler(IAppDbContext dbContext) : ICommandHandler<DeleteAppCommand, Result> public sealed class DeleteAppCommandHandler(IAppDbContext dbContext)
: ICommandHandler<DeleteAppCommand, Result>
{ {
public async Task<Result> Handle(DeleteAppCommand command, CancellationToken cancellationToken) public async Task<Result> Handle(DeleteAppCommand command, CancellationToken cancellationToken)
{ {
var app = await dbContext.ClientApps.FirstOrDefaultAsync(a => a.Id == command.AppId, cancellationToken); var app = await dbContext.ClientApps.FirstOrDefaultAsync(
a => a.Id == command.AppId,
cancellationToken
);
if (app is null) if (app is null)
return Result.Failure(AppErrors.NotFound); return Result.Failure(AppErrors.NotFound);
@@ -5,14 +5,22 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Apps; namespace PnvPanel.Application.Admin.Apps;
public sealed class ListAdminAppsQueryHandler(IAppDbContext dbContext) : IQueryHandler<ListAdminAppsQuery, Result<IReadOnlyList<AdminAppDto>>> public sealed class ListAdminAppsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListAdminAppsQuery, Result<IReadOnlyList<AdminAppDto>>>
{ {
public async Task<Result<IReadOnlyList<AdminAppDto>>> Handle(ListAdminAppsQuery query, CancellationToken cancellationToken) public async Task<Result<IReadOnlyList<AdminAppDto>>> Handle(
ListAdminAppsQuery query,
CancellationToken cancellationToken
)
{ {
var apps = await dbContext.ClientApps.AsNoTracking() var apps = await dbContext
.OrderBy(a => a.OperatingSystem).ThenBy(a => a.SortOrder) .ClientApps.AsNoTracking()
.OrderBy(a => a.OperatingSystem)
.ThenBy(a => a.SortOrder)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
return Result.Success<IReadOnlyList<AdminAppDto>>(apps.Select(AdminAppDto.FromDomain).ToList()); return Result.Success<IReadOnlyList<AdminAppDto>>(
apps.Select(AdminAppDto.FromDomain).ToList()
);
} }
} }
@@ -5,6 +5,12 @@ using PnvPanel.Domain.Apps;
namespace PnvPanel.Application.Admin.Apps; namespace PnvPanel.Application.Admin.Apps;
public sealed record UpdateAppCommand( public sealed record UpdateAppCommand(
Guid AppId, string Name, string DownloadUrl, OsPlatform OperatingSystem, string? Description, Guid AppId,
string? IconUrl, int SortOrder, bool IsEnabled) string Name,
: ICommand<Result<AdminAppDto>>; string DownloadUrl,
OsPlatform OperatingSystem,
string? Description,
string? IconUrl,
int SortOrder,
bool IsEnabled
) : ICommand<Result<AdminAppDto>>;
@@ -5,17 +5,30 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Apps; namespace PnvPanel.Application.Admin.Apps;
public sealed class UpdateAppCommandHandler(IAppDbContext dbContext) : ICommandHandler<UpdateAppCommand, Result<AdminAppDto>> public sealed class UpdateAppCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateAppCommand, Result<AdminAppDto>>
{ {
public async Task<Result<AdminAppDto>> Handle(UpdateAppCommand command, CancellationToken cancellationToken) public async Task<Result<AdminAppDto>> Handle(
UpdateAppCommand command,
CancellationToken cancellationToken
)
{ {
var app = await dbContext.ClientApps.FirstOrDefaultAsync(a => a.Id == command.AppId, cancellationToken); var app = await dbContext.ClientApps.FirstOrDefaultAsync(
a => a.Id == command.AppId,
cancellationToken
);
if (app is null) if (app is null)
return Result.Failure<AdminAppDto>(AppErrors.NotFound); return Result.Failure<AdminAppDto>(AppErrors.NotFound);
app.Update( app.Update(
command.Name, new Uri(command.DownloadUrl, UriKind.Absolute), command.OperatingSystem, command.Name,
command.Description, command.IconUrl, command.SortOrder, command.IsEnabled); new Uri(command.DownloadUrl, UriKind.Absolute),
command.OperatingSystem,
command.Description,
command.IconUrl,
command.SortOrder,
command.IsEnabled
);
return Result.Success(AdminAppDto.FromDomain(app)); return Result.Success(AdminAppDto.FromDomain(app));
} }
@@ -4,8 +4,16 @@ using PnvPanel.Domain.Audit;
namespace PnvPanel.Application.Admin.Audit; namespace PnvPanel.Application.Admin.Audit;
public sealed record ListAuditLogsQuery(int Page, int PageSize) : IQuery<Result<PagedList<AuditLogDto>>>; public sealed record ListAuditLogsQuery(int Page, int PageSize)
: IQuery<Result<PagedList<AuditLogDto>>>;
public sealed record AuditLogDto( public sealed record AuditLogDto(
long Id, Guid? ActorId, string Action, string TargetType, string TargetId, string? Metadata, long Id,
AuditSource Source, DateTimeOffset CreatedAt); Guid? ActorId,
string Action,
string TargetType,
string TargetId,
string? Metadata,
AuditSource Source,
DateTimeOffset CreatedAt
);
@@ -5,16 +5,30 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Audit; namespace PnvPanel.Application.Admin.Audit;
public sealed class ListAuditLogsQueryHandler(IAppDbContext dbContext) : IQueryHandler<ListAuditLogsQuery, Result<PagedList<AuditLogDto>>> public sealed class ListAuditLogsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListAuditLogsQuery, Result<PagedList<AuditLogDto>>>
{ {
public async Task<Result<PagedList<AuditLogDto>>> Handle(ListAuditLogsQuery query, CancellationToken cancellationToken) public async Task<Result<PagedList<AuditLogDto>>> Handle(
ListAuditLogsQuery query,
CancellationToken cancellationToken
)
{ {
var page = query.Page <= 0 ? 1 : query.Page; var page = query.Page <= 0 ? 1 : query.Page;
var pageSize = query.PageSize is <= 0 or > 200 ? 50 : query.PageSize; var pageSize = query.PageSize is <= 0 or > 200 ? 50 : query.PageSize;
var result = await dbContext.AuditLogs.AsNoTracking() var result = await dbContext
.AuditLogs.AsNoTracking()
.OrderByDescending(a => a.CreatedAt) .OrderByDescending(a => a.CreatedAt)
.Select(a => new AuditLogDto(a.Id, a.ActorId, a.Action, a.TargetType, a.TargetId, a.Metadata, a.Source, a.CreatedAt)) .Select(a => new AuditLogDto(
a.Id,
a.ActorId,
a.Action,
a.TargetType,
a.TargetId,
a.Metadata,
a.Source,
a.CreatedAt
))
.ToPagedListAsync(page, pageSize, cancellationToken); .ToPagedListAsync(page, pageSize, cancellationToken);
return Result.Success(result); return Result.Success(result);
@@ -6,6 +6,17 @@ namespace PnvPanel.Application.Admin.Configs;
/// <summary>Строка глобального списка конфигов для админа — в отличие от VpnConfigDto (self-service) /// <summary>Строка глобального списка конфигов для админа — в отличие от VpnConfigDto (self-service)
/// содержит владельца и ноду, т.к. список не скоупится одним пользователем.</summary> /// содержит владельца и ноду, т.к. список не скоупится одним пользователем.</summary>
public sealed record AdminVpnConfigDto( public sealed record AdminVpnConfigDto(
Guid Id, Guid UserId, string UserName, string? Label, string ClientEmail, VpnProtocol Protocol, Guid Id,
string Location, string NodeName, long UsedUpBytes, long UsedDownBytes, DateTimeOffset? ExpiresAt, Guid UserId,
ConfigStatus Status, DateTimeOffset CreatedAt); string UserName,
string? Label,
string ClientEmail,
VpnProtocol Protocol,
string Location,
string NodeName,
long UsedUpBytes,
long UsedDownBytes,
DateTimeOffset? ExpiresAt,
ConfigStatus Status,
DateTimeOffset CreatedAt
);
@@ -7,5 +7,9 @@ namespace PnvPanel.Application.Admin.Configs;
/// <summary><paramref name="Search"/> матчится по ClientEmail/Label — это то, по чему админ сверяет /// <summary><paramref name="Search"/> матчится по ClientEmail/Label — это то, по чему админ сверяет
/// конфиг с записью в 3x-ui, а не по владельцу (для поиска по пользователю есть /admin/users).</summary> /// конфиг с записью в 3x-ui, а не по владельцу (для поиска по пользователю есть /admin/users).</summary>
public sealed record ListAllConfigsQuery(int Page, int PageSize, string? Search, ConfigStatus? Status) public sealed record ListAllConfigsQuery(
: IQuery<Result<PagedList<AdminVpnConfigDto>>>; int Page,
int PageSize,
string? Search,
ConfigStatus? Status
) : IQuery<Result<PagedList<AdminVpnConfigDto>>>;
@@ -5,10 +5,15 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Configs; namespace PnvPanel.Application.Admin.Configs;
public sealed class ListAllConfigsQueryHandler(IAppDbContext dbContext, IIdentityService identityService) public sealed class ListAllConfigsQueryHandler(
: IQueryHandler<ListAllConfigsQuery, Result<PagedList<AdminVpnConfigDto>>> IAppDbContext dbContext,
IIdentityService identityService
) : IQueryHandler<ListAllConfigsQuery, Result<PagedList<AdminVpnConfigDto>>>
{ {
public async Task<Result<PagedList<AdminVpnConfigDto>>> Handle(ListAllConfigsQuery query, CancellationToken cancellationToken) public async Task<Result<PagedList<AdminVpnConfigDto>>> Handle(
ListAllConfigsQuery query,
CancellationToken cancellationToken
)
{ {
var page = query.Page <= 0 ? 1 : query.Page; var page = query.Page <= 0 ? 1 : query.Page;
var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize; var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize;
@@ -21,7 +26,9 @@ public sealed class ListAllConfigsQueryHandler(IAppDbContext dbContext, IIdentit
if (!string.IsNullOrWhiteSpace(query.Search)) if (!string.IsNullOrWhiteSpace(query.Search))
{ {
var search = query.Search.Trim(); var search = query.Search.Trim();
configsQuery = configsQuery.Where(c => c.ClientEmail.Contains(search) || (c.Label != null && c.Label.Contains(search))); configsQuery = configsQuery.Where(c =>
c.ClientEmail.Contains(search) || (c.Label != null && c.Label.Contains(search))
);
} }
var pageResult = await configsQuery var pageResult = await configsQuery
@@ -29,30 +36,56 @@ public sealed class ListAllConfigsQueryHandler(IAppDbContext dbContext, IIdentit
.ToPagedListAsync(page, pageSize, cancellationToken); .ToPagedListAsync(page, pageSize, cancellationToken);
var inboundIds = pageResult.Items.Select(c => c.InboundId).Distinct().ToList(); var inboundIds = pageResult.Items.Select(c => c.InboundId).Distinct().ToList();
var inbounds = (await dbContext.Inbounds.AsNoTracking() var inbounds = (
await dbContext
.Inbounds.AsNoTracking()
.Where(i => inboundIds.Contains(i.Id)) .Where(i => inboundIds.Contains(i.Id))
.ToListAsync(cancellationToken)) .ToListAsync(cancellationToken)
.ToDictionary(i => i.Id); ).ToDictionary(i => i.Id);
var nodeIds = inbounds.Values.Select(i => i.NodeId).Distinct().ToList(); var nodeIds = inbounds.Values.Select(i => i.NodeId).Distinct().ToList();
var nodes = (await dbContext.Nodes.AsNoTracking() var nodes = (
await dbContext
.Nodes.AsNoTracking()
.Where(n => nodeIds.Contains(n.Id)) .Where(n => nodeIds.Contains(n.Id))
.ToListAsync(cancellationToken)) .ToListAsync(cancellationToken)
.ToDictionary(n => n.Id); ).ToDictionary(n => n.Id);
var userNames = await identityService.GetUserNamesAsync( var userNames = await identityService.GetUserNamesAsync(
pageResult.Items.Select(c => c.UserId).Distinct().ToList(), cancellationToken); pageResult.Items.Select(c => c.UserId).Distinct().ToList(),
cancellationToken
);
var items = pageResult.Items.Select(c => var items = pageResult
{ .Items.Select(c =>
var inbound = inbounds.GetValueOrDefault(c.InboundId); {
var node = inbound is null ? null : nodes.GetValueOrDefault(inbound.NodeId); var inbound = inbounds.GetValueOrDefault(c.InboundId);
return new AdminVpnConfigDto( var node = inbound is null ? null : nodes.GetValueOrDefault(inbound.NodeId);
c.Id, c.UserId, userNames.GetValueOrDefault(c.UserId, "?"), c.Label, c.ClientEmail, c.Protocol, return new AdminVpnConfigDto(
inbound?.DisplayName ?? inbound?.Remark ?? "?", node?.Name ?? "?", c.Id,
c.UsedUpBytes, c.UsedDownBytes, c.ExpiresAt, c.Status, c.CreatedAt); c.UserId,
}).ToList(); userNames.GetValueOrDefault(c.UserId, "?"),
c.Label,
c.ClientEmail,
c.Protocol,
inbound?.DisplayName ?? inbound?.Remark ?? "?",
node?.Name ?? "?",
c.UsedUpBytes,
c.UsedDownBytes,
c.ExpiresAt,
c.Status,
c.CreatedAt
);
})
.ToList();
return Result.Success(new PagedList<AdminVpnConfigDto>(items, pageResult.Total, pageResult.Page, pageResult.PageSize)); return Result.Success(
new PagedList<AdminVpnConfigDto>(
items,
pageResult.Total,
pageResult.Page,
pageResult.PageSize
)
);
} }
} }
@@ -3,11 +3,31 @@ using PnvPanel.Domain.Inbounds;
namespace PnvPanel.Application.Admin.Inbounds; namespace PnvPanel.Application.Admin.Inbounds;
public sealed record InboundDto( public sealed record InboundDto(
Guid Id, Guid NodeId, string RemoteInboundId, VpnProtocol Protocol, string Remark, int Port, Guid Id,
bool IsPublished, string? DisplayName, int? MaxClients, IReadOnlyList<Guid> AllowedRoleIds, Guid NodeId,
DateTimeOffset? LastSyncAt) 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( public static InboundDto FromDomain(Inbound inbound) =>
inbound.Id, inbound.NodeId, inbound.RemoteInboundId, inbound.Protocol, inbound.Remark, inbound.Port, new(
inbound.IsPublished, inbound.DisplayName, inbound.MaxClients, inbound.AllowedRoleIds, inbound.LastSyncAt); inbound.Id,
inbound.NodeId,
inbound.RemoteInboundId,
inbound.Protocol,
inbound.Remark,
inbound.Port,
inbound.IsPublished,
inbound.DisplayName,
inbound.MaxClients,
inbound.AllowedRoleIds,
inbound.LastSyncAt
);
} }
@@ -4,5 +4,8 @@ namespace PnvPanel.Application.Admin.Inbounds;
public static class InboundErrors public static class InboundErrors
{ {
public static readonly Error NotFound = Error.NotFound("Inbounds.NotFound", "Inbound не найден."); public static readonly Error NotFound = Error.NotFound(
"Inbounds.NotFound",
"Inbound не найден."
);
} }
@@ -5,15 +5,21 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Inbounds; namespace PnvPanel.Application.Admin.Inbounds;
public sealed class ListInboundsQueryHandler(IAppDbContext dbContext) : IQueryHandler<ListInboundsQuery, Result<IReadOnlyList<InboundDto>>> public sealed class ListInboundsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListInboundsQuery, Result<IReadOnlyList<InboundDto>>>
{ {
public async Task<Result<IReadOnlyList<InboundDto>>> Handle(ListInboundsQuery query, CancellationToken cancellationToken) public async Task<Result<IReadOnlyList<InboundDto>>> Handle(
ListInboundsQuery query,
CancellationToken cancellationToken
)
{ {
var inboundsQuery = dbContext.Inbounds.AsNoTracking(); var inboundsQuery = dbContext.Inbounds.AsNoTracking();
if (query.NodeId is { } nodeId) if (query.NodeId is { } nodeId)
inboundsQuery = inboundsQuery.Where(i => i.NodeId == nodeId); inboundsQuery = inboundsQuery.Where(i => i.NodeId == nodeId);
var inbounds = await inboundsQuery.OrderBy(i => i.Remark).ToListAsync(cancellationToken); var inbounds = await inboundsQuery.OrderBy(i => i.Remark).ToListAsync(cancellationToken);
return Result.Success<IReadOnlyList<InboundDto>>(inbounds.Select(InboundDto.FromDomain).ToList()); return Result.Success<IReadOnlyList<InboundDto>>(
inbounds.Select(InboundDto.FromDomain).ToList()
);
} }
} }
@@ -4,5 +4,9 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Inbounds; namespace PnvPanel.Application.Admin.Inbounds;
public sealed record PublishInboundCommand( public sealed record PublishInboundCommand(
Guid InboundId, bool IsPublished, string? DisplayName, IReadOnlyList<Guid> AllowedRoleIds, int? MaxClients) Guid InboundId,
: ICommand<Result<InboundDto>>; bool IsPublished,
string? DisplayName,
IReadOnlyList<Guid> AllowedRoleIds,
int? MaxClients
) : ICommand<Result<InboundDto>>;
@@ -9,9 +9,15 @@ namespace PnvPanel.Application.Admin.Inbounds;
public sealed class PublishInboundCommandHandler(IAppDbContext dbContext, ICurrentUser currentUser) public sealed class PublishInboundCommandHandler(IAppDbContext dbContext, ICurrentUser currentUser)
: ICommandHandler<PublishInboundCommand, Result<InboundDto>> : ICommandHandler<PublishInboundCommand, Result<InboundDto>>
{ {
public async Task<Result<InboundDto>> Handle(PublishInboundCommand command, CancellationToken cancellationToken) public async Task<Result<InboundDto>> Handle(
PublishInboundCommand command,
CancellationToken cancellationToken
)
{ {
var inbound = await dbContext.Inbounds.FirstOrDefaultAsync(i => i.Id == command.InboundId, cancellationToken); var inbound = await dbContext.Inbounds.FirstOrDefaultAsync(
i => i.Id == command.InboundId,
cancellationToken
);
if (inbound is null) if (inbound is null)
return Result.Failure<InboundDto>(InboundErrors.NotFound); return Result.Failure<InboundDto>(InboundErrors.NotFound);
@@ -20,9 +26,16 @@ public sealed class PublishInboundCommandHandler(IAppDbContext dbContext, ICurre
else else
inbound.Unpublish(); inbound.Unpublish();
dbContext.AuditLogs.Add(AuditLog.Create( dbContext.AuditLogs.Add(
currentUser.UserId, command.IsPublished ? "InboundPublished" : "InboundUnpublished", AuditLog.Create(
"Inbound", inbound.Id.ToString(), metadata: null, AuditSource.Web)); currentUser.UserId,
command.IsPublished ? "InboundPublished" : "InboundUnpublished",
"Inbound",
inbound.Id.ToString(),
metadata: null,
AuditSource.Web
)
);
return Result.Success(InboundDto.FromDomain(inbound)); return Result.Success(InboundDto.FromDomain(inbound));
} }
@@ -6,15 +6,26 @@ using PnvPanel.Domain.News;
namespace PnvPanel.Application.Admin.News; namespace PnvPanel.Application.Admin.News;
public sealed class CreatePostCommandHandler(IAppDbContext dbContext, IRealtimeNotifier notifier, ITelegramNotifier telegramNotifier) public sealed class CreatePostCommandHandler(
: ICommandHandler<CreatePostCommand, Result<NewsPostDto>> IAppDbContext dbContext,
IRealtimeNotifier notifier,
ITelegramNotifier telegramNotifier
) : ICommandHandler<CreatePostCommand, Result<NewsPostDto>>
{ {
public async Task<Result<NewsPostDto>> Handle(CreatePostCommand command, CancellationToken cancellationToken) public async Task<Result<NewsPostDto>> Handle(
CreatePostCommand command,
CancellationToken cancellationToken
)
{ {
var post = NewsPost.Create(command.Title, command.Body); var post = NewsPost.Create(command.Title, command.Body);
dbContext.NewsPosts.Add(post); dbContext.NewsPosts.Add(post);
await notifier.NotifyNewsPublishedAsync(post.Id, post.Title, post.CreatedAt, cancellationToken); await notifier.NotifyNewsPublishedAsync(
post.Id,
post.Title,
post.CreatedAt,
cancellationToken
);
await telegramNotifier.NotifyUsersNewsPublishedAsync(post.Title, cancellationToken); await telegramNotifier.NotifyUsersNewsPublishedAsync(post.Title, cancellationToken);
return Result.Success(NewsPostDto.FromDomain(post)); return Result.Success(NewsPostDto.FromDomain(post));
@@ -5,11 +5,15 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.News; namespace PnvPanel.Application.Admin.News;
public sealed class DeletePostCommandHandler(IAppDbContext dbContext) : ICommandHandler<DeletePostCommand, Result> public sealed class DeletePostCommandHandler(IAppDbContext dbContext)
: ICommandHandler<DeletePostCommand, Result>
{ {
public async Task<Result> Handle(DeletePostCommand command, CancellationToken cancellationToken) public async Task<Result> Handle(DeletePostCommand command, CancellationToken cancellationToken)
{ {
var post = await dbContext.NewsPosts.FirstOrDefaultAsync(p => p.Id == command.PostId, cancellationToken); var post = await dbContext.NewsPosts.FirstOrDefaultAsync(
p => p.Id == command.PostId,
cancellationToken
);
if (post is null) if (post is null)
return Result.Failure(NewsErrors.NotFound); return Result.Failure(NewsErrors.NotFound);
@@ -4,4 +4,5 @@ using PnvPanel.Application.News;
namespace PnvPanel.Application.Admin.News; namespace PnvPanel.Application.Admin.News;
public sealed record ListAdminNewsQuery(int Page, int PageSize) : IQuery<Result<PagedList<NewsPostDto>>>; public sealed record ListAdminNewsQuery(int Page, int PageSize)
: IQuery<Result<PagedList<NewsPostDto>>>;
@@ -6,14 +6,19 @@ using PnvPanel.Application.News;
namespace PnvPanel.Application.Admin.News; namespace PnvPanel.Application.Admin.News;
public sealed class ListAdminNewsQueryHandler(IAppDbContext dbContext) : IQueryHandler<ListAdminNewsQuery, Result<PagedList<NewsPostDto>>> public sealed class ListAdminNewsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListAdminNewsQuery, Result<PagedList<NewsPostDto>>>
{ {
public async Task<Result<PagedList<NewsPostDto>>> Handle(ListAdminNewsQuery query, CancellationToken cancellationToken) public async Task<Result<PagedList<NewsPostDto>>> Handle(
ListAdminNewsQuery query,
CancellationToken cancellationToken
)
{ {
var page = query.Page <= 0 ? 1 : query.Page; var page = query.Page <= 0 ? 1 : query.Page;
var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize; var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize;
var result = await dbContext.NewsPosts.AsNoTracking() var result = await dbContext
.NewsPosts.AsNoTracking()
.OrderByDescending(p => p.CreatedAt) .OrderByDescending(p => p.CreatedAt)
.Select(p => new NewsPostDto(p.Id, p.Title, p.Body, p.CreatedAt, p.UpdatedAt)) .Select(p => new NewsPostDto(p.Id, p.Title, p.Body, p.CreatedAt, p.UpdatedAt))
.ToPagedListAsync(page, pageSize, cancellationToken); .ToPagedListAsync(page, pageSize, cancellationToken);
@@ -4,4 +4,5 @@ using PnvPanel.Application.News;
namespace PnvPanel.Application.Admin.News; namespace PnvPanel.Application.Admin.News;
public sealed record UpdatePostCommand(Guid PostId, string Title, string Body) : ICommand<Result<NewsPostDto>>; public sealed record UpdatePostCommand(Guid PostId, string Title, string Body)
: ICommand<Result<NewsPostDto>>;
@@ -6,11 +6,18 @@ using PnvPanel.Application.News;
namespace PnvPanel.Application.Admin.News; namespace PnvPanel.Application.Admin.News;
public sealed class UpdatePostCommandHandler(IAppDbContext dbContext) : ICommandHandler<UpdatePostCommand, Result<NewsPostDto>> public sealed class UpdatePostCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdatePostCommand, Result<NewsPostDto>>
{ {
public async Task<Result<NewsPostDto>> Handle(UpdatePostCommand command, CancellationToken cancellationToken) public async Task<Result<NewsPostDto>> Handle(
UpdatePostCommand command,
CancellationToken cancellationToken
)
{ {
var post = await dbContext.NewsPosts.FirstOrDefaultAsync(p => p.Id == command.PostId, cancellationToken); var post = await dbContext.NewsPosts.FirstOrDefaultAsync(
p => p.Id == command.PostId,
cancellationToken
);
if (post is null) if (post is null)
return Result.Failure<NewsPostDto>(NewsErrors.NotFound); return Result.Failure<NewsPostDto>(NewsErrors.NotFound);
@@ -6,22 +6,38 @@ using PnvPanel.Domain.Audit;
namespace PnvPanel.Application.Admin.Nodes; namespace PnvPanel.Application.Admin.Nodes;
public sealed class DeleteNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway, ICurrentUser currentUser) public sealed class DeleteNodeCommandHandler(
: ICommandHandler<DeleteNodeCommand, Result> IAppDbContext dbContext,
IXuiPanelGateway gateway,
ICurrentUser currentUser
) : ICommandHandler<DeleteNodeCommand, Result>
{ {
public async Task<Result> Handle(DeleteNodeCommand command, CancellationToken cancellationToken) public async Task<Result> Handle(DeleteNodeCommand command, CancellationToken cancellationToken)
{ {
var node = await dbContext.Nodes.FirstOrDefaultAsync(n => n.Id == command.NodeId, cancellationToken); var node = await dbContext.Nodes.FirstOrDefaultAsync(
n => n.Id == command.NodeId,
cancellationToken
);
if (node is null) if (node is null)
return Result.Failure(NodeErrors.NotFound); return Result.Failure(NodeErrors.NotFound);
var inbounds = await dbContext.Inbounds.Where(i => i.NodeId == node.Id).ToListAsync(cancellationToken); var inbounds = await dbContext
.Inbounds.Where(i => i.NodeId == node.Id)
.ToListAsync(cancellationToken);
dbContext.Inbounds.RemoveRange(inbounds); dbContext.Inbounds.RemoveRange(inbounds);
dbContext.Nodes.Remove(node); dbContext.Nodes.Remove(node);
gateway.InvalidateClient(node.Id); gateway.InvalidateClient(node.Id);
dbContext.AuditLogs.Add(AuditLog.Create( dbContext.AuditLogs.Add(
currentUser.UserId, "NodeDeleted", "Node", node.Id.ToString(), metadata: null, AuditSource.Web)); AuditLog.Create(
currentUser.UserId,
"NodeDeleted",
"Node",
node.Id.ToString(),
metadata: null,
AuditSource.Web
)
);
return Result.Success(); return Result.Success();
} }
@@ -5,11 +5,18 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Nodes; namespace PnvPanel.Application.Admin.Nodes;
public sealed class ListNodesQueryHandler(IAppDbContext dbContext) : IQueryHandler<ListNodesQuery, Result<IReadOnlyList<NodeDto>>> public sealed class ListNodesQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListNodesQuery, Result<IReadOnlyList<NodeDto>>>
{ {
public async Task<Result<IReadOnlyList<NodeDto>>> Handle(ListNodesQuery query, CancellationToken cancellationToken) public async Task<Result<IReadOnlyList<NodeDto>>> Handle(
ListNodesQuery query,
CancellationToken cancellationToken
)
{ {
var nodes = await dbContext.Nodes.AsNoTracking().OrderBy(n => n.Name).ToListAsync(cancellationToken); var nodes = await dbContext
.Nodes.AsNoTracking()
.OrderBy(n => n.Name)
.ToListAsync(cancellationToken);
return Result.Success<IReadOnlyList<NodeDto>>(nodes.Select(NodeDto.FromDomain).ToList()); return Result.Success<IReadOnlyList<NodeDto>>(nodes.Select(NodeDto.FromDomain).ToList());
} }
} }
@@ -4,10 +4,25 @@ namespace PnvPanel.Application.Admin.Nodes;
/// <summary>Админский DTO ноды. Пароль никогда не попадает в ответ API.</summary> /// <summary>Админский DTO ноды. Пароль никогда не попадает в ответ API.</summary>
public sealed record NodeDto( public sealed record NodeDto(
Guid Id, string Name, string BaseAddress, string Username, string? Location, Guid Id,
NodeStatus Status, bool IsEnabled, DateTimeOffset? LastSyncAt) string Name,
string BaseAddress,
string Username,
string? Location,
NodeStatus Status,
bool IsEnabled,
DateTimeOffset? LastSyncAt
)
{ {
public static NodeDto FromDomain(Node node) => new( public static NodeDto FromDomain(Node node) =>
node.Id, node.Name, node.BaseAddress.ToString(), node.Credentials.Username, node.Location, new(
node.Status, node.IsEnabled, node.LastSyncAt); node.Id,
node.Name,
node.BaseAddress.ToString(),
node.Credentials.Username,
node.Location,
node.Status,
node.IsEnabled,
node.LastSyncAt
);
} }
@@ -5,5 +5,8 @@ namespace PnvPanel.Application.Admin.Nodes;
public static class NodeErrors public static class NodeErrors
{ {
public static readonly Error NotFound = Error.NotFound("Nodes.NotFound", "Нода не найдена."); public static readonly Error NotFound = Error.NotFound("Nodes.NotFound", "Нода не найдена.");
public static readonly Error InvalidBaseAddress = Error.Validation("Nodes.InvalidBaseAddress", "Некорректный адрес панели."); public static readonly Error InvalidBaseAddress = Error.Validation(
"Nodes.InvalidBaseAddress",
"Некорректный адрес панели."
);
} }
@@ -9,15 +9,23 @@ namespace PnvPanel.Application.Admin.Nodes;
public sealed class ProbeNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway) public sealed class ProbeNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway)
: ICommandHandler<ProbeNodeCommand, Result<NodeProbeResultDto>> : ICommandHandler<ProbeNodeCommand, Result<NodeProbeResultDto>>
{ {
public async Task<Result<NodeProbeResultDto>> Handle(ProbeNodeCommand command, CancellationToken cancellationToken) public async Task<Result<NodeProbeResultDto>> Handle(
ProbeNodeCommand command,
CancellationToken cancellationToken
)
{ {
var node = await dbContext.Nodes.FirstOrDefaultAsync(n => n.Id == command.NodeId, cancellationToken); var node = await dbContext.Nodes.FirstOrDefaultAsync(
n => n.Id == command.NodeId,
cancellationToken
);
if (node is null) if (node is null)
return Result.Failure<NodeProbeResultDto>(NodeErrors.NotFound); return Result.Failure<NodeProbeResultDto>(NodeErrors.NotFound);
var probe = await gateway.ProbeAsync(node, cancellationToken); var probe = await gateway.ProbeAsync(node, cancellationToken);
node.UpdateStatus(probe.IsReachable ? NodeStatus.Online : NodeStatus.Offline); node.UpdateStatus(probe.IsReachable ? NodeStatus.Online : NodeStatus.Offline);
return Result.Success(new NodeProbeResultDto(probe.IsReachable, probe.ErrorMessage, node.Status)); return Result.Success(
new NodeProbeResultDto(probe.IsReachable, probe.ErrorMessage, node.Status)
);
} }
} }
@@ -3,5 +3,10 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Nodes; namespace PnvPanel.Application.Admin.Nodes;
public sealed record RegisterNodeCommand(string Name, string BaseAddress, string Username, string Password, string? Location) public sealed record RegisterNodeCommand(
: ICommand<Result<NodeDto>>; string Name,
string BaseAddress,
string Username,
string Password,
string? Location
) : ICommand<Result<NodeDto>>;
@@ -7,10 +7,16 @@ using PnvPanel.Domain.Nodes;
namespace PnvPanel.Application.Admin.Nodes; namespace PnvPanel.Application.Admin.Nodes;
public sealed class RegisterNodeCommandHandler( public sealed class RegisterNodeCommandHandler(
IAppDbContext dbContext, IXuiPanelGateway gateway, ISecretProtector secretProtector, ICurrentUser currentUser) IAppDbContext dbContext,
: ICommandHandler<RegisterNodeCommand, Result<NodeDto>> IXuiPanelGateway gateway,
ISecretProtector secretProtector,
ICurrentUser currentUser
) : ICommandHandler<RegisterNodeCommand, Result<NodeDto>>
{ {
public Task<Result<NodeDto>> Handle(RegisterNodeCommand command, CancellationToken cancellationToken) public Task<Result<NodeDto>> Handle(
RegisterNodeCommand command,
CancellationToken cancellationToken
)
{ {
if (!Uri.TryCreate(command.BaseAddress, UriKind.Absolute, out var baseAddress)) if (!Uri.TryCreate(command.BaseAddress, UriKind.Absolute, out var baseAddress))
return Task.FromResult(Result.Failure<NodeDto>(NodeErrors.InvalidBaseAddress)); return Task.FromResult(Result.Failure<NodeDto>(NodeErrors.InvalidBaseAddress));
@@ -19,12 +25,23 @@ public sealed class RegisterNodeCommandHandler(
if (!validation.IsSuccess) if (!validation.IsSuccess)
return Task.FromResult(Result.Failure<NodeDto>(validation.Error)); return Task.FromResult(Result.Failure<NodeDto>(validation.Error));
var credentials = new NodeCredentials(command.Username, secretProtector.Protect(command.Password)); var credentials = new NodeCredentials(
command.Username,
secretProtector.Protect(command.Password)
);
var node = Node.Register(command.Name, baseAddress, credentials, command.Location); var node = Node.Register(command.Name, baseAddress, credentials, command.Location);
dbContext.Nodes.Add(node); dbContext.Nodes.Add(node);
dbContext.AuditLogs.Add(AuditLog.Create( dbContext.AuditLogs.Add(
currentUser.UserId, "NodeRegistered", "Node", node.Id.ToString(), metadata: null, AuditSource.Web)); AuditLog.Create(
currentUser.UserId,
"NodeRegistered",
"Node",
node.Id.ToString(),
metadata: null,
AuditSource.Web
)
);
return Task.FromResult(Result.Success(NodeDto.FromDomain(node))); return Task.FromResult(Result.Success(NodeDto.FromDomain(node)));
} }
@@ -10,9 +10,15 @@ namespace PnvPanel.Application.Admin.Nodes;
public sealed class SyncNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway) public sealed class SyncNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway)
: ICommandHandler<SyncNodeCommand, Result<SyncNodeResultDto>> : ICommandHandler<SyncNodeCommand, Result<SyncNodeResultDto>>
{ {
public async Task<Result<SyncNodeResultDto>> Handle(SyncNodeCommand command, CancellationToken cancellationToken) public async Task<Result<SyncNodeResultDto>> Handle(
SyncNodeCommand command,
CancellationToken cancellationToken
)
{ {
var node = await dbContext.Nodes.FirstOrDefaultAsync(n => n.Id == command.NodeId, cancellationToken); var node = await dbContext.Nodes.FirstOrDefaultAsync(
n => n.Id == command.NodeId,
cancellationToken
);
if (node is null) if (node is null)
return Result.Failure<SyncNodeResultDto>(NodeErrors.NotFound); return Result.Failure<SyncNodeResultDto>(NodeErrors.NotFound);
@@ -23,7 +29,9 @@ public sealed class SyncNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGat
return Result.Failure<SyncNodeResultDto>(remoteResult.Error); return Result.Failure<SyncNodeResultDto>(remoteResult.Error);
} }
var existing = await dbContext.Inbounds.Where(i => i.NodeId == node.Id).ToListAsync(cancellationToken); var existing = await dbContext
.Inbounds.Where(i => i.NodeId == node.Id)
.ToListAsync(cancellationToken);
var existingByRemoteId = existing.ToDictionary(i => i.RemoteInboundId); var existingByRemoteId = existing.ToDictionary(i => i.RemoteInboundId);
foreach (var remote in remoteResult.Value) foreach (var remote in remoteResult.Value)
@@ -31,13 +39,25 @@ public sealed class SyncNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGat
if (existingByRemoteId.TryGetValue(remote.RemoteInboundId, out var inbound)) if (existingByRemoteId.TryGetValue(remote.RemoteInboundId, out var inbound))
inbound.UpdateFromRemote(remote.Protocol, remote.Remark, remote.Port); inbound.UpdateFromRemote(remote.Protocol, remote.Remark, remote.Port);
else else
dbContext.Inbounds.Add(Inbound.FromRemote(node.Id, remote.RemoteInboundId, remote.Protocol, remote.Remark, remote.Port)); dbContext.Inbounds.Add(
Inbound.FromRemote(
node.Id,
remote.RemoteInboundId,
remote.Protocol,
remote.Remark,
remote.Port
)
);
} }
// Inbound, пропавший на панели, снимаем с публикации (не удаляем — реконсиляция дрейфа, // Inbound, пропавший на панели, снимаем с публикации (не удаляем — реконсиляция дрейфа,
// см. architecture.md); новые конфиги на нём создать будет нельзя, старые не трогаем. // см. architecture.md); новые конфиги на нём создать будет нельзя, старые не трогаем.
var remoteIds = remoteResult.Value.Select(r => r.RemoteInboundId).ToHashSet(); var remoteIds = remoteResult.Value.Select(r => r.RemoteInboundId).ToHashSet();
foreach (var stale in existing.Where(i => i.IsPublished && !remoteIds.Contains(i.RemoteInboundId))) foreach (
var stale in existing.Where(i =>
i.IsPublished && !remoteIds.Contains(i.RemoteInboundId)
)
)
stale.Unpublish(); stale.Unpublish();
node.UpdateStatus(NodeStatus.Online); node.UpdateStatus(NodeStatus.Online);
@@ -4,5 +4,10 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Nodes; namespace PnvPanel.Application.Admin.Nodes;
public sealed record UpdateNodeCommand( public sealed record UpdateNodeCommand(
Guid NodeId, string Name, string? Location, bool IsEnabled, string? Username, string? Password) Guid NodeId,
: ICommand<Result<NodeDto>>; string Name,
string? Location,
bool IsEnabled,
string? Username,
string? Password
) : ICommand<Result<NodeDto>>;
@@ -8,12 +8,21 @@ using PnvPanel.Domain.Nodes;
namespace PnvPanel.Application.Admin.Nodes; namespace PnvPanel.Application.Admin.Nodes;
public sealed class UpdateNodeCommandHandler( public sealed class UpdateNodeCommandHandler(
IAppDbContext dbContext, IXuiPanelGateway gateway, ISecretProtector secretProtector, ICurrentUser currentUser) IAppDbContext dbContext,
: ICommandHandler<UpdateNodeCommand, Result<NodeDto>> IXuiPanelGateway gateway,
ISecretProtector secretProtector,
ICurrentUser currentUser
) : ICommandHandler<UpdateNodeCommand, Result<NodeDto>>
{ {
public async Task<Result<NodeDto>> Handle(UpdateNodeCommand command, CancellationToken cancellationToken) public async Task<Result<NodeDto>> Handle(
UpdateNodeCommand command,
CancellationToken cancellationToken
)
{ {
var node = await dbContext.Nodes.FirstOrDefaultAsync(n => n.Id == command.NodeId, cancellationToken); var node = await dbContext.Nodes.FirstOrDefaultAsync(
n => n.Id == command.NodeId,
cancellationToken
);
if (node is null) if (node is null)
return Result.Failure<NodeDto>(NodeErrors.NotFound); return Result.Failure<NodeDto>(NodeErrors.NotFound);
@@ -24,14 +33,27 @@ public sealed class UpdateNodeCommandHandler(
else else
node.Disable(); node.Disable();
if (!string.IsNullOrWhiteSpace(command.Username) && !string.IsNullOrWhiteSpace(command.Password)) if (
!string.IsNullOrWhiteSpace(command.Username)
&& !string.IsNullOrWhiteSpace(command.Password)
)
{ {
node.UpdateCredentials(new NodeCredentials(command.Username, secretProtector.Protect(command.Password))); node.UpdateCredentials(
new NodeCredentials(command.Username, secretProtector.Protect(command.Password))
);
gateway.InvalidateClient(node.Id); gateway.InvalidateClient(node.Id);
} }
dbContext.AuditLogs.Add(AuditLog.Create( dbContext.AuditLogs.Add(
currentUser.UserId, "NodeUpdated", "Node", node.Id.ToString(), metadata: null, AuditSource.Web)); AuditLog.Create(
currentUser.UserId,
"NodeUpdated",
"Node",
node.Id.ToString(),
metadata: null,
AuditSource.Web
)
);
return Result.Success(NodeDto.FromDomain(node)); return Result.Success(NodeDto.FromDomain(node));
} }
@@ -4,4 +4,5 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Roles; namespace PnvPanel.Application.Admin.Roles;
public sealed record CreateRoleCommand(string Name, int MaxConfigs, int MaxIpLimit) : ICommand<Result<RoleDto>>; public sealed record CreateRoleCommand(string Name, int MaxConfigs, int MaxIpLimit)
: ICommand<Result<RoleDto>>;
@@ -4,8 +4,17 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Roles; namespace PnvPanel.Application.Admin.Roles;
public sealed class CreateRoleCommandHandler(IRoleService roleService) : ICommandHandler<CreateRoleCommand, Result<RoleDto>> public sealed class CreateRoleCommandHandler(IRoleService roleService)
: ICommandHandler<CreateRoleCommand, Result<RoleDto>>
{ {
public Task<Result<RoleDto>> Handle(CreateRoleCommand command, CancellationToken cancellationToken) public Task<Result<RoleDto>> Handle(
=> roleService.CreateRoleAsync(command.Name, command.MaxConfigs, command.MaxIpLimit, cancellationToken); CreateRoleCommand command,
CancellationToken cancellationToken
) =>
roleService.CreateRoleAsync(
command.Name,
command.MaxConfigs,
command.MaxIpLimit,
cancellationToken
);
} }
@@ -6,10 +6,7 @@ public sealed class CreateRoleCommandValidator : AbstractValidator<CreateRoleCom
{ {
public CreateRoleCommandValidator() public CreateRoleCommandValidator()
{ {
RuleFor(x => x.Name) RuleFor(x => x.Name).NotEmpty().Length(2, 32).Matches("^[a-zA-Z0-9_-]+$");
.NotEmpty()
.Length(2, 32)
.Matches("^[a-zA-Z0-9_-]+$");
RuleFor(x => x.MaxConfigs).GreaterThanOrEqualTo(-1); RuleFor(x => x.MaxConfigs).GreaterThanOrEqualTo(-1);
RuleFor(x => x.MaxIpLimit).GreaterThanOrEqualTo(-1); RuleFor(x => x.MaxIpLimit).GreaterThanOrEqualTo(-1);
@@ -4,8 +4,9 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Roles; namespace PnvPanel.Application.Admin.Roles;
public sealed class DeleteRoleCommandHandler(IRoleService roleService) : ICommandHandler<DeleteRoleCommand, Result> public sealed class DeleteRoleCommandHandler(IRoleService roleService)
: ICommandHandler<DeleteRoleCommand, Result>
{ {
public Task<Result> Handle(DeleteRoleCommand command, CancellationToken cancellationToken) public Task<Result> Handle(DeleteRoleCommand command, CancellationToken cancellationToken) =>
=> roleService.DeleteRoleAsync(command.RoleId, cancellationToken); roleService.DeleteRoleAsync(command.RoleId, cancellationToken);
} }
@@ -7,6 +7,8 @@ namespace PnvPanel.Application.Admin.Roles;
public sealed class ListRolesQueryHandler(IRoleService roleService) public sealed class ListRolesQueryHandler(IRoleService roleService)
: IQueryHandler<ListRolesQuery, Result<IReadOnlyList<RoleDto>>> : IQueryHandler<ListRolesQuery, Result<IReadOnlyList<RoleDto>>>
{ {
public async Task<Result<IReadOnlyList<RoleDto>>> Handle(ListRolesQuery query, CancellationToken cancellationToken) public async Task<Result<IReadOnlyList<RoleDto>>> Handle(
=> Result.Success(await roleService.ListRolesAsync(cancellationToken)); ListRolesQuery query,
CancellationToken cancellationToken
) => Result.Success(await roleService.ListRolesAsync(cancellationToken));
} }
@@ -5,7 +5,16 @@ namespace PnvPanel.Application.Admin.Roles;
public static class RoleErrors public static class RoleErrors
{ {
public static readonly Error NotFound = Error.NotFound("Roles.NotFound", "Роль не найдена."); public static readonly Error NotFound = Error.NotFound("Roles.NotFound", "Роль не найдена.");
public static readonly Error DuplicateName = Error.Conflict("Roles.DuplicateName", "Роль с таким именем уже существует."); public static readonly Error DuplicateName = Error.Conflict(
public static readonly Error CannotModifySystemRole = Error.Forbidden("Roles.CannotModifySystemRole", "Системную роль нельзя удалить."); "Roles.DuplicateName",
public static readonly Error RoleInUse = Error.Conflict("Roles.RoleInUse", "Роль назначена пользователям — сначала переназначьте их."); "Роль с таким именем уже существует."
);
public static readonly Error CannotModifySystemRole = Error.Forbidden(
"Roles.CannotModifySystemRole",
"Системную роль нельзя удалить."
);
public static readonly Error RoleInUse = Error.Conflict(
"Roles.RoleInUse",
"Роль назначена пользователям — сначала переназначьте их."
);
} }
@@ -4,4 +4,5 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Roles; namespace PnvPanel.Application.Admin.Roles;
public sealed record UpdateRoleCommand(Guid RoleId, int MaxConfigs, int MaxIpLimit) : ICommand<Result<RoleDto>>; public sealed record UpdateRoleCommand(Guid RoleId, int MaxConfigs, int MaxIpLimit)
: ICommand<Result<RoleDto>>;
@@ -4,8 +4,17 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Roles; namespace PnvPanel.Application.Admin.Roles;
public sealed class UpdateRoleCommandHandler(IRoleService roleService) : ICommandHandler<UpdateRoleCommand, Result<RoleDto>> public sealed class UpdateRoleCommandHandler(IRoleService roleService)
: ICommandHandler<UpdateRoleCommand, Result<RoleDto>>
{ {
public Task<Result<RoleDto>> Handle(UpdateRoleCommand command, CancellationToken cancellationToken) public Task<Result<RoleDto>> Handle(
=> roleService.UpdateRoleAsync(command.RoleId, command.MaxConfigs, command.MaxIpLimit, cancellationToken); UpdateRoleCommand command,
CancellationToken cancellationToken
) =>
roleService.UpdateRoleAsync(
command.RoleId,
command.MaxConfigs,
command.MaxIpLimit,
cancellationToken
);
} }
@@ -6,6 +6,13 @@ namespace PnvPanel.Application.Admin.Stats;
public sealed record GetStatsQuery : IQuery<Result<StatsDto>>; public sealed record GetStatsQuery : IQuery<Result<StatsDto>>;
public sealed record StatsDto( public sealed record StatsDto(
int TotalUsers, int ActivatedUsers, int PendingActivationRequests, int TotalUsers,
int TotalNodes, int OnlineNodes, int TotalConfigs, int ActiveConfigs, int ActivatedUsers,
long TotalUsedUpBytes, long TotalUsedDownBytes); int PendingActivationRequests,
int TotalNodes,
int OnlineNodes,
int TotalConfigs,
int ActiveConfigs,
long TotalUsedUpBytes,
long TotalUsedDownBytes
);
@@ -11,27 +11,47 @@ namespace PnvPanel.Application.Admin.Stats;
public sealed class GetStatsQueryHandler(IAppDbContext dbContext, IIdentityService identityService) public sealed class GetStatsQueryHandler(IAppDbContext dbContext, IIdentityService identityService)
: IQueryHandler<GetStatsQuery, Result<StatsDto>> : IQueryHandler<GetStatsQuery, Result<StatsDto>>
{ {
public async Task<Result<StatsDto>> Handle(GetStatsQuery query, CancellationToken cancellationToken) public async Task<Result<StatsDto>> Handle(
GetStatsQuery query,
CancellationToken cancellationToken
)
{ {
var userStats = await identityService.GetUserStatsAsync(cancellationToken); var userStats = await identityService.GetUserStatsAsync(cancellationToken);
var pendingActivations = await dbContext.ActivationRequests var pendingActivations = await dbContext.ActivationRequests.CountAsync(
.CountAsync(r => r.Status == ActivationStatus.Pending, cancellationToken); r => r.Status == ActivationStatus.Pending,
cancellationToken
);
var totalNodes = await dbContext.Nodes.CountAsync(cancellationToken); var totalNodes = await dbContext.Nodes.CountAsync(cancellationToken);
var onlineNodes = await dbContext.Nodes.CountAsync(n => n.Status == NodeStatus.Online, cancellationToken); var onlineNodes = await dbContext.Nodes.CountAsync(
n => n.Status == NodeStatus.Online,
cancellationToken
);
var totalConfigs = await dbContext.VpnConfigs.CountAsync(cancellationToken); var totalConfigs = await dbContext.VpnConfigs.CountAsync(cancellationToken);
var activeConfigs = await dbContext.VpnConfigs.CountAsync(c => c.Status == ConfigStatus.Active, cancellationToken); var activeConfigs = await dbContext.VpnConfigs.CountAsync(
c => c.Status == ConfigStatus.Active,
cancellationToken
);
var trafficTotals = await dbContext.VpnConfigs var trafficTotals = await dbContext
.GroupBy(_ => 1) .VpnConfigs.GroupBy(_ => 1)
.Select(g => new { Up = g.Sum(c => c.UsedUpBytes), Down = g.Sum(c => c.UsedDownBytes) }) .Select(g => new { Up = g.Sum(c => c.UsedUpBytes), Down = g.Sum(c => c.UsedDownBytes) })
.FirstOrDefaultAsync(cancellationToken); .FirstOrDefaultAsync(cancellationToken);
return Result.Success(new StatsDto( return Result.Success(
userStats.Total, userStats.Activated, pendingActivations, new StatsDto(
totalNodes, onlineNodes, totalConfigs, activeConfigs, userStats.Total,
trafficTotals?.Up ?? 0, trafficTotals?.Down ?? 0)); userStats.Activated,
pendingActivations,
totalNodes,
onlineNodes,
totalConfigs,
activeConfigs,
trafficTotals?.Up ?? 0,
trafficTotals?.Down ?? 0
)
);
} }
} }
@@ -10,16 +10,25 @@ using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Admin.Support; namespace PnvPanel.Application.Admin.Support;
public sealed class ApproveRoleRequestCommandHandler( public sealed class ApproveRoleRequestCommandHandler(
IAppDbContext dbContext, IRoleService roleService, IRealtimeNotifier notifier, IAppDbContext dbContext,
ITelegramNotifier telegramNotifier, ICurrentUser currentUser) IRoleService roleService,
: ICommandHandler<ApproveRoleRequestCommand, Result> IRealtimeNotifier notifier,
ITelegramNotifier telegramNotifier,
ICurrentUser currentUser
) : ICommandHandler<ApproveRoleRequestCommand, Result>
{ {
public async Task<Result> Handle(ApproveRoleRequestCommand command, CancellationToken cancellationToken) public async Task<Result> Handle(
ApproveRoleRequestCommand command,
CancellationToken cancellationToken
)
{ {
if (currentUser.UserId is not { } adminId) if (currentUser.UserId is not { } adminId)
return Result.Failure(AuthErrors.Unauthorized); return Result.Failure(AuthErrors.Unauthorized);
var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync(t => t.Id == command.TicketId, cancellationToken); var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync(
t => t.Id == command.TicketId,
cancellationToken
);
if (ticket is null) if (ticket is null)
return Result.Failure(SupportErrors.NotFound); return Result.Failure(SupportErrors.NotFound);
@@ -37,24 +46,44 @@ public sealed class ApproveRoleRequestCommandHandler(
else else
{ {
var createResult = await roleService.CreateRoleAsync( var createResult = await roleService.CreateRoleAsync(
ticket.ProposedRoleName!, ticket.ProposedMaxConfigs!.Value, ticket.ProposedMaxIpLimit!.Value, cancellationToken); ticket.ProposedRoleName!,
ticket.ProposedMaxConfigs!.Value,
ticket.ProposedMaxIpLimit!.Value,
cancellationToken
);
if (!createResult.IsSuccess) if (!createResult.IsSuccess)
return Result.Failure(createResult.Error); return Result.Failure(createResult.Error);
roleId = createResult.Value.Id; roleId = createResult.Value.Id;
} }
var assignResult = await roleService.ChangeUserRoleAsync(ticket.UserId, roleId, cancellationToken); var assignResult = await roleService.ChangeUserRoleAsync(
ticket.UserId,
roleId,
cancellationToken
);
if (!assignResult.IsSuccess) if (!assignResult.IsSuccess)
return assignResult; return assignResult;
ticket.Resolve(); ticket.Resolve();
dbContext.AuditLogs.Add(AuditLog.Create( dbContext.AuditLogs.Add(
adminId, "RoleRequestApproved", "SupportTicket", ticket.Id.ToString(), metadata: null, AuditSource.Web)); AuditLog.Create(
adminId,
"RoleRequestApproved",
"SupportTicket",
ticket.Id.ToString(),
metadata: null,
AuditSource.Web
)
);
await notifier.NotifyTicketUpdatedAsync(ticket.Id, ticket.UserId, cancellationToken); await notifier.NotifyTicketUpdatedAsync(ticket.Id, ticket.UserId, cancellationToken);
await telegramNotifier.NotifyUserAsync(ticket.UserId, "✅ Ваша заявка на роль одобрена.", cancellationToken); await telegramNotifier.NotifyUserAsync(
ticket.UserId,
"✅ Ваша заявка на роль одобрена.",
cancellationToken
);
return Result.Success(); return Result.Success();
} }
@@ -10,15 +10,24 @@ using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Admin.Support; namespace PnvPanel.Application.Admin.Support;
public sealed class CloseTicketCommandHandler( public sealed class CloseTicketCommandHandler(
IAppDbContext dbContext, IRealtimeNotifier notifier, ITelegramNotifier telegramNotifier, ICurrentUser currentUser) IAppDbContext dbContext,
: ICommandHandler<CloseTicketCommand, Result> IRealtimeNotifier notifier,
ITelegramNotifier telegramNotifier,
ICurrentUser currentUser
) : ICommandHandler<CloseTicketCommand, Result>
{ {
public async Task<Result> Handle(CloseTicketCommand command, CancellationToken cancellationToken) public async Task<Result> Handle(
CloseTicketCommand command,
CancellationToken cancellationToken
)
{ {
if (currentUser.UserId is not { } adminId) if (currentUser.UserId is not { } adminId)
return Result.Failure(AuthErrors.Unauthorized); return Result.Failure(AuthErrors.Unauthorized);
var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync(t => t.Id == command.TicketId, cancellationToken); var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync(
t => t.Id == command.TicketId,
cancellationToken
);
if (ticket is null) if (ticket is null)
return Result.Failure(SupportErrors.NotFound); return Result.Failure(SupportErrors.NotFound);
@@ -27,11 +36,23 @@ public sealed class CloseTicketCommandHandler(
ticket.Close(); ticket.Close();
dbContext.AuditLogs.Add(AuditLog.Create( dbContext.AuditLogs.Add(
adminId, "TicketClosed", "SupportTicket", ticket.Id.ToString(), metadata: null, AuditSource.Web)); AuditLog.Create(
adminId,
"TicketClosed",
"SupportTicket",
ticket.Id.ToString(),
metadata: null,
AuditSource.Web
)
);
await notifier.NotifyTicketUpdatedAsync(ticket.Id, ticket.UserId, cancellationToken); await notifier.NotifyTicketUpdatedAsync(ticket.Id, ticket.UserId, cancellationToken);
await telegramNotifier.NotifyUserAsync(ticket.UserId, "🔒 Ваше обращение закрыто.", cancellationToken); await telegramNotifier.NotifyUserAsync(
ticket.UserId,
"🔒 Ваше обращение закрыто.",
cancellationToken
);
return Result.Success(); return Result.Success();
} }
@@ -6,17 +6,30 @@ using PnvPanel.Application.Support;
namespace PnvPanel.Application.Admin.Support; namespace PnvPanel.Application.Admin.Support;
public sealed class GetTicketAdminQueryHandler(IAppDbContext dbContext, IIdentityService identityService, IRoleService roleService) public sealed class GetTicketAdminQueryHandler(
: IQueryHandler<GetTicketAdminQuery, Result<TicketDetailDto>> IAppDbContext dbContext,
IIdentityService identityService,
IRoleService roleService
) : IQueryHandler<GetTicketAdminQuery, Result<TicketDetailDto>>
{ {
public async Task<Result<TicketDetailDto>> Handle(GetTicketAdminQuery query, CancellationToken cancellationToken) public async Task<Result<TicketDetailDto>> Handle(
GetTicketAdminQuery query,
CancellationToken cancellationToken
)
{ {
var ticket = await dbContext.SupportTickets.AsNoTracking() var ticket = await dbContext
.SupportTickets.AsNoTracking()
.FirstOrDefaultAsync(t => t.Id == query.TicketId, cancellationToken); .FirstOrDefaultAsync(t => t.Id == query.TicketId, cancellationToken);
if (ticket is null) if (ticket is null)
return Result.Failure<TicketDetailDto>(SupportErrors.NotFound); return Result.Failure<TicketDetailDto>(SupportErrors.NotFound);
var dto = await TicketMapping.ToDetailDtoAsync(dbContext, identityService, roleService, ticket, cancellationToken); var dto = await TicketMapping.ToDetailDtoAsync(
dbContext,
identityService,
roleService,
ticket,
cancellationToken
);
return Result.Success(dto); return Result.Success(dto);
} }
} }
@@ -5,5 +5,9 @@ using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Admin.Support; namespace PnvPanel.Application.Admin.Support;
public sealed record ListAllTicketsQuery(TicketType? TypeFilter, TicketStatus? StatusFilter, int Page, int PageSize) public sealed record ListAllTicketsQuery(
: IQuery<Result<PagedList<TicketSummaryDto>>>; TicketType? TypeFilter,
TicketStatus? StatusFilter,
int Page,
int PageSize
) : IQuery<Result<PagedList<TicketSummaryDto>>>;
@@ -6,10 +6,15 @@ using PnvPanel.Application.Support;
namespace PnvPanel.Application.Admin.Support; namespace PnvPanel.Application.Admin.Support;
public sealed class ListAllTicketsQueryHandler(IAppDbContext dbContext, IIdentityService identityService) public sealed class ListAllTicketsQueryHandler(
: IQueryHandler<ListAllTicketsQuery, Result<PagedList<TicketSummaryDto>>> IAppDbContext dbContext,
IIdentityService identityService
) : IQueryHandler<ListAllTicketsQuery, Result<PagedList<TicketSummaryDto>>>
{ {
public async Task<Result<PagedList<TicketSummaryDto>>> Handle(ListAllTicketsQuery query, CancellationToken cancellationToken) public async Task<Result<PagedList<TicketSummaryDto>>> Handle(
ListAllTicketsQuery query,
CancellationToken cancellationToken
)
{ {
var page = query.Page <= 0 ? 1 : query.Page; var page = query.Page <= 0 ? 1 : query.Page;
var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize; var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize;
@@ -20,9 +25,18 @@ public sealed class ListAllTicketsQueryHandler(IAppDbContext dbContext, IIdentit
if (query.StatusFilter is { } status) if (query.StatusFilter is { } status)
ticketsQuery = ticketsQuery.Where(t => t.Status == status); ticketsQuery = ticketsQuery.Where(t => t.Status == status);
var page1 = await ticketsQuery.OrderByDescending(t => t.CreatedAt).ToPagedListAsync(page, pageSize, cancellationToken); var page1 = await ticketsQuery
var items = await TicketMapping.ToSummaryDtosAsync(dbContext, identityService, page1.Items, cancellationToken); .OrderByDescending(t => t.CreatedAt)
.ToPagedListAsync(page, pageSize, cancellationToken);
var items = await TicketMapping.ToSummaryDtosAsync(
dbContext,
identityService,
page1.Items,
cancellationToken
);
return Result.Success(new PagedList<TicketSummaryDto>(items, page1.Total, page1.Page, page1.PageSize)); return Result.Success(
new PagedList<TicketSummaryDto>(items, page1.Total, page1.Page, page1.PageSize)
);
} }
} }
@@ -10,15 +10,24 @@ using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Admin.Support; namespace PnvPanel.Application.Admin.Support;
public sealed class RejectRoleRequestCommandHandler( public sealed class RejectRoleRequestCommandHandler(
IAppDbContext dbContext, IRealtimeNotifier notifier, ITelegramNotifier telegramNotifier, ICurrentUser currentUser) IAppDbContext dbContext,
: ICommandHandler<RejectRoleRequestCommand, Result> IRealtimeNotifier notifier,
ITelegramNotifier telegramNotifier,
ICurrentUser currentUser
) : ICommandHandler<RejectRoleRequestCommand, Result>
{ {
public async Task<Result> Handle(RejectRoleRequestCommand command, CancellationToken cancellationToken) public async Task<Result> Handle(
RejectRoleRequestCommand command,
CancellationToken cancellationToken
)
{ {
if (currentUser.UserId is not { } adminId) if (currentUser.UserId is not { } adminId)
return Result.Failure(AuthErrors.Unauthorized); return Result.Failure(AuthErrors.Unauthorized);
var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync(t => t.Id == command.TicketId, cancellationToken); var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync(
t => t.Id == command.TicketId,
cancellationToken
);
if (ticket is null) if (ticket is null)
return Result.Failure(SupportErrors.NotFound); return Result.Failure(SupportErrors.NotFound);
@@ -33,11 +42,23 @@ public sealed class RejectRoleRequestCommandHandler(
ticket.Close(); ticket.Close();
dbContext.AuditLogs.Add(AuditLog.Create( dbContext.AuditLogs.Add(
adminId, "RoleRequestRejected", "SupportTicket", ticket.Id.ToString(), metadata: null, AuditSource.Web)); AuditLog.Create(
adminId,
"RoleRequestRejected",
"SupportTicket",
ticket.Id.ToString(),
metadata: null,
AuditSource.Web
)
);
await notifier.NotifyTicketUpdatedAsync(ticket.Id, ticket.UserId, cancellationToken); await notifier.NotifyTicketUpdatedAsync(ticket.Id, ticket.UserId, cancellationToken);
await telegramNotifier.NotifyUserAsync(ticket.UserId, "❌ Ваша заявка на роль отклонена.", cancellationToken); await telegramNotifier.NotifyUserAsync(
ticket.UserId,
"❌ Ваша заявка на роль отклонена.",
cancellationToken
);
return Result.Success(); return Result.Success();
} }
@@ -10,15 +10,24 @@ using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Admin.Support; namespace PnvPanel.Application.Admin.Support;
public sealed class ResolveTicketCommandHandler( public sealed class ResolveTicketCommandHandler(
IAppDbContext dbContext, IRealtimeNotifier notifier, ITelegramNotifier telegramNotifier, ICurrentUser currentUser) IAppDbContext dbContext,
: ICommandHandler<ResolveTicketCommand, Result> IRealtimeNotifier notifier,
ITelegramNotifier telegramNotifier,
ICurrentUser currentUser
) : ICommandHandler<ResolveTicketCommand, Result>
{ {
public async Task<Result> Handle(ResolveTicketCommand command, CancellationToken cancellationToken) public async Task<Result> Handle(
ResolveTicketCommand command,
CancellationToken cancellationToken
)
{ {
if (currentUser.UserId is not { } adminId) if (currentUser.UserId is not { } adminId)
return Result.Failure(AuthErrors.Unauthorized); return Result.Failure(AuthErrors.Unauthorized);
var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync(t => t.Id == command.TicketId, cancellationToken); var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync(
t => t.Id == command.TicketId,
cancellationToken
);
if (ticket is null) if (ticket is null)
return Result.Failure(SupportErrors.NotFound); return Result.Failure(SupportErrors.NotFound);
@@ -27,11 +36,23 @@ public sealed class ResolveTicketCommandHandler(
ticket.Resolve(); ticket.Resolve();
dbContext.AuditLogs.Add(AuditLog.Create( dbContext.AuditLogs.Add(
adminId, "TicketResolved", "SupportTicket", ticket.Id.ToString(), metadata: null, AuditSource.Web)); AuditLog.Create(
adminId,
"TicketResolved",
"SupportTicket",
ticket.Id.ToString(),
metadata: null,
AuditSource.Web
)
);
await notifier.NotifyTicketUpdatedAsync(ticket.Id, ticket.UserId, cancellationToken); await notifier.NotifyTicketUpdatedAsync(ticket.Id, ticket.UserId, cancellationToken);
await telegramNotifier.NotifyUserAsync(ticket.UserId, "✅ Ваше обращение решено.", cancellationToken); await telegramNotifier.NotifyUserAsync(
ticket.UserId,
"✅ Ваше обращение решено.",
cancellationToken
);
return Result.Success(); return Result.Success();
} }
@@ -10,10 +10,14 @@ namespace PnvPanel.Application.Admin.Users;
/// <summary>Блокировка гасит все активные конфиги в 3x-ui (см. architecture.md).</summary> /// <summary>Блокировка гасит все активные конфиги в 3x-ui (см. architecture.md).</summary>
public sealed class BlockUserCommandHandler( public sealed class BlockUserCommandHandler(
IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway, IAppDbContext dbContext,
IRealtimeNotifier notifier, ITelegramNotifier telegramNotifier, ICurrentUser currentUser, IIdentityService identityService,
ILogger<BlockUserCommandHandler> logger) IXuiPanelGateway gateway,
: ICommandHandler<BlockUserCommand, Result> IRealtimeNotifier notifier,
ITelegramNotifier telegramNotifier,
ICurrentUser currentUser,
ILogger<BlockUserCommandHandler> logger
) : ICommandHandler<BlockUserCommand, Result>
{ {
public async Task<Result> Handle(BlockUserCommand command, CancellationToken cancellationToken) public async Task<Result> Handle(BlockUserCommand command, CancellationToken cancellationToken)
{ {
@@ -21,22 +25,32 @@ public sealed class BlockUserCommandHandler(
if (!blockResult.IsSuccess) if (!blockResult.IsSuccess)
return blockResult; return blockResult;
var configs = await dbContext.VpnConfigs var configs = await dbContext
.Where(c => c.UserId == command.UserId && c.Status == ConfigStatus.Active) .VpnConfigs.Where(c => c.UserId == command.UserId && c.Status == ConfigStatus.Active)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
foreach (var config in configs) foreach (var config in configs)
{ {
var inbound = await dbContext.Inbounds.AsNoTracking().FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken); var inbound = await dbContext
.Inbounds.AsNoTracking()
.FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
var node = inbound is null var node = inbound is null
? null ? null
: await dbContext.Nodes.AsNoTracking().FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken); : await dbContext
.Nodes.AsNoTracking()
.FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
if (inbound is not null && node is not null) if (inbound is not null && node is not null)
{ {
var updateResult = await gateway.UpdateClientAsync( var updateResult = await gateway.UpdateClientAsync(
node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, node,
config.Label ?? config.ClientEmail, enable: false, cancellationToken); inbound.RemoteInboundId,
config.ClientExternalId,
config.Protocol,
config.Label ?? config.ClientEmail,
enable: false,
cancellationToken
);
if (!updateResult.IsSuccess) if (!updateResult.IsSuccess)
{ {
@@ -45,19 +59,40 @@ public sealed class BlockUserCommandHandler(
// Конфиг останется Active и будет подхвачен повторным BlockUserCommand (идемпотентен). // Конфиг останется Active и будет подхвачен повторным BlockUserCommand (идемпотентен).
logger.LogWarning( logger.LogWarning(
"Failed to disable client for config {ConfigId} on node {NodeId} while blocking user {UserId}: {Error}", "Failed to disable client for config {ConfigId} on node {NodeId} while blocking user {UserId}: {Error}",
config.Id, node.Id, command.UserId, updateResult.Error); config.Id,
node.Id,
command.UserId,
updateResult.Error
);
continue; continue;
} }
} }
config.Disable(); config.Disable();
await notifier.NotifyConfigStatusChangedAsync(config.UserId, config.Id, config.Status, cancellationToken); await notifier.NotifyConfigStatusChangedAsync(
config.UserId,
config.Id,
config.Status,
cancellationToken
);
} }
dbContext.AuditLogs.Add(AuditLog.Create( dbContext.AuditLogs.Add(
currentUser.UserId, "UserBlocked", "User", command.UserId.ToString(), metadata: null, AuditSource.Web)); AuditLog.Create(
currentUser.UserId,
"UserBlocked",
"User",
command.UserId.ToString(),
metadata: null,
AuditSource.Web
)
);
await telegramNotifier.NotifyUserAsync(command.UserId, "⛔ Ваш аккаунт заблокирован администратором.", cancellationToken); await telegramNotifier.NotifyUserAsync(
command.UserId,
"⛔ Ваш аккаунт заблокирован администратором.",
cancellationToken
);
return Result.Success(); return Result.Success();
} }
@@ -5,18 +5,35 @@ using PnvPanel.Domain.Audit;
namespace PnvPanel.Application.Admin.Users; namespace PnvPanel.Application.Admin.Users;
public sealed class ChangeUserRoleCommandHandler(IRoleService roleService, IAppDbContext dbContext, ICurrentUser currentUser) public sealed class ChangeUserRoleCommandHandler(
: ICommandHandler<ChangeUserRoleCommand, Result> IRoleService roleService,
IAppDbContext dbContext,
ICurrentUser currentUser
) : ICommandHandler<ChangeUserRoleCommand, Result>
{ {
public async Task<Result> Handle(ChangeUserRoleCommand command, CancellationToken cancellationToken) public async Task<Result> Handle(
ChangeUserRoleCommand command,
CancellationToken cancellationToken
)
{ {
var result = await roleService.ChangeUserRoleAsync(command.UserId, command.RoleId, cancellationToken); var result = await roleService.ChangeUserRoleAsync(
command.UserId,
command.RoleId,
cancellationToken
);
if (!result.IsSuccess) if (!result.IsSuccess)
return result; return result;
dbContext.AuditLogs.Add(AuditLog.Create( dbContext.AuditLogs.Add(
currentUser.UserId, "UserRoleChanged", "User", command.UserId.ToString(), AuditLog.Create(
metadata: $"{{\"roleId\":\"{command.RoleId}\"}}", AuditSource.Web)); currentUser.UserId,
"UserRoleChanged",
"User",
command.UserId.ToString(),
metadata: $"{{\"roleId\":\"{command.RoleId}\"}}",
AuditSource.Web
)
);
return result; return result;
} }
@@ -9,40 +9,65 @@ namespace PnvPanel.Application.Admin.Users;
/// <summary>Удаление пользователя админом: отзывает все его конфиги в 3x-ui, затем удаляет учётку.</summary> /// <summary>Удаление пользователя админом: отзывает все его конфиги в 3x-ui, затем удаляет учётку.</summary>
public sealed class DeleteUserCommandHandler( public sealed class DeleteUserCommandHandler(
IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway, IAppDbContext dbContext,
ITelegramNotifier telegramNotifier, ICurrentUser currentUser) IIdentityService identityService,
: ICommandHandler<DeleteUserCommand, Result> IXuiPanelGateway gateway,
ITelegramNotifier telegramNotifier,
ICurrentUser currentUser
) : ICommandHandler<DeleteUserCommand, Result>
{ {
public async Task<Result> Handle(DeleteUserCommand command, CancellationToken cancellationToken) public async Task<Result> Handle(DeleteUserCommand command, CancellationToken cancellationToken)
{ {
if (currentUser.UserId == command.UserId) if (currentUser.UserId == command.UserId)
return Result.Failure(UserErrors.CannotDeleteSelf); return Result.Failure(UserErrors.CannotDeleteSelf);
var configs = await dbContext.VpnConfigs var configs = await dbContext
.Where(c => c.UserId == command.UserId && c.Status != ConfigStatus.Revoked) .VpnConfigs.Where(c => c.UserId == command.UserId && c.Status != ConfigStatus.Revoked)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
foreach (var config in configs) foreach (var config in configs)
{ {
var inbound = await dbContext.Inbounds.AsNoTracking().FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken); var inbound = await dbContext
.Inbounds.AsNoTracking()
.FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
var node = inbound is null var node = inbound is null
? null ? null
: await dbContext.Nodes.AsNoTracking().FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken); : await dbContext
.Nodes.AsNoTracking()
.FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
if (inbound is not null && node is not null) if (inbound is not null && node is not null)
await gateway.RemoveClientAsync(node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, cancellationToken); await gateway.RemoveClientAsync(
node,
inbound.RemoteInboundId,
config.ClientExternalId,
config.Protocol,
cancellationToken
);
config.Revoke(); config.Revoke();
} }
dbContext.AuditLogs.Add(AuditLog.Create( dbContext.AuditLogs.Add(
currentUser.UserId, "UserDeleted", "User", command.UserId.ToString(), metadata: null, AuditSource.Web)); AuditLog.Create(
currentUser.UserId,
"UserDeleted",
"User",
command.UserId.ToString(),
metadata: null,
AuditSource.Web
)
);
// Коммитим отзыв конфигов + аудит ДО удаления учётки: UserManager.DeleteAsync ниже удаляет // Коммитим отзыв конфигов + аудит ДО удаления учётки: UserManager.DeleteAsync ниже удаляет
// AppUser отдельным путём (Identity store), после чего NotifyUserAsync уже не найдёт Telegram-привязку. // AppUser отдельным путём (Identity store), после чего NotifyUserAsync уже не найдёт Telegram-привязку.
await dbContext.SaveChangesAsync(cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);
await telegramNotifier.NotifyUserAsync(command.UserId, "🗑 Ваш аккаунт удалён администратором.", cancellationToken); await telegramNotifier.NotifyUserAsync(
command.UserId,
"🗑 Ваш аккаунт удалён администратором.",
cancellationToken
);
return await identityService.DeleteUserAsync(command.UserId, cancellationToken); return await identityService.DeleteUserAsync(command.UserId, cancellationToken);
} }
@@ -9,35 +9,70 @@ using PnvPanel.Domain.Configs;
namespace PnvPanel.Application.Admin.Users; namespace PnvPanel.Application.Admin.Users;
public sealed class ForceRevokeConfigCommandHandler( public sealed class ForceRevokeConfigCommandHandler(
IAppDbContext dbContext, IXuiPanelGateway gateway, IRealtimeNotifier notifier, IAppDbContext dbContext,
ITelegramNotifier telegramNotifier, ICurrentUser currentUser) IXuiPanelGateway gateway,
: ICommandHandler<ForceRevokeConfigCommand, Result> IRealtimeNotifier notifier,
ITelegramNotifier telegramNotifier,
ICurrentUser currentUser
) : ICommandHandler<ForceRevokeConfigCommand, Result>
{ {
public async Task<Result> Handle(ForceRevokeConfigCommand command, CancellationToken cancellationToken) public async Task<Result> Handle(
ForceRevokeConfigCommand command,
CancellationToken cancellationToken
)
{ {
var config = await dbContext.VpnConfigs.FirstOrDefaultAsync(c => c.Id == command.ConfigId, cancellationToken); var config = await dbContext.VpnConfigs.FirstOrDefaultAsync(
c => c.Id == command.ConfigId,
cancellationToken
);
if (config is null) if (config is null)
return Result.Failure(ConfigErrors.NotFound); return Result.Failure(ConfigErrors.NotFound);
if (config.Status == ConfigStatus.Revoked) if (config.Status == ConfigStatus.Revoked)
return Result.Success(); return Result.Success();
var inbound = await dbContext.Inbounds.AsNoTracking().FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken); var inbound = await dbContext
.Inbounds.AsNoTracking()
.FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
var node = inbound is null var node = inbound is null
? null ? null
: await dbContext.Nodes.AsNoTracking().FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken); : await dbContext
.Nodes.AsNoTracking()
.FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
if (inbound is not null && node is not null) if (inbound is not null && node is not null)
await gateway.RemoveClientAsync(node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, cancellationToken); await gateway.RemoveClientAsync(
node,
inbound.RemoteInboundId,
config.ClientExternalId,
config.Protocol,
cancellationToken
);
config.Revoke(); config.Revoke();
await notifier.NotifyConfigStatusChangedAsync(config.UserId, config.Id, config.Status, cancellationToken); await notifier.NotifyConfigStatusChangedAsync(
config.UserId,
config.Id,
config.Status,
cancellationToken
);
dbContext.AuditLogs.Add(AuditLog.Create( dbContext.AuditLogs.Add(
currentUser.UserId, "ConfigForceRevoked", "VpnConfig", config.Id.ToString(), metadata: null, AuditSource.Web)); AuditLog.Create(
currentUser.UserId,
"ConfigForceRevoked",
"VpnConfig",
config.Id.ToString(),
metadata: null,
AuditSource.Web
)
);
await telegramNotifier.NotifyUserAsync( await telegramNotifier.NotifyUserAsync(
config.UserId, $"⚠️ Администратор отозвал ваш конфиг «{config.Label ?? config.ClientEmail}».", cancellationToken); config.UserId,
$"⚠️ Администратор отозвал ваш конфиг «{config.Label ?? config.ClientEmail}».",
cancellationToken
);
return Result.Success(); return Result.Success();
} }
@@ -10,11 +10,20 @@ namespace PnvPanel.Application.Admin.Users;
public sealed class GetUserConfigsQueryHandler(IAppDbContext dbContext) public sealed class GetUserConfigsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<GetUserConfigsQuery, Result<IReadOnlyList<VpnConfigDto>>> : IQueryHandler<GetUserConfigsQuery, Result<IReadOnlyList<VpnConfigDto>>>
{ {
public async Task<Result<IReadOnlyList<VpnConfigDto>>> Handle(GetUserConfigsQuery query, CancellationToken cancellationToken) public async Task<Result<IReadOnlyList<VpnConfigDto>>> Handle(
GetUserConfigsQuery query,
CancellationToken cancellationToken
)
{ {
var rows = await dbContext.VpnConfigs.AsNoTracking() var rows = await dbContext
.VpnConfigs.AsNoTracking()
.Where(c => c.UserId == query.UserId && c.Status != ConfigStatus.Revoked) .Where(c => c.UserId == query.UserId && c.Status != ConfigStatus.Revoked)
.Join(dbContext.Inbounds.AsNoTracking(), c => c.InboundId, i => i.Id, (c, i) => new { Config = c, Inbound = i }) .Join(
dbContext.Inbounds.AsNoTracking(),
c => c.InboundId,
i => i.Id,
(c, i) => new { Config = c, Inbound = i }
)
.OrderByDescending(x => x.Config.CreatedAt) .OrderByDescending(x => x.Config.CreatedAt)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@@ -4,4 +4,5 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Users; namespace PnvPanel.Application.Admin.Users;
public sealed record ListUsersQuery(int Page, int PageSize, string? Search) : IQuery<Result<PagedList<UserSummaryDto>>>; public sealed record ListUsersQuery(int Page, int PageSize, string? Search)
: IQuery<Result<PagedList<UserSummaryDto>>>;
@@ -7,12 +7,20 @@ namespace PnvPanel.Application.Admin.Users;
public sealed class ListUsersQueryHandler(IIdentityService identityService) public sealed class ListUsersQueryHandler(IIdentityService identityService)
: IQueryHandler<ListUsersQuery, Result<PagedList<UserSummaryDto>>> : IQueryHandler<ListUsersQuery, Result<PagedList<UserSummaryDto>>>
{ {
public async Task<Result<PagedList<UserSummaryDto>>> Handle(ListUsersQuery query, CancellationToken cancellationToken) public async Task<Result<PagedList<UserSummaryDto>>> Handle(
ListUsersQuery query,
CancellationToken cancellationToken
)
{ {
var page = query.Page <= 0 ? 1 : query.Page; var page = query.Page <= 0 ? 1 : query.Page;
var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize; var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize;
var result = await identityService.ListUsersAsync(page, pageSize, query.Search, cancellationToken); var result = await identityService.ListUsersAsync(
page,
pageSize,
query.Search,
cancellationToken
);
return Result.Success(result); return Result.Success(result);
} }
} }
@@ -5,17 +5,35 @@ using PnvPanel.Domain.Audit;
namespace PnvPanel.Application.Admin.Users; namespace PnvPanel.Application.Admin.Users;
public sealed class ResetUserPasswordCommandHandler(IAppDbContext dbContext, IIdentityService identityService, ICurrentUser currentUser) public sealed class ResetUserPasswordCommandHandler(
: ICommandHandler<ResetUserPasswordCommand, Result> IAppDbContext dbContext,
IIdentityService identityService,
ICurrentUser currentUser
) : ICommandHandler<ResetUserPasswordCommand, Result>
{ {
public async Task<Result> Handle(ResetUserPasswordCommand command, CancellationToken cancellationToken) public async Task<Result> Handle(
ResetUserPasswordCommand command,
CancellationToken cancellationToken
)
{ {
var result = await identityService.ResetPasswordAsync(command.UserId, command.NewPassword, cancellationToken); var result = await identityService.ResetPasswordAsync(
command.UserId,
command.NewPassword,
cancellationToken
);
if (!result.IsSuccess) if (!result.IsSuccess)
return result; return result;
dbContext.AuditLogs.Add(AuditLog.Create( dbContext.AuditLogs.Add(
currentUser.UserId, "UserPasswordReset", "User", command.UserId.ToString(), metadata: null, AuditSource.Web)); AuditLog.Create(
currentUser.UserId,
"UserPasswordReset",
"User",
command.UserId.ToString(),
metadata: null,
AuditSource.Web
)
);
await dbContext.SaveChangesAsync(cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);
return Result.Success(); return Result.Success();
@@ -10,32 +10,52 @@ namespace PnvPanel.Application.Admin.Users;
/// <summary>Разблокировка возвращает в 3x-ui только конфиги, погашенные блокировкой (Disabled).</summary> /// <summary>Разблокировка возвращает в 3x-ui только конфиги, погашенные блокировкой (Disabled).</summary>
public sealed class UnblockUserCommandHandler( public sealed class UnblockUserCommandHandler(
IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway, IAppDbContext dbContext,
IRealtimeNotifier notifier, ICurrentUser currentUser, ILogger<UnblockUserCommandHandler> logger) IIdentityService identityService,
: ICommandHandler<UnblockUserCommand, Result> IXuiPanelGateway gateway,
IRealtimeNotifier notifier,
ICurrentUser currentUser,
ILogger<UnblockUserCommandHandler> logger
) : ICommandHandler<UnblockUserCommand, Result>
{ {
public async Task<Result> Handle(UnblockUserCommand command, CancellationToken cancellationToken) public async Task<Result> Handle(
UnblockUserCommand command,
CancellationToken cancellationToken
)
{ {
var unblockResult = await identityService.UnblockUserAsync(command.UserId, cancellationToken); var unblockResult = await identityService.UnblockUserAsync(
command.UserId,
cancellationToken
);
if (!unblockResult.IsSuccess) if (!unblockResult.IsSuccess)
return unblockResult; return unblockResult;
var configs = await dbContext.VpnConfigs var configs = await dbContext
.Where(c => c.UserId == command.UserId && c.Status == ConfigStatus.Disabled) .VpnConfigs.Where(c => c.UserId == command.UserId && c.Status == ConfigStatus.Disabled)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
foreach (var config in configs) foreach (var config in configs)
{ {
var inbound = await dbContext.Inbounds.AsNoTracking().FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken); var inbound = await dbContext
.Inbounds.AsNoTracking()
.FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
var node = inbound is null var node = inbound is null
? null ? null
: await dbContext.Nodes.AsNoTracking().FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken); : await dbContext
.Nodes.AsNoTracking()
.FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
if (inbound is not null && node is not null) if (inbound is not null && node is not null)
{ {
var updateResult = await gateway.UpdateClientAsync( var updateResult = await gateway.UpdateClientAsync(
node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, node,
config.Label ?? config.ClientEmail, enable: true, cancellationToken); inbound.RemoteInboundId,
config.ClientExternalId,
config.Protocol,
config.Label ?? config.ClientEmail,
enable: true,
cancellationToken
);
if (!updateResult.IsSuccess) if (!updateResult.IsSuccess)
{ {
@@ -44,17 +64,34 @@ public sealed class UnblockUserCommandHandler(
// повторным UnblockUserCommand (идемпотентен). // повторным UnblockUserCommand (идемпотентен).
logger.LogWarning( logger.LogWarning(
"Failed to enable client for config {ConfigId} on node {NodeId} while unblocking user {UserId}: {Error}", "Failed to enable client for config {ConfigId} on node {NodeId} while unblocking user {UserId}: {Error}",
config.Id, node.Id, command.UserId, updateResult.Error); config.Id,
node.Id,
command.UserId,
updateResult.Error
);
continue; continue;
} }
} }
config.Enable(); config.Enable();
await notifier.NotifyConfigStatusChangedAsync(config.UserId, config.Id, config.Status, cancellationToken); await notifier.NotifyConfigStatusChangedAsync(
config.UserId,
config.Id,
config.Status,
cancellationToken
);
} }
dbContext.AuditLogs.Add(AuditLog.Create( dbContext.AuditLogs.Add(
currentUser.UserId, "UserUnblocked", "User", command.UserId.ToString(), metadata: null, AuditSource.Web)); AuditLog.Create(
currentUser.UserId,
"UserUnblocked",
"User",
command.UserId.ToString(),
metadata: null,
AuditSource.Web
)
);
return Result.Success(); return Result.Success();
} }
@@ -4,8 +4,13 @@ namespace PnvPanel.Application.Admin.Users;
public static class UserErrors public static class UserErrors
{ {
public static readonly Error NotFound = Error.NotFound("Users.NotFound", "Пользователь не найден."); public static readonly Error NotFound = Error.NotFound(
"Users.NotFound",
"Пользователь не найден."
);
public static readonly Error CannotDeleteSelf = Error.Validation( public static readonly Error CannotDeleteSelf = Error.Validation(
"Users.CannotDeleteSelf", "Нельзя удалить свою учётную запись здесь — используйте удаление аккаунта в Настройках."); "Users.CannotDeleteSelf",
"Нельзя удалить свою учётную запись здесь — используйте удаление аккаунта в Настройках."
);
} }
@@ -2,7 +2,13 @@ using PnvPanel.Domain.Apps;
namespace PnvPanel.Application.Apps; namespace PnvPanel.Application.Apps;
public sealed record ClientAppDto(Guid Id, string Name, string DownloadUrl, string? Description, string? IconUrl) public sealed record ClientAppDto(
Guid Id,
string Name,
string DownloadUrl,
string? Description,
string? IconUrl
)
{ {
public static ClientAppDto FromDomain(ClientApp app) => public static ClientAppDto FromDomain(ClientApp app) =>
new(app.Id, app.Name, app.DownloadUrl.ToString(), app.Description, app.IconUrl); new(app.Id, app.Name, app.DownloadUrl.ToString(), app.Description, app.IconUrl);
@@ -4,4 +4,6 @@ using PnvPanel.Domain.Apps;
namespace PnvPanel.Application.Apps; namespace PnvPanel.Application.Apps;
public sealed record ListAppsQuery : IQuery<Result<IReadOnlyDictionary<OsPlatform, IReadOnlyList<ClientAppDto>>>>, IRequiresActivation; public sealed record ListAppsQuery
: IQuery<Result<IReadOnlyDictionary<OsPlatform, IReadOnlyList<ClientAppDto>>>>,
IRequiresActivation;
@@ -7,22 +7,30 @@ using PnvPanel.Domain.Apps;
namespace PnvPanel.Application.Apps; namespace PnvPanel.Application.Apps;
public sealed class ListAppsQueryHandler(IAppDbContext dbContext) public sealed class ListAppsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListAppsQuery, Result<IReadOnlyDictionary<OsPlatform, IReadOnlyList<ClientAppDto>>>> : IQueryHandler<
ListAppsQuery,
Result<IReadOnlyDictionary<OsPlatform, IReadOnlyList<ClientAppDto>>>
>
{ {
public async Task<Result<IReadOnlyDictionary<OsPlatform, IReadOnlyList<ClientAppDto>>>> Handle( public async Task<Result<IReadOnlyDictionary<OsPlatform, IReadOnlyList<ClientAppDto>>>> Handle(
ListAppsQuery query, CancellationToken cancellationToken) ListAppsQuery query,
CancellationToken cancellationToken
)
{ {
var apps = await dbContext.ClientApps.AsNoTracking() var apps = await dbContext
.ClientApps.AsNoTracking()
.Where(a => a.IsEnabled) .Where(a => a.IsEnabled)
.OrderBy(a => a.SortOrder) .OrderBy(a => a.SortOrder)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var grouped = apps var grouped = apps.GroupBy(a => a.OperatingSystem)
.GroupBy(a => a.OperatingSystem)
.ToDictionary( .ToDictionary(
g => g.Key, g => g.Key,
g => (IReadOnlyList<ClientAppDto>)g.Select(ClientAppDto.FromDomain).ToList()); g => (IReadOnlyList<ClientAppDto>)g.Select(ClientAppDto.FromDomain).ToList()
);
return Result.Success<IReadOnlyDictionary<OsPlatform, IReadOnlyList<ClientAppDto>>>(grouped); return Result.Success<IReadOnlyDictionary<OsPlatform, IReadOnlyList<ClientAppDto>>>(
grouped
);
} }
} }
@@ -4,24 +4,38 @@ namespace PnvPanel.Application.Auth;
public static class AuthErrors public static class AuthErrors
{ {
public static readonly Error DuplicateUserName = public static readonly Error DuplicateUserName = Error.Conflict(
Error.Conflict("Auth.DuplicateUserName", "Пользователь с таким именем уже существует."); "Auth.DuplicateUserName",
"Пользователь с таким именем уже существует."
);
public static readonly Error InvalidCredentials = public static readonly Error InvalidCredentials = Error.Unauthorized(
Error.Unauthorized("Auth.InvalidCredentials", "Неверное имя пользователя или пароль."); "Auth.InvalidCredentials",
"Неверное имя пользователя или пароль."
);
public static readonly Error LockedOut = public static readonly Error LockedOut = Error.Unauthorized(
Error.Unauthorized("Auth.LockedOut", "Слишком много неудачных попыток входа. Попробуйте позже."); "Auth.LockedOut",
"Слишком много неудачных попыток входа. Попробуйте позже."
);
public static readonly Error InvalidRefreshToken = public static readonly Error InvalidRefreshToken = Error.Unauthorized(
Error.Unauthorized("Auth.InvalidRefreshToken", "Недействительный refresh-токен."); "Auth.InvalidRefreshToken",
"Недействительный refresh-токен."
);
public static readonly Error Unauthorized = public static readonly Error Unauthorized = Error.Unauthorized(
Error.Unauthorized("Auth.Unauthorized", "Требуется аутентификация."); "Auth.Unauthorized",
"Требуется аутентификация."
);
public static readonly Error UserBlocked = public static readonly Error UserBlocked = Error.Forbidden(
Error.Forbidden("Auth.UserBlocked", "Аккаунт заблокирован администратором."); "Auth.UserBlocked",
"Аккаунт заблокирован администратором."
);
public static readonly Error NotActivated = public static readonly Error NotActivated = Error.Forbidden(
Error.Forbidden("Auth.NotActivated", "Аккаунт не активирован — обратитесь к администратору."); "Auth.NotActivated",
"Аккаунт не активирован — обратитесь к администратору."
);
} }
@@ -5,4 +5,5 @@ public sealed record AuthResult(
DateTimeOffset AccessTokenExpiresAt, DateTimeOffset AccessTokenExpiresAt,
string RefreshToken, string RefreshToken,
DateTimeOffset RefreshTokenExpiresAt, DateTimeOffset RefreshTokenExpiresAt,
CurrentUserDto User); CurrentUserDto User
);

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