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>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
@@ -18,5 +17,4 @@
-->
<NoWarn>$(NoWarn);CA1711;CA1716;CA1848;CA1873</NoWarn>
</PropertyGroup>
</Project>
+9 -3
View File
@@ -10,7 +10,10 @@
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" 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.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.OpenApi" Version="2.9.0" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.7" />
@@ -22,7 +25,10 @@
</PackageVersion>
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<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="Telegram.Bot" Version="22.10.1" />
<PackageVersion Include="ThreeXui.Net" Version="1.0.1" />
@@ -37,4 +43,4 @@
не имеющие реляционных аналогов; InMemory игнорирует HasColumnType и не требует их маппинга. -->
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
</ItemGroup>
</Project>
</Project>
@@ -4,11 +4,11 @@ namespace PnvPanel.Api.Common;
public static class ResultExtensions
{
public static IResult ToHttpResult(this Result result)
=> result.IsSuccess ? Results.NoContent() : ToProblem(result.Error);
public static IResult ToHttpResult(this Result result) =>
result.IsSuccess ? Results.NoContent() : ToProblem(result.Error);
public static IResult ToHttpResult<T>(this Result<T> result)
=> result.IsSuccess ? Results.Ok(result.Value) : ToProblem(result.Error);
public static IResult ToHttpResult<T>(this Result<T> result) =>
result.IsSuccess ? Results.Ok(result.Value) : ToProblem(result.Error);
private static IResult ToProblem(Error error)
{
@@ -27,40 +27,69 @@ public static class ActivationEndpoints
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);
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);
return result.ToHttpResult();
}
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);
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);
return result.ToHttpResult();
}
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();
}
}
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);
@@ -28,21 +28,42 @@ public static class AdminAppEndpoints
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);
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(
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);
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);
return result.ToHttpResult();
@@ -50,5 +71,11 @@ public static class AdminAppEndpoints
}
public sealed record UpdateAppBody(
string Name, string DownloadUrl, OsPlatform OperatingSystem, string? Description, string? IconUrl,
int SortOrder, bool IsEnabled);
string Name,
string DownloadUrl,
OsPlatform OperatingSystem,
string? Description,
string? IconUrl,
int SortOrder,
bool IsEnabled
);
@@ -23,26 +23,44 @@ public static class AdminNewsEndpoints
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);
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);
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 result = await sender.Send(command, cancellationToken);
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);
return result.ToHttpResult();
@@ -27,7 +27,12 @@ public static class AdminStatsEndpoints
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 result = await sender.Send(query, cancellationToken);
@@ -19,59 +19,104 @@ public static class AdminSupportEndpoints
admin.MapGet("/tickets", ListTickets).Produces<PagedList<TicketSummaryDto>>();
admin.MapGet("/tickets/{id:guid}", GetTicket).Produces<TicketDetailDto>();
admin.MapPost("/tickets/{id:guid}/comments", AddComment).DisableAntiforgery().Produces<TicketCommentDto>();
admin.MapPost("/tickets/{id:guid}/resolve", Resolve).Produces(StatusCodes.Status204NoContent);
admin
.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}/approve", ApproveRoleRequest).Produces(StatusCodes.Status204NoContent);
admin.MapPost("/tickets/{id:guid}/reject", RejectRoleRequest).Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/tickets/{id:guid}/approve", ApproveRoleRequest)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/tickets/{id:guid}/reject", RejectRoleRequest)
.Produces(StatusCodes.Status204NoContent);
return app;
}
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);
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);
return result.ToHttpResult();
}
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 result = await sender.Send(command, cancellationToken);
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);
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);
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);
return result.ToHttpResult();
}
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();
}
}
@@ -19,64 +19,118 @@ public static class AdminUserEndpoints
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("/users", ListUsers).Produces<PagedList<UserSummaryDto>>();
admin.MapPatch("/users/{id:guid}/block", BlockUser).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
.MapPatch("/users/{id:guid}/block", BlockUser)
.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.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.MapDelete("/configs/{id:guid}", ForceRevokeConfig).Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/configs/{id:guid}", ForceRevokeConfig)
.Produces(StatusCodes.Status204NoContent);
return app;
}
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 result = await sender.Send(query, cancellationToken);
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);
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);
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();
}
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);
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);
return result.ToHttpResult();
}
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);
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);
return result.ToHttpResult();
@@ -25,34 +25,69 @@ public static class AuthEndpoints
group.MapPost("/register", Register).Produces<RegisterResult>();
group.MapPost("/login", Login).Produces<AuthResponseDto>();
group.MapPost("/refresh", Refresh).Produces<AuthResponseDto>();
group.MapPost("/logout", Logout).RequireAuthorization().Produces(StatusCodes.Status204NoContent);
group.MapPost("/change-password", ChangePassword).RequireAuthorization().Produces(StatusCodes.Status204NoContent);
group.MapPost("/change-username", ChangeUserName).RequireAuthorization().Produces(StatusCodes.Status204NoContent);
group
.MapPost("/logout", Logout)
.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.MapDelete("/me", DeleteMe).RequireAuthorization().Produces(StatusCodes.Status204NoContent);
group
.MapDelete("/me", DeleteMe)
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent);
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);
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);
if (!result.IsSuccess)
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));
}
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();
var result = await sender.Send(new RefreshCommand(rawToken), cancellationToken);
@@ -62,29 +97,50 @@ public static class AuthEndpoints
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));
}
internal static AuthResponseDto ToLoginResponse(AuthResult auth) =>
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);
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request));
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);
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);
return result.ToHttpResult();
@@ -96,14 +152,24 @@ public static class AuthEndpoints
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);
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request));
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);
options.Expires = expiresAt;
@@ -112,13 +178,18 @@ public static class AuthEndpoints
// Secure = IsHttps запроса (учитывает ForwardedHeaders за внешним TLS-прокси, см. CLAUDE.md) —
// иначе браузер/HttpClient не пришлёт cookie обратно на plain-http (локальный dev, TestServer).
private static CookieOptions BuildCookieOptions(HttpRequest request) => new()
{
HttpOnly = true,
Secure = request.IsHttps,
SameSite = SameSiteMode.Strict,
Path = "/api/auth",
};
private static CookieOptions BuildCookieOptions(HttpRequest request) =>
new()
{
HttpOnly = true,
Secure = request.IsHttps,
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();
group.MapGet("/inbounds/available", ListAvailableInbounds).Produces<IReadOnlyList<AvailableInboundDto>>();
group
.MapGet("/inbounds/available", ListAvailableInbounds)
.Produces<IReadOnlyList<AvailableInboundDto>>();
group.MapGet("/configs", GetMyConfigs).Produces<GetMyConfigsResult>();
group.MapPost("/configs", CreateConfig).Produces<VpnConfigDto>();
group.MapPatch("/configs/{id:guid}", EditConfig).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("/subscription", GetMySubscription).Produces<MySubscriptionResponseDto>();
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);
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);
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 result = await sender.Send(command, cancellationToken);
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 result = await sender.Send(command, cancellationToken);
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);
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);
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);
if (!result.IsSuccess)
return result.ToHttpResult();
var subscriptionUrl = $"{request.Scheme}://{request.Host}/sub/{result.Value.SubscriptionToken}";
return Results.Ok(new ConfigLinkResponseDto(result.Value.ConnectionString, subscriptionUrl));
var 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);
if (!result.IsSuccess)
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));
}
}
@@ -19,20 +19,38 @@ public static class InboundEndpoints
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);
return result.ToHttpResult();
}
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(
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);
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;
}
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);
return result.ToHttpResult();
@@ -23,42 +23,79 @@ public static class NodeEndpoints
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);
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);
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);
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);
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);
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);
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.MapPut("/roles/{id:guid}", UpdateRole).Produces<RoleDto>();
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;
}
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);
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);
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();
}
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);
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();
}
}
@@ -19,12 +19,19 @@ public static class SubscriptionEndpoints
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
// (один конфиг). Пробуем пользовательский токен первым.
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)
return Results.NotFound();
@@ -36,7 +43,8 @@ public static class SubscriptionEndpoints
var expire = result.Value.ExpiresAt is { } exp ? exp.ToUnixTimeSeconds().ToString() : "0";
response.Headers.Append(
"Subscription-Userinfo",
$"upload={result.Value.UsedUpBytes}; download={result.Value.UsedDownBytes}; total={total}; expire={expire}");
$"upload={result.Value.UsedUpBytes}; download={result.Value.UsedDownBytes}; total={total}; expire={expire}"
);
response.Headers.Append("Profile-Update-Interval", "12");
return Results.Text(base64Body, "text/plain; charset=utf-8");
@@ -23,25 +23,38 @@ public static class SupportEndpoints
var group = app.MapGroup("/api/support").WithTags("Support").RequireAuthorization();
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.MapGet("/tickets", ListMyTickets).Produces<PagedList<TicketSummaryDto>>();
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.MapGet("/attachments/{id:guid}", GetAttachment);
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);
return result.ToHttpResult();
}
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 result = await sender.Send(command, cancellationToken);
@@ -49,43 +62,76 @@ public static class SupportEndpoints
}
private static async Task<IResult> CreateRoleRequest(
CreateRoleRequestBody body, ISender sender, CancellationToken cancellationToken)
CreateRoleRequestBody body,
ISender sender,
CancellationToken cancellationToken
)
{
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);
return result.ToHttpResult();
}
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);
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);
return result.ToHttpResult();
}
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 result = await sender.Send(command, cancellationToken);
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);
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);
if (!result.IsSuccess)
@@ -100,12 +146,27 @@ public static class SupportEndpoints
return [];
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();
}
}
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")
.RequireRateLimiting(RateLimiting.AuthPolicy);
group.MapPost("/link-token", CreateLinkToken).RequireAuthorization().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>();
group
.MapPost("/link-token", CreateLinkToken)
.RequireAuthorization()
.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;
}
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);
if (!result.IsSuccess)
return result.ToHttpResult();
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));
}
@@ -43,7 +58,11 @@ public static class TelegramEndpoints
}
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 result = await sender.Send(new CreateLoginRequestCommand(context), cancellationToken);
@@ -51,13 +70,26 @@ public static class TelegramEndpoints
return result.ToHttpResult();
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(
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);
if (!result.IsSuccess)
@@ -77,17 +109,33 @@ public static class TelegramEndpoints
};
response.Cookies.Append("pnv_refresh_token", auth.RefreshToken, cookieOptions);
return Results.Ok(new TelegramLoginStatusResponseDto(
result.Value.Status, auth.AccessToken, auth.AccessTokenExpiresAt, auth.User));
return Results.Ok(
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 TelegramLoginRequestResponseDto(Guid RequestId, string? DeepLink, DateTimeOffset ExpiresAt);
public sealed record TelegramLoginRequestResponseDto(
Guid RequestId,
string? DeepLink,
DateTimeOffset ExpiresAt
);
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
{
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(
"configTrafficUpdated",
new { configId, usedUpBytes, usedDownBytes },
cancellationToken);
return hubContext
.Clients.Group(GroupNames.User(userId))
.SendAsync(
"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(
"configStatusChanged",
new { configId, status = status.ToString() },
cancellationToken);
return hubContext
.Clients.Group(GroupNames.User(userId))
.SendAsync(
"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(
"nodeStatusChanged",
new { nodeId, status = status.ToString(), lastSyncAt },
cancellationToken);
return hubContext
.Clients.Group(GroupNames.Admins)
.SendAsync(
"nodeStatusChanged",
new
{
nodeId,
status = status.ToString(),
lastSyncAt,
},
cancellationToken
);
}
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(
"activationRequested",
new { requestId, userId, userName, comment, createdAt },
cancellationToken);
return hubContext
.Clients.Group(GroupNames.Admins)
.SendAsync(
"activationRequested",
new
{
requestId,
userId,
userName,
comment,
createdAt,
},
cancellationToken
);
}
public Task NotifyUserActivatedAsync(Guid userId, CancellationToken cancellationToken)
{
return hubContext.Clients.Group(GroupNames.User(userId)).SendAsync(
"userActivated",
new { userId },
cancellationToken);
return hubContext
.Clients.Group(GroupNames.User(userId))
.SendAsync("userActivated", new { userId }, 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(
"newsPublished",
new { id = postId, title, createdAt },
cancellationToken);
new
{
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(
"ticketCreated",
new { ticketId, userId, userName, type = type.ToString() },
cancellationToken);
return hubContext
.Clients.Group(GroupNames.Admins)
.SendAsync(
"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(
"ticketUpdated",
new { ticketId },
cancellationToken);
return hubContext
.Clients.Group(GroupNames.User(userId))
.SendAsync("ticketUpdated", new { ticketId }, cancellationToken);
}
}
+5 -3
View File
@@ -1,12 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<ProjectReference Include="..\PnvPanel.Infrastructure\PnvPanel.Infrastructure.csproj" />
<ProjectReference Include="..\PnvPanel.Application\PnvPanel.Application.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="..\..\..\seed\client-apps.json" Link="seed\client-apps.json" CopyToOutputDirectory="PreserveNewest" />
<Content
Include="..\..\..\seed\client-apps.json"
Link="seed\client-apps.json"
CopyToOutputDirectory="PreserveNewest"
/>
</ItemGroup>
<ItemGroup>
@@ -27,5 +30,4 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
+56 -23
View File
@@ -20,10 +20,13 @@ using Telegram.Bot;
var builder = WebApplication.CreateBuilder(args);
// Структурное логирование (Serilog), конфигурация из appsettings/env.
builder.Services.AddSerilog((services, configuration) => configuration
.ReadFrom.Configuration(builder.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext());
builder.Services.AddSerilog(
(services, configuration) =>
configuration
.ReadFrom.Configuration(builder.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
);
// За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера), но ТОЛЬКО от явно
// перечисленных адресов/сетей прокси — иначе клиент может подделать свой IP/схему напрямую, минуя
@@ -34,13 +37,25 @@ builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
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));
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('/');
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.AddSignalR();
// В Api, не в Infrastructure — реализации нужен IHubContext<PanelHub>, а Hub определён здесь же.
builder.Services.AddSingleton<IRealtimeNotifier, SignalRRealtimeNotifier>();
@@ -74,17 +90,27 @@ builder.Services.AddSingleton<ITelegramBotClient>(sp =>
if (!string.IsNullOrEmpty(proxyUri.UserInfo))
{
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(
"Telegram bot using proxy {Scheme}://{Host}:{Port}", proxyUri.Scheme, proxyUri.Host, proxyUri.Port);
sp.GetRequiredService<ILogger<Program>>()
.LogInformation(
"Telegram bot using proxy {Scheme}://{Host}:{Port}",
proxyUri.Scheme,
proxyUri.Host,
proxyUri.Port
);
var handler = new SocketsHttpHandler { Proxy = proxy, UseProxy = true };
return new TelegramBotClient(token, new HttpClient(handler));
});
// Scoped — зависит от IIdentityService (scoped), не Singleton.
builder.Services.AddScoped<ITelegramNotifier, TelegramNotifier>();
// Singleton — кэширует username бота (getMe) на весь процесс, не из ручного env (см. TelegramBotInfo).
builder.Services.AddSingleton<ITelegramBotInfo, TelegramBotInfo>();
builder.Services.AddSingleton<PnvBotUpdateHandler>();
@@ -92,26 +118,32 @@ builder.Services.AddHostedService<TelegramBotHostedService>();
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter(RateLimiting.AuthPolicy, limiterOptions =>
{
// Настраиваемо через конфиг, чтобы интеграционные тесты (общий TestServer/host на весь
// collection, все запросы — от одного "клиента") могли поднять лимит и не ловить 429.
limiterOptions.PermitLimit = builder.Configuration.GetValue("RateLimiting:AuthPermitLimit", 20);
limiterOptions.Window = TimeSpan.FromMinutes(1);
limiterOptions.QueueLimit = 0;
});
options.AddFixedWindowLimiter(
RateLimiting.AuthPolicy,
limiterOptions =>
{
// Настраиваемо через конфиг, чтобы интеграционные тесты (общий TestServer/host на весь
// collection, все запросы — от одного "клиента") могли поднять лимит и не ловить 429.
limiterOptions.PermitLimit = builder.Configuration.GetValue(
"RateLimiting:AuthPermitLimit",
20
);
limiterOptions.Window = TimeSpan.FromMinutes(1);
limiterOptions.QueueLimit = 0;
}
);
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});
// Энумы сериализуются строками ("Vless", "Active", ...), не числами — самодокументируемый JSON,
// корректные строковые литералы при генерации TS-типов из OpenAPI-схемы (см. docs/frontend.md).
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())
);
builder.Services.AddProblemDetails();
builder.Services.AddOpenApi();
builder.Services.AddHealthChecks()
.AddDbContextCheck<AppDbContext>();
builder.Services.AddHealthChecks().AddDbContextCheck<AppDbContext>();
var app = builder.Build();
@@ -120,8 +152,9 @@ var app = builder.Build();
if (string.IsNullOrWhiteSpace(builder.Configuration["DataProtection:KeyRingPath"]))
{
app.Logger.LogWarning(
"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.");
"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."
);
}
// Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте.
@@ -23,24 +23,41 @@ namespace PnvPanel.Api.Telegram;
/// свежие scoped-сервисы (ISender, ICurrentUserSetter, ...). Бот — read-only по конфигам в MVP.
/// </summary>
public sealed class PnvBotUpdateHandler(
IServiceScopeFactory scopeFactory, IOptions<TelegramOptions> options, ILogger<PnvBotUpdateHandler> logger)
: IUpdateHandler
IServiceScopeFactory scopeFactory,
IOptions<TelegramOptions> options,
ILogger<PnvBotUpdateHandler> logger
) : IUpdateHandler
{
// Показывается везде, где боту нужен привязанный аккаунт, а его нет — явно проговариваем оба шага,
// иначе новые пользователи не понимают, что сначала нужен обычный аккаунт на сайте.
private const string NotLinkedMessage =
"Сначала зарегистрируйтесь и войдите на сайте, затем привяжите 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();
try
{
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)
await HandleCallbackAsync(botClient, scope.ServiceProvider, callback, cancellationToken);
await HandleCallbackAsync(
botClient,
scope.ServiceProvider,
callback,
cancellationToken
);
}
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);
return Task.CompletedTask;
}
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 fromId = message.From?.Id;
@@ -67,11 +94,32 @@ public sealed class PnvBotUpdateHandler(
var payload = text.Length > 7 ? text[7..].Trim() : string.Empty;
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))
await HandleLoginPromptAsync(botClient, services, chatId, fromId.Value, payload[6..], cancellationToken);
await HandleLoginPromptAsync(
botClient,
services,
chatId,
fromId.Value,
payload[6..],
cancellationToken
);
else
await SendWelcomeAsync(botClient, services, chatId, fromId.Value, cancellationToken);
await SendWelcomeAsync(
botClient,
services,
chatId,
fromId.Value,
cancellationToken
);
return;
}
@@ -79,25 +127,57 @@ public sealed class PnvBotUpdateHandler(
switch (text)
{
case "/configs":
await HandleConfigsAsync(botClient, services, chatId, fromId.Value, cancellationToken);
await HandleConfigsAsync(
botClient,
services,
chatId,
fromId.Value,
cancellationToken
);
break;
case "/unlink":
await HandleUnlinkAsync(botClient, services, chatId, fromId.Value, cancellationToken);
await HandleUnlinkAsync(
botClient,
services,
chatId,
fromId.Value,
cancellationToken
);
break;
case "/requests":
await HandleRequestsAsync(botClient, services, chatId, fromId.Value, cancellationToken);
await HandleRequestsAsync(
botClient,
services,
chatId,
fromId.Value,
cancellationToken
);
break;
case "/help":
await SendWelcomeAsync(botClient, services, chatId, fromId.Value, cancellationToken);
await SendWelcomeAsync(
botClient,
services,
chatId,
fromId.Value,
cancellationToken
);
break;
default:
await botClient.SendMessage(chatId, "Не понимаю эту команду. /help — список команд.", cancellationToken: cancellationToken);
await botClient.SendMessage(
chatId,
"Не понимаю эту команду. /help — список команд.",
cancellationToken: cancellationToken
);
break;
}
}
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 chatId = callback.Message?.Chat.Id;
@@ -107,7 +187,15 @@ public sealed class PnvBotUpdateHandler(
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;
}
@@ -115,15 +203,28 @@ public sealed class PnvBotUpdateHandler(
{
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
{
await botClient.AnswerCallbackQuery(callback.Id, "Telegram не привязан.", cancellationToken: cancellationToken);
await botClient.AnswerCallbackQuery(
callback.Id,
"Telegram не привязан.",
cancellationToken: cancellationToken
);
return;
}
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)
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;
}
@@ -135,9 +236,18 @@ public sealed class PnvBotUpdateHandler(
return;
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);
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;
}
@@ -146,12 +256,19 @@ public sealed class PnvBotUpdateHandler(
{
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
{
await botClient.AnswerCallbackQuery(callback.Id, "Telegram не привязан.", cancellationToken: cancellationToken);
await botClient.AnswerCallbackQuery(
callback.Id,
"Telegram не привязан.",
cancellationToken: cancellationToken
);
return;
}
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);
if (callback.Message is null)
@@ -160,15 +277,23 @@ public sealed class PnvBotUpdateHandler(
if (!unlinkResult.IsSuccess)
{
await botClient.EditMessageText(
chatId.Value, callback.Message.Id, $"❌ Ошибка: {unlinkResult.Error.Message}",
replyMarkup: BackToMenuKeyboard(), cancellationToken: cancellationToken);
chatId.Value,
callback.Message.Id,
$"❌ Ошибка: {unlinkResult.Error.Message}",
replyMarkup: BackToMenuKeyboard(),
cancellationToken: cancellationToken
);
return;
}
var (unlinkedText, unlinkedKeyboard) = BuildMainMenu(isLinked: false);
await botClient.EditMessageText(
chatId.Value, callback.Message.Id, "✅ Telegram отвязан от аккаунта.\n\n" + unlinkedText,
replyMarkup: unlinkedKeyboard, cancellationToken: cancellationToken);
chatId.Value,
callback.Message.Id,
"✅ Telegram отвязан от аккаунта.\n\n" + unlinkedText,
replyMarkup: unlinkedKeyboard,
cancellationToken: cancellationToken
);
return;
}
@@ -183,11 +308,22 @@ public sealed class PnvBotUpdateHandler(
{
case "login":
{
var result = parts[1] == "approve"
? await sender.Send(new ApproveTelegramLoginCommand(requestId, fromId), cancellationToken)
: await sender.Send(new RejectTelegramLoginCommand(requestId, fromId), cancellationToken);
var result =
parts[1] == "approve"
? 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)
{
var statusText = result.IsSuccess
@@ -195,7 +331,12 @@ public sealed class PnvBotUpdateHandler(
: $"⚠️ {result.Error.Message}";
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
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;
@@ -204,26 +345,50 @@ public sealed class PnvBotUpdateHandler(
{
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
{
await botClient.AnswerCallbackQuery(callback.Id, "Недостаточно прав.", cancellationToken: cancellationToken);
await botClient.AnswerCallbackQuery(
callback.Id,
"Недостаточно прав.",
cancellationToken: cancellationToken
);
return;
}
var result = parts[1] == "approve"
? await sender.Send(new ApproveActivationCommand(requestId), cancellationToken)
: await sender.Send(new RejectActivationCommand(requestId, Reason: null), cancellationToken);
var result =
parts[1] == "approve"
? 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)
{
// Редактируем исходное сообщение с запросом вместо отдельного — иначе кнопки
// «Активировать/Отклонить» остаются висеть под уже обработанным запросом (в т.ч.
// если его обработали в другом месте — на сайте или из другого чата).
var statusText = result.IsSuccess
? (parts[1] == "approve" ? "✅ Пользователь активирован." : "❌ Запрос отклонён.")
? (
parts[1] == "approve"
? "✅ Пользователь активирован."
: "❌ Запрос отклонён."
)
: $"⚠️ {result.Error.Message}";
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
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;
@@ -232,23 +397,47 @@ public sealed class PnvBotUpdateHandler(
{
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
{
await botClient.AnswerCallbackQuery(callback.Id, "Недостаточно прав.", cancellationToken: cancellationToken);
await botClient.AnswerCallbackQuery(
callback.Id,
"Недостаточно прав.",
cancellationToken: cancellationToken
);
return;
}
var result = parts[1] == "approve"
? await sender.Send(new ApproveRoleRequestCommand(requestId), cancellationToken)
: await sender.Send(new RejectRoleRequestCommand(requestId, Reason: null), cancellationToken);
var result =
parts[1] == "approve"
? 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)
{
var statusText = result.IsSuccess
? (parts[1] == "approve" ? "✅ Заявка одобрена, роль выдана." : "❌ Заявка отклонена.")
? (
parts[1] == "approve"
? "✅ Заявка одобрена, роль выдана."
: "❌ Заявка отклонена."
)
: $"⚠️ {result.Error.Message}";
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
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;
@@ -257,16 +446,30 @@ public sealed class PnvBotUpdateHandler(
{
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
{
await botClient.AnswerCallbackQuery(callback.Id, "Telegram не привязан.", cancellationToken: cancellationToken);
await botClient.AnswerCallbackQuery(
callback.Id,
"Telegram не привязан.",
cancellationToken: cancellationToken
);
return;
}
var linkResult = await sender.Send(new GetConfigLinkQuery(requestId), cancellationToken);
await botClient.AnswerCallbackQuery(callback.Id, cancellationToken: cancellationToken);
var linkResult = await sender.Send(
new GetConfigLinkQuery(requestId),
cancellationToken
);
await botClient.AnswerCallbackQuery(
callback.Id,
cancellationToken: cancellationToken
);
if (!linkResult.IsSuccess)
{
await botClient.SendMessage(chatId.Value, $"Не удалось получить ссылку: {linkResult.Error.Message}", cancellationToken: cancellationToken);
await botClient.SendMessage(
chatId.Value,
$"Не удалось получить ссылку: {linkResult.Error.Message}",
cancellationToken: cancellationToken
);
return;
}
@@ -276,15 +479,22 @@ public sealed class PnvBotUpdateHandler(
// Редактируем то же сообщение (не плодим отдельное с сырым URL) — ссылка моноширинным
// блоком, по нему в 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 ?? [])
.Where(row => row.All(b => b.CallbackData != data))
.ToArray();
await botClient.EditMessageText(
chatId.Value, callback.Message.Id, text, parseMode: ParseMode.Html,
replyMarkup: remainingRows.Length > 0 ? new InlineKeyboardMarkup(remainingRows) : null,
cancellationToken: cancellationToken);
chatId.Value,
callback.Message.Id,
text,
parseMode: ParseMode.Html,
replyMarkup: remainingRows.Length > 0
? new InlineKeyboardMarkup(remainingRows)
: null,
cancellationToken: cancellationToken
);
break;
}
@@ -292,11 +502,20 @@ public sealed class PnvBotUpdateHandler(
}
private async Task HandleLinkAsync(
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, string? username, string token,
CancellationToken cancellationToken)
ITelegramBotClient botClient,
IServiceProvider services,
long chatId,
long fromId,
string? username,
string token,
CancellationToken cancellationToken
)
{
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
? "✅ Telegram успешно привязан к вашему аккаунту."
@@ -306,56 +525,103 @@ public sealed class PnvBotUpdateHandler(
}
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))
{
await botClient.SendMessage(chatId, "Некорректная ссылка входа.", cancellationToken: cancellationToken);
await botClient.SendMessage(
chatId,
"Некорректная ссылка входа.",
cancellationToken: cancellationToken
);
return;
}
var identityService = services.GetRequiredService<IIdentityService>();
var userId = await identityService.FindUserIdByTelegramUserIdAsync(fromId, cancellationToken);
var userId = await identityService.FindUserIdByTelegramUserIdAsync(
fromId,
cancellationToken
);
if (userId is null)
{
await botClient.SendMessage(
chatId, NotLinkedMessage,
replyMarkup: new InlineKeyboardMarkup(new[] { InlineKeyboardButton.WithCallbackData("📝 Зарегистрироваться", "reg:new") }),
cancellationToken: cancellationToken);
chatId,
NotLinkedMessage,
replyMarkup: new InlineKeyboardMarkup(
new[]
{
InlineKeyboardButton.WithCallbackData("📝 Зарегистрироваться", "reg:new"),
}
),
cancellationToken: cancellationToken
);
return;
}
var keyboard = new InlineKeyboardMarkup(new[]
{
InlineKeyboardButton.WithCallbackData("✅ Подтвердить вход", $"login:approve:{requestId}"),
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"login:reject:{requestId}"),
});
var keyboard = new InlineKeyboardMarkup(
new[]
{
InlineKeyboardButton.WithCallbackData(
"✅ Подтвердить вход",
$"login:approve:{requestId}"
),
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"login:reject:{requestId}"),
}
);
await botClient.SendMessage(
chatId, "Кто-то пытается войти в PnvPanel через ваш аккаунт. Подтвердить вход?",
replyMarkup: keyboard, cancellationToken: cancellationToken);
chatId,
"Кто-то пытается войти в PnvPanel через ваш аккаунт. Подтвердить вход?",
replyMarkup: keyboard,
cancellationToken: cancellationToken
);
}
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))
{
await botClient.SendMessage(
chatId, NotLinkedMessage,
replyMarkup: new InlineKeyboardMarkup(new[] { InlineKeyboardButton.WithCallbackData("📝 Зарегистрироваться", "reg:new") }),
cancellationToken: cancellationToken);
chatId,
NotLinkedMessage,
replyMarkup: new InlineKeyboardMarkup(
new[]
{
InlineKeyboardButton.WithCallbackData("📝 Зарегистрироваться", "reg:new"),
}
),
cancellationToken: cancellationToken
);
return;
}
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>
private static async Task<(string Text, InlineKeyboardMarkup Keyboard)> BuildConfigsMenuAsync(
IServiceProvider services, CancellationToken cancellationToken)
IServiceProvider services,
CancellationToken cancellationToken
)
{
var sender = services.GetRequiredService<ISender>();
var result = await sender.Send(new GetMyConfigsQuery(), cancellationToken);
@@ -363,29 +629,53 @@ public sealed class PnvBotUpdateHandler(
if (!result.IsSuccess || result.Value.Configs.Count == 0)
return ("У вас пока нет конфигов.", BackToMenuKeyboard());
var text = "Ваши конфиги:\n" + string.Join('\n', result.Value.Configs.Select(c =>
$"• {c.Label ?? c.Location} ({c.Protocol}) — {c.Status}"));
var text =
"Ваши конфиги:\n"
+ string.Join(
'\n',
result.Value.Configs.Select(c =>
$"• {c.Label ?? c.Location} ({c.Protocol}) — {c.Status}"
)
);
// Отозванному конфигу нечего показывать — кнопку не даём.
var rows = result.Value.Configs
.Where(c => c.Status != ConfigStatus.Revoked)
.Select(c => new[] { InlineKeyboardButton.WithCallbackData($"🔗 {c.Label ?? c.Location}", $"cfg:link:{c.Id}") })
var rows = result
.Value.Configs.Where(c => c.Status != ConfigStatus.Revoked)
.Select(c =>
new[]
{
InlineKeyboardButton.WithCallbackData(
$"🔗 {c.Label ?? c.Location}",
$"cfg:link:{c.Id}"
),
}
)
.Append(BackToMenuRow())
.ToArray();
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 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))
{
await botClient.SendMessage(chatId, "Telegram не привязан.", cancellationToken: cancellationToken);
await botClient.SendMessage(
chatId,
"Telegram не привязан.",
cancellationToken: cancellationToken
);
return;
}
@@ -393,105 +683,191 @@ public sealed class PnvBotUpdateHandler(
var result = await sender.Send(new UnlinkTelegramCommand(), cancellationToken);
await botClient.SendMessage(
chatId, result.IsSuccess ? "Telegram отвязан от аккаунта." : $"Ошибка: {result.Error.Message}",
cancellationToken: cancellationToken);
chatId,
result.IsSuccess ? "Telegram отвязан от аккаунта." : $"Ошибка: {result.Error.Message}",
cancellationToken: cancellationToken
);
}
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))
{
await botClient.SendMessage(chatId, "Недостаточно прав.", cancellationToken: cancellationToken);
await botClient.SendMessage(
chatId,
"Недостаточно прав.",
cancellationToken: cancellationToken
);
return;
}
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)
{
await botClient.SendMessage(chatId, "Нет ожидающих запросов на активацию.", cancellationToken: cancellationToken);
await botClient.SendMessage(
chatId,
"Нет ожидающих запросов на активацию.",
cancellationToken: cancellationToken
);
return;
}
foreach (var item in result.Value.Items)
{
var text = $"Запрос от <b>{item.UserName}</b>" + (string.IsNullOrWhiteSpace(item.Comment) ? "" : $"\n{item.Comment}");
var keyboard = new InlineKeyboardMarkup(new[]
{
InlineKeyboardButton.WithCallbackData("✅ Активировать", $"act:approve:{item.Id}"),
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"act:reject:{item.Id}"),
});
await botClient.SendMessage(chatId, text, parseMode: ParseMode.Html, replyMarkup: keyboard, cancellationToken: cancellationToken);
var text =
$"Запрос от <b>{item.UserName}</b>"
+ (string.IsNullOrWhiteSpace(item.Comment) ? "" : $"\n{item.Comment}");
var keyboard = new InlineKeyboardMarkup(
new[]
{
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(
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 userId = await identityService.FindUserIdByTelegramUserIdAsync(fromId, cancellationToken);
var userId = await identityService.FindUserIdByTelegramUserIdAsync(
fromId,
cancellationToken
);
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>Привязанному аккаунту — кнопки-действия вместо текстовых команд; непривязанному —
/// только регистрация (остальное ему всё равно недоступно). Кнопка на сайт — если задан PublicSiteUrl.</summary>
private (string Text, InlineKeyboardMarkup Keyboard) BuildMainMenu(bool isLinked)
{
const string text = "Привет! Это бот PnvPanel.\n\n"
const string text =
"Привет! Это бот PnvPanel.\n\n"
+ "Вход без пароля запускается кнопкой «Войти через Telegram» на сайте — бот пришлёт запрос на подтверждение.";
var rows = new List<InlineKeyboardButton[]>();
if (isLinked)
{
rows.Add(new[] { InlineKeyboardButton.WithCallbackData("📋 Мои конфиги", "menu:configs") });
rows.Add(new[] { InlineKeyboardButton.WithCallbackData("🔓 Отвязать Telegram", "menu:unlink") });
rows.Add(
new[] { InlineKeyboardButton.WithCallbackData("📋 Мои конфиги", "menu:configs") }
);
rows.Add(
new[]
{
InlineKeyboardButton.WithCallbackData("🔓 Отвязать Telegram", "menu:unlink"),
}
);
}
else
{
rows.Add(new[] { InlineKeyboardButton.WithCallbackData("📝 Зарегистрироваться", "reg:new") });
rows.Add(
new[] { InlineKeyboardButton.WithCallbackData("📝 Зарегистрироваться", "reg:new") }
);
}
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));
}
private static async Task HandleRegisterCallbackAsync(
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, string? username, string callbackId,
CancellationToken cancellationToken)
ITelegramBotClient botClient,
IServiceProvider services,
long chatId,
long fromId,
string? username,
string callbackId,
CancellationToken cancellationToken
)
{
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);
if (!result.IsSuccess)
{
await botClient.SendMessage(chatId, $"Не удалось зарегистрироваться: {result.Error.Message}", cancellationToken: cancellationToken);
await botClient.SendMessage(
chatId,
$"Не удалось зарегистрироваться: {result.Error.Message}",
cancellationToken: cancellationToken
);
return;
}
var text = "✅ Аккаунт создан.\n\n"
var text =
"✅ Аккаунт создан.\n\n"
+ $"Логин: <code>{result.Value.UserName}</code>\n"
+ $"Пароль: <code>{result.Value.Password}</code>\n\n"
+ "Сохраните пароль — он присылается только один раз. Логин можно сменить в Настройках на сайте.\n\n"
+ "Дальше нужно дождаться активации администратором — после неё будут доступны конфиги. "
+ "Входить можно как по паролю, так и кнопкой «Войти через 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 userId = await identityService.FindUserIdByTelegramUserIdAsync(telegramUserId, cancellationToken);
var userId = await identityService.FindUserIdByTelegramUserIdAsync(
telegramUserId,
cancellationToken
);
if (userId is null)
return false;
@@ -503,7 +879,11 @@ public sealed class PnvBotUpdateHandler(
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))
return false;
@@ -13,9 +13,11 @@ namespace PnvPanel.Api.Telegram;
/// вызывает те же CQRS-команды, что и веб, через собственный ISender.
/// </summary>
public sealed class TelegramBotHostedService(
ITelegramBotClient botClient, PnvBotUpdateHandler updateHandler, IOptions<TelegramOptions> options,
ILogger<TelegramBotHostedService> logger)
: BackgroundService
ITelegramBotClient botClient,
PnvBotUpdateHandler updateHandler,
IOptions<TelegramOptions> options,
ILogger<TelegramBotHostedService> logger
) : BackgroundService
{
private static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(30);
@@ -6,7 +6,10 @@ using Telegram.Bot;
namespace PnvPanel.Api.Telegram;
/// <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 string? _cachedUsername;
@@ -8,30 +8,49 @@ using Telegram.Bot.Types.ReplyMarkups;
namespace PnvPanel.Api.Telegram;
internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentityService identityService, IOptions<TelegramOptions> options)
: ITelegramNotifier
internal sealed class TelegramNotifier(
ITelegramBotClient botClient,
IIdentityService identityService,
IOptions<TelegramOptions> options
) : ITelegramNotifier
{
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))
return;
var text = $"🆕 Запрос на активацию от <b>{Escape(userName)}</b>"
+ (string.IsNullOrWhiteSpace(comment) ? string.Empty : $"\nКомментарий: {Escape(comment)}");
var text =
$"🆕 Запрос на активацию от <b>{Escape(userName)}</b>"
+ (
string.IsNullOrWhiteSpace(comment)
? string.Empty
: $"\nКомментарий: {Escape(comment)}"
);
var keyboard = new InlineKeyboardMarkup(new[]
{
InlineKeyboardButton.WithCallbackData("✅ Активировать", $"act:approve:{requestId}"),
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"act:reject:{requestId}"),
});
var keyboard = new InlineKeyboardMarkup(
new[]
{
InlineKeyboardButton.WithCallbackData("✅ Активировать", $"act:approve:{requestId}"),
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"act:reject:{requestId}"),
}
);
foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
{
try
{
await botClient.SendMessage(
adminId, text, parseMode: ParseMode.Html, replyMarkup: keyboard, cancellationToken: cancellationToken);
adminId,
text,
parseMode: ParseMode.Html,
replyMarkup: keyboard,
cancellationToken: cancellationToken
);
}
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))
return;
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;
if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl))
{
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())
@@ -61,7 +88,12 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
try
{
await botClient.SendMessage(
adminId, text, parseMode: ParseMode.Html, replyMarkup: keyboard, cancellationToken: cancellationToken);
adminId,
text,
parseMode: ParseMode.Html,
replyMarkup: keyboard,
cancellationToken: cancellationToken
);
}
catch
{
@@ -71,25 +103,38 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
}
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))
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[]
{
InlineKeyboardButton.WithCallbackData("✅ Одобрить", $"rrq:approve:{ticketId}"),
InlineKeyboardButton.WithCallbackData(" Отклонить", $"rrq:reject:{ticketId}"),
});
var keyboard = new InlineKeyboardMarkup(
new[]
{
InlineKeyboardButton.WithCallbackData(" Одобрить", $"rrq:approve:{ticketId}"),
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"rrq:reject:{ticketId}"),
}
);
foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
{
try
{
await botClient.SendMessage(
adminId, text, parseMode: ParseMode.Html, replyMarkup: keyboard, cancellationToken: cancellationToken);
adminId,
text,
parseMode: ParseMode.Html,
replyMarkup: keyboard,
cancellationToken: cancellationToken
);
}
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))
return;
@@ -110,7 +160,9 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl))
{
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())
@@ -118,7 +170,12 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
try
{
await botClient.SendMessage(
adminId, text, parseMode: ParseMode.Html, replyMarkup: keyboard, cancellationToken: cancellationToken);
adminId,
text,
parseMode: ParseMode.Html,
replyMarkup: keyboard,
cancellationToken: cancellationToken
);
}
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))
return;
@@ -138,16 +198,25 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl))
{
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)
{
try
{
await botClient.SendMessage(
telegramUserId, text, parseMode: ParseMode.Html, replyMarkup: keyboard, cancellationToken: cancellationToken);
telegramUserId,
text,
parseMode: ParseMode.Html,
replyMarkup: keyboard,
cancellationToken: cancellationToken
);
}
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))
return;
@@ -167,11 +240,24 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
InlineKeyboardMarkup? keyboard = null;
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
{
await botClient.SendMessage(telegramUserId, message, replyMarkup: keyboard, cancellationToken: cancellationToken);
await botClient.SendMessage(
telegramUserId,
message,
replyMarkup: keyboard,
cancellationToken: cancellationToken
);
}
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 readonly Error AlreadyPending =
Error.Conflict("Activation.AlreadyPending", "У вас уже есть необработанный запрос на активацию.");
public static readonly Error AlreadyPending = Error.Conflict(
"Activation.AlreadyPending",
"У вас уже есть необработанный запрос на активацию."
);
public static readonly Error NotFound =
Error.NotFound("Activation.NotFound", "Запрос на активацию не найден.");
public static readonly Error NotFound = Error.NotFound(
"Activation.NotFound",
"Запрос на активацию не найден."
);
public static readonly Error AlreadyDecided =
Error.Conflict("Activation.AlreadyDecided", "Запрос на активацию уже обработан.");
public static readonly Error AlreadyDecided = Error.Conflict(
"Activation.AlreadyDecided",
"Запрос на активацию уже обработан."
);
}
@@ -7,10 +7,16 @@ using PnvPanel.Domain.Activation;
namespace PnvPanel.Application.Activation;
public sealed class GetActivationStatusQueryHandler(IIdentityService identityService, IAppDbContext dbContext, ICurrentUser currentUser)
: IQueryHandler<GetActivationStatusQuery, Result<ActivationStatusDto>>
public sealed class GetActivationStatusQueryHandler(
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)
return Result.Failure<ActivationStatusDto>(AuthErrors.Unauthorized);
@@ -19,8 +25,10 @@ public sealed class GetActivationStatusQueryHandler(IIdentityService identitySer
if (profile is null)
return Result.Failure<ActivationStatusDto>(AuthErrors.Unauthorized);
var pending = await dbContext.ActivationRequests
.Where(r => r.UserId == userId && r.Status == ActivationStatus.Pending)
var pending = await dbContext
.ActivationRequests.Where(r =>
r.UserId == userId && r.Status == ActivationStatus.Pending
)
.Select(r => new ActivationRequestDto(r.Id, r.Comment, r.CreatedAt))
.FirstOrDefaultAsync(cancellationToken);
@@ -3,4 +3,5 @@ using PnvPanel.Application.Common.Models;
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;
public sealed class RequestActivationCommandHandler(
IAppDbContext dbContext, IRealtimeNotifier notifier, ITelegramNotifier telegramNotifier, ICurrentUser currentUser)
: ICommandHandler<RequestActivationCommand, Result<ActivationRequestDto>>
IAppDbContext dbContext,
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)
return Result.Failure<ActivationRequestDto>(AuthErrors.Unauthorized);
var hasPending = await dbContext.ActivationRequests
.AnyAsync(r => r.UserId == userId && r.Status == ActivationStatus.Pending, cancellationToken);
var hasPending = await dbContext.ActivationRequests.AnyAsync(
r => r.UserId == userId && r.Status == ActivationStatus.Pending,
cancellationToken
);
if (hasPending)
return Result.Failure<ActivationRequestDto>(ActivationErrors.AlreadyPending);
@@ -27,9 +35,23 @@ public sealed class RequestActivationCommandHandler(
var userName = currentUser.UserName ?? userId.ToString();
await notifier.NotifyActivationRequestedAsync(request.Id, userId, userName, request.Comment, request.CreatedAt, cancellationToken);
await telegramNotifier.NotifyAdminsActivationRequestedAsync(request.Id, userName, request.Comment, cancellationToken);
await notifier.NotifyActivationRequestedAsync(
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? Comment,
ActivationStatus Status,
DateTimeOffset CreatedAt);
DateTimeOffset CreatedAt
);
@@ -10,17 +10,25 @@ using PnvPanel.Domain.Audit;
namespace PnvPanel.Application.Admin.Activation;
public sealed class ApproveActivationCommandHandler(
IAppDbContext dbContext, IIdentityService identityService, IRealtimeNotifier notifier,
ITelegramNotifier telegramNotifier, ICurrentUser currentUser)
: ICommandHandler<ApproveActivationCommand, Result>
IAppDbContext dbContext,
IIdentityService identityService,
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)
return Result.Failure(AuthErrors.Unauthorized);
var request = await dbContext.ActivationRequests
.FirstOrDefaultAsync(r => r.Id == command.RequestId, cancellationToken);
var request = await dbContext.ActivationRequests.FirstOrDefaultAsync(
r => r.Id == command.RequestId,
cancellationToken
);
if (request is null)
return Result.Failure(ActivationErrors.NotFound);
@@ -30,15 +38,31 @@ public sealed class ApproveActivationCommandHandler(
request.Approve(adminId);
var activateResult = await identityService.ActivateUserAsync(request.UserId, adminId, cancellationToken);
var activateResult = await identityService.ActivateUserAsync(
request.UserId,
adminId,
cancellationToken
);
if (!activateResult.IsSuccess)
return activateResult;
dbContext.AuditLogs.Add(AuditLog.Create(
adminId, "ActivationApproved", "User", request.UserId.ToString(), metadata: null, AuditSource.Web));
dbContext.AuditLogs.Add(
AuditLog.Create(
adminId,
"ActivationApproved",
"User",
request.UserId.ToString(),
metadata: null,
AuditSource.Web
)
);
await notifier.NotifyUserActivatedAsync(request.UserId, cancellationToken);
await telegramNotifier.NotifyUserAsync(request.UserId, "✅ Ваш аккаунт активирован администратором.", cancellationToken);
await telegramNotifier.NotifyUserAsync(
request.UserId,
"✅ Ваш аккаунт активирован администратором.",
cancellationToken
);
return Result.Success();
}
}
@@ -4,5 +4,8 @@ using PnvPanel.Domain.Activation;
namespace PnvPanel.Application.Admin.Activation;
public sealed record ListActivationRequestsQuery(ActivationStatus? StatusFilter, int Page, int PageSize)
: IQuery<Result<PagedList<ActivationRequestAdminDto>>>;
public sealed record ListActivationRequestsQuery(
ActivationStatus? StatusFilter,
int Page,
int PageSize
) : IQuery<Result<PagedList<ActivationRequestAdminDto>>>;
@@ -5,10 +5,15 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Activation;
public sealed class ListActivationRequestsQueryHandler(IAppDbContext dbContext, IIdentityService identityService)
: IQueryHandler<ListActivationRequestsQuery, Result<PagedList<ActivationRequestAdminDto>>>
public sealed class ListActivationRequestsQueryHandler(
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 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(
page1.Items.Select(r => r.UserId).Distinct().ToList(),
cancellationToken);
cancellationToken
);
var items = page1.Items
.Select(r => new ActivationRequestAdminDto(
r.Id, r.UserId, userNames.GetValueOrDefault(r.UserId, "?"), r.Comment, r.Status, r.CreatedAt))
var items = page1
.Items.Select(r => new ActivationRequestAdminDto(
r.Id,
r.UserId,
userNames.GetValueOrDefault(r.UserId, "?"),
r.Comment,
r.Status,
r.CreatedAt
))
.ToList();
return Result.Success(new PagedList<ActivationRequestAdminDto>(items, page1.Total, page1.Page, page1.PageSize));
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;
public sealed class RejectActivationCommandHandler(IAppDbContext dbContext, ICurrentUser currentUser)
: ICommandHandler<RejectActivationCommand, Result>
public sealed class RejectActivationCommandHandler(
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)
return Result.Failure(AuthErrors.Unauthorized);
var request = await dbContext.ActivationRequests
.FirstOrDefaultAsync(r => r.Id == command.RequestId, cancellationToken);
var request = await dbContext.ActivationRequests.FirstOrDefaultAsync(
r => r.Id == command.RequestId,
cancellationToken
);
if (request is null)
return Result.Failure(ActivationErrors.NotFound);
@@ -28,8 +35,16 @@ public sealed class RejectActivationCommandHandler(IAppDbContext dbContext, ICur
request.Reject(adminId, command.Reason);
dbContext.AuditLogs.Add(AuditLog.Create(
adminId, "ActivationRejected", "User", request.UserId.ToString(), metadata: null, AuditSource.Web));
dbContext.AuditLogs.Add(
AuditLog.Create(
adminId,
"ActivationRejected",
"User",
request.UserId.ToString(),
metadata: null,
AuditSource.Web
)
);
return Result.Success();
}
@@ -3,10 +3,25 @@ using PnvPanel.Domain.Apps;
namespace PnvPanel.Application.Admin.Apps;
public sealed record AdminAppDto(
Guid Id, string Name, string DownloadUrl, OsPlatform OperatingSystem, string? Description,
string? IconUrl, int SortOrder, bool IsEnabled)
Guid Id,
string Name,
string DownloadUrl,
OsPlatform OperatingSystem,
string? Description,
string? IconUrl,
int SortOrder,
bool IsEnabled
)
{
public static AdminAppDto FromDomain(ClientApp app) => new(
app.Id, app.Name, app.DownloadUrl.ToString(), app.OperatingSystem, app.Description,
app.IconUrl, app.SortOrder, app.IsEnabled);
public static AdminAppDto FromDomain(ClientApp app) =>
new(
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 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;
public sealed record CreateAppCommand(
string Name, string DownloadUrl, OsPlatform OperatingSystem, string? Description, string? IconUrl, int SortOrder)
: ICommand<Result<AdminAppDto>>;
string Name,
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;
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(
command.Name, new Uri(command.DownloadUrl, UriKind.Absolute), command.OperatingSystem,
command.Description, command.IconUrl, command.SortOrder);
command.Name,
new Uri(command.DownloadUrl, UriKind.Absolute),
command.OperatingSystem,
command.Description,
command.IconUrl,
command.SortOrder
);
dbContext.ClientApps.Add(app);
@@ -5,11 +5,15 @@ using PnvPanel.Application.Common.Models;
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)
{
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)
return Result.Failure(AppErrors.NotFound);
@@ -5,14 +5,22 @@ using PnvPanel.Application.Common.Models;
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()
.OrderBy(a => a.OperatingSystem).ThenBy(a => a.SortOrder)
var apps = await dbContext
.ClientApps.AsNoTracking()
.OrderBy(a => a.OperatingSystem)
.ThenBy(a => a.SortOrder)
.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;
public sealed record UpdateAppCommand(
Guid AppId, string Name, string DownloadUrl, OsPlatform OperatingSystem, string? Description,
string? IconUrl, int SortOrder, bool IsEnabled)
: ICommand<Result<AdminAppDto>>;
Guid AppId,
string Name,
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;
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)
return Result.Failure<AdminAppDto>(AppErrors.NotFound);
app.Update(
command.Name, new Uri(command.DownloadUrl, UriKind.Absolute), command.OperatingSystem,
command.Description, command.IconUrl, command.SortOrder, command.IsEnabled);
command.Name,
new Uri(command.DownloadUrl, UriKind.Absolute),
command.OperatingSystem,
command.Description,
command.IconUrl,
command.SortOrder,
command.IsEnabled
);
return Result.Success(AdminAppDto.FromDomain(app));
}
@@ -4,8 +4,16 @@ using PnvPanel.Domain.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(
long Id, Guid? ActorId, string Action, string TargetType, string TargetId, string? Metadata,
AuditSource Source, DateTimeOffset CreatedAt);
long Id,
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;
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 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)
.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);
return Result.Success(result);
@@ -6,6 +6,17 @@ namespace PnvPanel.Application.Admin.Configs;
/// <summary>Строка глобального списка конфигов для админа — в отличие от VpnConfigDto (self-service)
/// содержит владельца и ноду, т.к. список не скоупится одним пользователем.</summary>
public sealed record AdminVpnConfigDto(
Guid Id, Guid UserId, string UserName, string? Label, string ClientEmail, VpnProtocol Protocol,
string Location, string NodeName, long UsedUpBytes, long UsedDownBytes, DateTimeOffset? ExpiresAt,
ConfigStatus Status, DateTimeOffset CreatedAt);
Guid Id,
Guid UserId,
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 — это то, по чему админ сверяет
/// конфиг с записью в 3x-ui, а не по владельцу (для поиска по пользователю есть /admin/users).</summary>
public sealed record ListAllConfigsQuery(int Page, int PageSize, string? Search, ConfigStatus? Status)
: IQuery<Result<PagedList<AdminVpnConfigDto>>>;
public sealed record ListAllConfigsQuery(
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;
public sealed class ListAllConfigsQueryHandler(IAppDbContext dbContext, IIdentityService identityService)
: IQueryHandler<ListAllConfigsQuery, Result<PagedList<AdminVpnConfigDto>>>
public sealed class ListAllConfigsQueryHandler(
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 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))
{
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
@@ -29,30 +36,56 @@ public sealed class ListAllConfigsQueryHandler(IAppDbContext dbContext, IIdentit
.ToPagedListAsync(page, pageSize, cancellationToken);
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))
.ToListAsync(cancellationToken))
.ToDictionary(i => i.Id);
.ToListAsync(cancellationToken)
).ToDictionary(i => i.Id);
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))
.ToListAsync(cancellationToken))
.ToDictionary(n => n.Id);
.ToListAsync(cancellationToken)
).ToDictionary(n => n.Id);
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 inbound = inbounds.GetValueOrDefault(c.InboundId);
var node = inbound is null ? null : nodes.GetValueOrDefault(inbound.NodeId);
return new AdminVpnConfigDto(
c.Id, c.UserId, 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();
var items = pageResult
.Items.Select(c =>
{
var inbound = inbounds.GetValueOrDefault(c.InboundId);
var node = inbound is null ? null : nodes.GetValueOrDefault(inbound.NodeId);
return new AdminVpnConfigDto(
c.Id,
c.UserId,
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;
public sealed record InboundDto(
Guid Id, Guid NodeId, string RemoteInboundId, VpnProtocol Protocol, string Remark, int Port,
bool IsPublished, string? DisplayName, int? MaxClients, IReadOnlyList<Guid> AllowedRoleIds,
DateTimeOffset? LastSyncAt)
Guid Id,
Guid NodeId,
string RemoteInboundId,
VpnProtocol Protocol,
string Remark,
int Port,
bool IsPublished,
string? DisplayName,
int? MaxClients,
IReadOnlyList<Guid> AllowedRoleIds,
DateTimeOffset? LastSyncAt
)
{
public static InboundDto FromDomain(Inbound inbound) => new(
inbound.Id, inbound.NodeId, inbound.RemoteInboundId, inbound.Protocol, inbound.Remark, inbound.Port,
inbound.IsPublished, inbound.DisplayName, inbound.MaxClients, inbound.AllowedRoleIds, inbound.LastSyncAt);
public static InboundDto FromDomain(Inbound inbound) =>
new(
inbound.Id,
inbound.NodeId,
inbound.RemoteInboundId,
inbound.Protocol,
inbound.Remark,
inbound.Port,
inbound.IsPublished,
inbound.DisplayName,
inbound.MaxClients,
inbound.AllowedRoleIds,
inbound.LastSyncAt
);
}
@@ -4,5 +4,8 @@ namespace PnvPanel.Application.Admin.Inbounds;
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;
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();
if (query.NodeId is { } nodeId)
inboundsQuery = inboundsQuery.Where(i => i.NodeId == nodeId);
var inbounds = await inboundsQuery.OrderBy(i => i.Remark).ToListAsync(cancellationToken);
return Result.Success<IReadOnlyList<InboundDto>>(inbounds.Select(InboundDto.FromDomain).ToList());
return Result.Success<IReadOnlyList<InboundDto>>(
inbounds.Select(InboundDto.FromDomain).ToList()
);
}
}
@@ -4,5 +4,9 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Inbounds;
public sealed record PublishInboundCommand(
Guid InboundId, bool IsPublished, string? DisplayName, IReadOnlyList<Guid> AllowedRoleIds, int? MaxClients)
: ICommand<Result<InboundDto>>;
Guid InboundId,
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)
: 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)
return Result.Failure<InboundDto>(InboundErrors.NotFound);
@@ -20,9 +26,16 @@ public sealed class PublishInboundCommandHandler(IAppDbContext dbContext, ICurre
else
inbound.Unpublish();
dbContext.AuditLogs.Add(AuditLog.Create(
currentUser.UserId, command.IsPublished ? "InboundPublished" : "InboundUnpublished",
"Inbound", inbound.Id.ToString(), metadata: null, AuditSource.Web));
dbContext.AuditLogs.Add(
AuditLog.Create(
currentUser.UserId,
command.IsPublished ? "InboundPublished" : "InboundUnpublished",
"Inbound",
inbound.Id.ToString(),
metadata: null,
AuditSource.Web
)
);
return Result.Success(InboundDto.FromDomain(inbound));
}
@@ -6,15 +6,26 @@ using PnvPanel.Domain.News;
namespace PnvPanel.Application.Admin.News;
public sealed class CreatePostCommandHandler(IAppDbContext dbContext, IRealtimeNotifier notifier, ITelegramNotifier telegramNotifier)
: ICommandHandler<CreatePostCommand, Result<NewsPostDto>>
public sealed class CreatePostCommandHandler(
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);
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);
return Result.Success(NewsPostDto.FromDomain(post));
@@ -5,11 +5,15 @@ using PnvPanel.Application.Common.Models;
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)
{
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)
return Result.Failure(NewsErrors.NotFound);
@@ -4,4 +4,5 @@ using PnvPanel.Application.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;
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 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)
.Select(p => new NewsPostDto(p.Id, p.Title, p.Body, p.CreatedAt, p.UpdatedAt))
.ToPagedListAsync(page, pageSize, cancellationToken);
@@ -4,4 +4,5 @@ using PnvPanel.Application.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;
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)
return Result.Failure<NewsPostDto>(NewsErrors.NotFound);
@@ -6,22 +6,38 @@ using PnvPanel.Domain.Audit;
namespace PnvPanel.Application.Admin.Nodes;
public sealed class DeleteNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway, ICurrentUser currentUser)
: ICommandHandler<DeleteNodeCommand, Result>
public sealed class DeleteNodeCommandHandler(
IAppDbContext dbContext,
IXuiPanelGateway gateway,
ICurrentUser currentUser
) : ICommandHandler<DeleteNodeCommand, Result>
{
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)
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.Nodes.Remove(node);
gateway.InvalidateClient(node.Id);
dbContext.AuditLogs.Add(AuditLog.Create(
currentUser.UserId, "NodeDeleted", "Node", node.Id.ToString(), metadata: null, AuditSource.Web));
dbContext.AuditLogs.Add(
AuditLog.Create(
currentUser.UserId,
"NodeDeleted",
"Node",
node.Id.ToString(),
metadata: null,
AuditSource.Web
)
);
return Result.Success();
}
@@ -5,11 +5,18 @@ using PnvPanel.Application.Common.Models;
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());
}
}
@@ -4,10 +4,25 @@ namespace PnvPanel.Application.Admin.Nodes;
/// <summary>Админский DTO ноды. Пароль никогда не попадает в ответ API.</summary>
public sealed record NodeDto(
Guid Id, string Name, string BaseAddress, string Username, string? Location,
NodeStatus Status, bool IsEnabled, DateTimeOffset? LastSyncAt)
Guid Id,
string Name,
string BaseAddress,
string Username,
string? Location,
NodeStatus Status,
bool IsEnabled,
DateTimeOffset? LastSyncAt
)
{
public static NodeDto FromDomain(Node node) => new(
node.Id, node.Name, node.BaseAddress.ToString(), node.Credentials.Username, node.Location,
node.Status, node.IsEnabled, node.LastSyncAt);
public static NodeDto FromDomain(Node node) =>
new(
node.Id,
node.Name,
node.BaseAddress.ToString(),
node.Credentials.Username,
node.Location,
node.Status,
node.IsEnabled,
node.LastSyncAt
);
}
@@ -5,5 +5,8 @@ namespace PnvPanel.Application.Admin.Nodes;
public static class NodeErrors
{
public static readonly Error NotFound = Error.NotFound("Nodes.NotFound", "Нода не найдена.");
public static readonly Error InvalidBaseAddress = Error.Validation("Nodes.InvalidBaseAddress", "Некорректный адрес панели.");
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)
: 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)
return Result.Failure<NodeProbeResultDto>(NodeErrors.NotFound);
var probe = await gateway.ProbeAsync(node, cancellationToken);
node.UpdateStatus(probe.IsReachable ? NodeStatus.Online : NodeStatus.Offline);
return Result.Success(new NodeProbeResultDto(probe.IsReachable, probe.ErrorMessage, node.Status));
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;
public sealed record RegisterNodeCommand(string Name, string BaseAddress, string Username, string Password, string? Location)
: ICommand<Result<NodeDto>>;
public sealed record RegisterNodeCommand(
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;
public sealed class RegisterNodeCommandHandler(
IAppDbContext dbContext, IXuiPanelGateway gateway, ISecretProtector secretProtector, ICurrentUser currentUser)
: ICommandHandler<RegisterNodeCommand, Result<NodeDto>>
IAppDbContext dbContext,
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))
return Task.FromResult(Result.Failure<NodeDto>(NodeErrors.InvalidBaseAddress));
@@ -19,12 +25,23 @@ public sealed class RegisterNodeCommandHandler(
if (!validation.IsSuccess)
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);
dbContext.Nodes.Add(node);
dbContext.AuditLogs.Add(AuditLog.Create(
currentUser.UserId, "NodeRegistered", "Node", node.Id.ToString(), metadata: null, AuditSource.Web));
dbContext.AuditLogs.Add(
AuditLog.Create(
currentUser.UserId,
"NodeRegistered",
"Node",
node.Id.ToString(),
metadata: null,
AuditSource.Web
)
);
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)
: 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)
return Result.Failure<SyncNodeResultDto>(NodeErrors.NotFound);
@@ -23,7 +29,9 @@ public sealed class SyncNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGat
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);
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))
inbound.UpdateFromRemote(remote.Protocol, remote.Remark, remote.Port);
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, пропавший на панели, снимаем с публикации (не удаляем — реконсиляция дрейфа,
// см. architecture.md); новые конфиги на нём создать будет нельзя, старые не трогаем.
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();
node.UpdateStatus(NodeStatus.Online);
@@ -4,5 +4,10 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Nodes;
public sealed record UpdateNodeCommand(
Guid NodeId, string Name, string? Location, bool IsEnabled, string? Username, string? Password)
: ICommand<Result<NodeDto>>;
Guid NodeId,
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;
public sealed class UpdateNodeCommandHandler(
IAppDbContext dbContext, IXuiPanelGateway gateway, ISecretProtector secretProtector, ICurrentUser currentUser)
: ICommandHandler<UpdateNodeCommand, Result<NodeDto>>
IAppDbContext dbContext,
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)
return Result.Failure<NodeDto>(NodeErrors.NotFound);
@@ -24,14 +33,27 @@ public sealed class UpdateNodeCommandHandler(
else
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);
}
dbContext.AuditLogs.Add(AuditLog.Create(
currentUser.UserId, "NodeUpdated", "Node", node.Id.ToString(), metadata: null, AuditSource.Web));
dbContext.AuditLogs.Add(
AuditLog.Create(
currentUser.UserId,
"NodeUpdated",
"Node",
node.Id.ToString(),
metadata: null,
AuditSource.Web
)
);
return Result.Success(NodeDto.FromDomain(node));
}
@@ -4,4 +4,5 @@ using PnvPanel.Application.Common.Models;
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;
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)
=> roleService.CreateRoleAsync(command.Name, command.MaxConfigs, command.MaxIpLimit, cancellationToken);
public Task<Result<RoleDto>> Handle(
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()
{
RuleFor(x => x.Name)
.NotEmpty()
.Length(2, 32)
.Matches("^[a-zA-Z0-9_-]+$");
RuleFor(x => x.Name).NotEmpty().Length(2, 32).Matches("^[a-zA-Z0-9_-]+$");
RuleFor(x => x.MaxConfigs).GreaterThanOrEqualTo(-1);
RuleFor(x => x.MaxIpLimit).GreaterThanOrEqualTo(-1);
@@ -4,8 +4,9 @@ using PnvPanel.Application.Common.Models;
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)
=> roleService.DeleteRoleAsync(command.RoleId, cancellationToken);
public Task<Result> Handle(DeleteRoleCommand command, CancellationToken cancellationToken) =>
roleService.DeleteRoleAsync(command.RoleId, cancellationToken);
}
@@ -7,6 +7,8 @@ namespace PnvPanel.Application.Admin.Roles;
public sealed class ListRolesQueryHandler(IRoleService roleService)
: IQueryHandler<ListRolesQuery, Result<IReadOnlyList<RoleDto>>>
{
public async Task<Result<IReadOnlyList<RoleDto>>> Handle(ListRolesQuery query, CancellationToken cancellationToken)
=> Result.Success(await roleService.ListRolesAsync(cancellationToken));
public async Task<Result<IReadOnlyList<RoleDto>>> Handle(
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 readonly Error NotFound = Error.NotFound("Roles.NotFound", "Роль не найдена.");
public static readonly Error DuplicateName = Error.Conflict("Roles.DuplicateName", "Роль с таким именем уже существует.");
public static readonly Error CannotModifySystemRole = Error.Forbidden("Roles.CannotModifySystemRole", "Системную роль нельзя удалить.");
public static readonly Error RoleInUse = Error.Conflict("Roles.RoleInUse", "Роль назначена пользователям — сначала переназначьте их.");
public static readonly Error DuplicateName = Error.Conflict(
"Roles.DuplicateName",
"Роль с таким именем уже существует."
);
public static readonly Error CannotModifySystemRole = Error.Forbidden(
"Roles.CannotModifySystemRole",
"Системную роль нельзя удалить."
);
public static readonly Error RoleInUse = Error.Conflict(
"Roles.RoleInUse",
"Роль назначена пользователям — сначала переназначьте их."
);
}
@@ -4,4 +4,5 @@ using PnvPanel.Application.Common.Models;
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;
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)
=> roleService.UpdateRoleAsync(command.RoleId, command.MaxConfigs, command.MaxIpLimit, cancellationToken);
public Task<Result<RoleDto>> Handle(
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 StatsDto(
int TotalUsers, int ActivatedUsers, int PendingActivationRequests,
int TotalNodes, int OnlineNodes, int TotalConfigs, int ActiveConfigs,
long TotalUsedUpBytes, long TotalUsedDownBytes);
int TotalUsers,
int ActivatedUsers,
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)
: 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 pendingActivations = await dbContext.ActivationRequests
.CountAsync(r => r.Status == ActivationStatus.Pending, cancellationToken);
var pendingActivations = await dbContext.ActivationRequests.CountAsync(
r => r.Status == ActivationStatus.Pending,
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 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
.GroupBy(_ => 1)
var trafficTotals = await dbContext
.VpnConfigs.GroupBy(_ => 1)
.Select(g => new { Up = g.Sum(c => c.UsedUpBytes), Down = g.Sum(c => c.UsedDownBytes) })
.FirstOrDefaultAsync(cancellationToken);
return Result.Success(new StatsDto(
userStats.Total, userStats.Activated, pendingActivations,
totalNodes, onlineNodes, totalConfigs, activeConfigs,
trafficTotals?.Up ?? 0, trafficTotals?.Down ?? 0));
return Result.Success(
new StatsDto(
userStats.Total,
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;
public sealed class ApproveRoleRequestCommandHandler(
IAppDbContext dbContext, IRoleService roleService, IRealtimeNotifier notifier,
ITelegramNotifier telegramNotifier, ICurrentUser currentUser)
: ICommandHandler<ApproveRoleRequestCommand, Result>
IAppDbContext dbContext,
IRoleService roleService,
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)
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)
return Result.Failure(SupportErrors.NotFound);
@@ -37,24 +46,44 @@ public sealed class ApproveRoleRequestCommandHandler(
else
{
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)
return Result.Failure(createResult.Error);
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)
return assignResult;
ticket.Resolve();
dbContext.AuditLogs.Add(AuditLog.Create(
adminId, "RoleRequestApproved", "SupportTicket", ticket.Id.ToString(), metadata: null, AuditSource.Web));
dbContext.AuditLogs.Add(
AuditLog.Create(
adminId,
"RoleRequestApproved",
"SupportTicket",
ticket.Id.ToString(),
metadata: null,
AuditSource.Web
)
);
await notifier.NotifyTicketUpdatedAsync(ticket.Id, ticket.UserId, cancellationToken);
await telegramNotifier.NotifyUserAsync(ticket.UserId, "✅ Ваша заявка на роль одобрена.", cancellationToken);
await telegramNotifier.NotifyUserAsync(
ticket.UserId,
"✅ Ваша заявка на роль одобрена.",
cancellationToken
);
return Result.Success();
}
@@ -10,15 +10,24 @@ using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Admin.Support;
public sealed class CloseTicketCommandHandler(
IAppDbContext dbContext, IRealtimeNotifier notifier, ITelegramNotifier telegramNotifier, ICurrentUser currentUser)
: ICommandHandler<CloseTicketCommand, Result>
IAppDbContext dbContext,
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)
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)
return Result.Failure(SupportErrors.NotFound);
@@ -27,11 +36,23 @@ public sealed class CloseTicketCommandHandler(
ticket.Close();
dbContext.AuditLogs.Add(AuditLog.Create(
adminId, "TicketClosed", "SupportTicket", ticket.Id.ToString(), metadata: null, AuditSource.Web));
dbContext.AuditLogs.Add(
AuditLog.Create(
adminId,
"TicketClosed",
"SupportTicket",
ticket.Id.ToString(),
metadata: null,
AuditSource.Web
)
);
await notifier.NotifyTicketUpdatedAsync(ticket.Id, ticket.UserId, cancellationToken);
await telegramNotifier.NotifyUserAsync(ticket.UserId, "🔒 Ваше обращение закрыто.", cancellationToken);
await telegramNotifier.NotifyUserAsync(
ticket.UserId,
"🔒 Ваше обращение закрыто.",
cancellationToken
);
return Result.Success();
}
@@ -6,17 +6,30 @@ using PnvPanel.Application.Support;
namespace PnvPanel.Application.Admin.Support;
public sealed class GetTicketAdminQueryHandler(IAppDbContext dbContext, IIdentityService identityService, IRoleService roleService)
: IQueryHandler<GetTicketAdminQuery, Result<TicketDetailDto>>
public sealed class GetTicketAdminQueryHandler(
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);
if (ticket is null)
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);
}
}
@@ -5,5 +5,9 @@ using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Admin.Support;
public sealed record ListAllTicketsQuery(TicketType? TypeFilter, TicketStatus? StatusFilter, int Page, int PageSize)
: IQuery<Result<PagedList<TicketSummaryDto>>>;
public sealed record ListAllTicketsQuery(
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;
public sealed class ListAllTicketsQueryHandler(IAppDbContext dbContext, IIdentityService identityService)
: IQueryHandler<ListAllTicketsQuery, Result<PagedList<TicketSummaryDto>>>
public sealed class ListAllTicketsQueryHandler(
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 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)
ticketsQuery = ticketsQuery.Where(t => t.Status == status);
var page1 = await ticketsQuery.OrderByDescending(t => t.CreatedAt).ToPagedListAsync(page, pageSize, cancellationToken);
var items = await TicketMapping.ToSummaryDtosAsync(dbContext, identityService, page1.Items, cancellationToken);
var page1 = await ticketsQuery
.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;
public sealed class RejectRoleRequestCommandHandler(
IAppDbContext dbContext, IRealtimeNotifier notifier, ITelegramNotifier telegramNotifier, ICurrentUser currentUser)
: ICommandHandler<RejectRoleRequestCommand, Result>
IAppDbContext dbContext,
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)
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)
return Result.Failure(SupportErrors.NotFound);
@@ -33,11 +42,23 @@ public sealed class RejectRoleRequestCommandHandler(
ticket.Close();
dbContext.AuditLogs.Add(AuditLog.Create(
adminId, "RoleRequestRejected", "SupportTicket", ticket.Id.ToString(), metadata: null, AuditSource.Web));
dbContext.AuditLogs.Add(
AuditLog.Create(
adminId,
"RoleRequestRejected",
"SupportTicket",
ticket.Id.ToString(),
metadata: null,
AuditSource.Web
)
);
await notifier.NotifyTicketUpdatedAsync(ticket.Id, ticket.UserId, cancellationToken);
await telegramNotifier.NotifyUserAsync(ticket.UserId, "❌ Ваша заявка на роль отклонена.", cancellationToken);
await telegramNotifier.NotifyUserAsync(
ticket.UserId,
"❌ Ваша заявка на роль отклонена.",
cancellationToken
);
return Result.Success();
}
@@ -10,15 +10,24 @@ using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Admin.Support;
public sealed class ResolveTicketCommandHandler(
IAppDbContext dbContext, IRealtimeNotifier notifier, ITelegramNotifier telegramNotifier, ICurrentUser currentUser)
: ICommandHandler<ResolveTicketCommand, Result>
IAppDbContext dbContext,
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)
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)
return Result.Failure(SupportErrors.NotFound);
@@ -27,11 +36,23 @@ public sealed class ResolveTicketCommandHandler(
ticket.Resolve();
dbContext.AuditLogs.Add(AuditLog.Create(
adminId, "TicketResolved", "SupportTicket", ticket.Id.ToString(), metadata: null, AuditSource.Web));
dbContext.AuditLogs.Add(
AuditLog.Create(
adminId,
"TicketResolved",
"SupportTicket",
ticket.Id.ToString(),
metadata: null,
AuditSource.Web
)
);
await notifier.NotifyTicketUpdatedAsync(ticket.Id, ticket.UserId, cancellationToken);
await telegramNotifier.NotifyUserAsync(ticket.UserId, "✅ Ваше обращение решено.", cancellationToken);
await telegramNotifier.NotifyUserAsync(
ticket.UserId,
"✅ Ваше обращение решено.",
cancellationToken
);
return Result.Success();
}
@@ -10,10 +10,14 @@ namespace PnvPanel.Application.Admin.Users;
/// <summary>Блокировка гасит все активные конфиги в 3x-ui (см. architecture.md).</summary>
public sealed class BlockUserCommandHandler(
IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway,
IRealtimeNotifier notifier, ITelegramNotifier telegramNotifier, ICurrentUser currentUser,
ILogger<BlockUserCommandHandler> logger)
: ICommandHandler<BlockUserCommand, Result>
IAppDbContext dbContext,
IIdentityService identityService,
IXuiPanelGateway gateway,
IRealtimeNotifier notifier,
ITelegramNotifier telegramNotifier,
ICurrentUser currentUser,
ILogger<BlockUserCommandHandler> logger
) : ICommandHandler<BlockUserCommand, Result>
{
public async Task<Result> Handle(BlockUserCommand command, CancellationToken cancellationToken)
{
@@ -21,22 +25,32 @@ public sealed class BlockUserCommandHandler(
if (!blockResult.IsSuccess)
return blockResult;
var configs = await dbContext.VpnConfigs
.Where(c => c.UserId == command.UserId && c.Status == ConfigStatus.Active)
var configs = await dbContext
.VpnConfigs.Where(c => c.UserId == command.UserId && c.Status == ConfigStatus.Active)
.ToListAsync(cancellationToken);
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
? 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)
{
var updateResult = await gateway.UpdateClientAsync(
node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol,
config.Label ?? config.ClientEmail, enable: false, cancellationToken);
node,
inbound.RemoteInboundId,
config.ClientExternalId,
config.Protocol,
config.Label ?? config.ClientEmail,
enable: false,
cancellationToken
);
if (!updateResult.IsSuccess)
{
@@ -45,19 +59,40 @@ public sealed class BlockUserCommandHandler(
// Конфиг останется Active и будет подхвачен повторным BlockUserCommand (идемпотентен).
logger.LogWarning(
"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;
}
}
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(
currentUser.UserId, "UserBlocked", "User", command.UserId.ToString(), metadata: null, AuditSource.Web));
dbContext.AuditLogs.Add(
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();
}
@@ -5,18 +5,35 @@ using PnvPanel.Domain.Audit;
namespace PnvPanel.Application.Admin.Users;
public sealed class ChangeUserRoleCommandHandler(IRoleService roleService, IAppDbContext dbContext, ICurrentUser currentUser)
: ICommandHandler<ChangeUserRoleCommand, Result>
public sealed class ChangeUserRoleCommandHandler(
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)
return result;
dbContext.AuditLogs.Add(AuditLog.Create(
currentUser.UserId, "UserRoleChanged", "User", command.UserId.ToString(),
metadata: $"{{\"roleId\":\"{command.RoleId}\"}}", AuditSource.Web));
dbContext.AuditLogs.Add(
AuditLog.Create(
currentUser.UserId,
"UserRoleChanged",
"User",
command.UserId.ToString(),
metadata: $"{{\"roleId\":\"{command.RoleId}\"}}",
AuditSource.Web
)
);
return result;
}
@@ -9,40 +9,65 @@ namespace PnvPanel.Application.Admin.Users;
/// <summary>Удаление пользователя админом: отзывает все его конфиги в 3x-ui, затем удаляет учётку.</summary>
public sealed class DeleteUserCommandHandler(
IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway,
ITelegramNotifier telegramNotifier, ICurrentUser currentUser)
: ICommandHandler<DeleteUserCommand, Result>
IAppDbContext dbContext,
IIdentityService identityService,
IXuiPanelGateway gateway,
ITelegramNotifier telegramNotifier,
ICurrentUser currentUser
) : ICommandHandler<DeleteUserCommand, Result>
{
public async Task<Result> Handle(DeleteUserCommand command, CancellationToken cancellationToken)
{
if (currentUser.UserId == command.UserId)
return Result.Failure(UserErrors.CannotDeleteSelf);
var configs = await dbContext.VpnConfigs
.Where(c => c.UserId == command.UserId && c.Status != ConfigStatus.Revoked)
var configs = await dbContext
.VpnConfigs.Where(c => c.UserId == command.UserId && c.Status != ConfigStatus.Revoked)
.ToListAsync(cancellationToken);
foreach (var config in configs)
{
var inbound = await dbContext.Inbounds.AsNoTracking().FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
var inbound = await dbContext
.Inbounds.AsNoTracking()
.FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
var node = inbound is null
? null
: await dbContext.Nodes.AsNoTracking().FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
: await dbContext
.Nodes.AsNoTracking()
.FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
if (inbound is not null && node is not null)
await gateway.RemoveClientAsync(node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, cancellationToken);
await gateway.RemoveClientAsync(
node,
inbound.RemoteInboundId,
config.ClientExternalId,
config.Protocol,
cancellationToken
);
config.Revoke();
}
dbContext.AuditLogs.Add(AuditLog.Create(
currentUser.UserId, "UserDeleted", "User", command.UserId.ToString(), metadata: null, AuditSource.Web));
dbContext.AuditLogs.Add(
AuditLog.Create(
currentUser.UserId,
"UserDeleted",
"User",
command.UserId.ToString(),
metadata: null,
AuditSource.Web
)
);
// Коммитим отзыв конфигов + аудит ДО удаления учётки: UserManager.DeleteAsync ниже удаляет
// AppUser отдельным путём (Identity store), после чего NotifyUserAsync уже не найдёт Telegram-привязку.
await dbContext.SaveChangesAsync(cancellationToken);
await telegramNotifier.NotifyUserAsync(command.UserId, "🗑 Ваш аккаунт удалён администратором.", cancellationToken);
await telegramNotifier.NotifyUserAsync(
command.UserId,
"🗑 Ваш аккаунт удалён администратором.",
cancellationToken
);
return await identityService.DeleteUserAsync(command.UserId, cancellationToken);
}
@@ -9,35 +9,70 @@ using PnvPanel.Domain.Configs;
namespace PnvPanel.Application.Admin.Users;
public sealed class ForceRevokeConfigCommandHandler(
IAppDbContext dbContext, IXuiPanelGateway gateway, IRealtimeNotifier notifier,
ITelegramNotifier telegramNotifier, ICurrentUser currentUser)
: ICommandHandler<ForceRevokeConfigCommand, Result>
IAppDbContext dbContext,
IXuiPanelGateway gateway,
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)
return Result.Failure(ConfigErrors.NotFound);
if (config.Status == ConfigStatus.Revoked)
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
? 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)
await gateway.RemoveClientAsync(node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, cancellationToken);
await gateway.RemoveClientAsync(
node,
inbound.RemoteInboundId,
config.ClientExternalId,
config.Protocol,
cancellationToken
);
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(
currentUser.UserId, "ConfigForceRevoked", "VpnConfig", config.Id.ToString(), metadata: null, AuditSource.Web));
dbContext.AuditLogs.Add(
AuditLog.Create(
currentUser.UserId,
"ConfigForceRevoked",
"VpnConfig",
config.Id.ToString(),
metadata: null,
AuditSource.Web
)
);
await telegramNotifier.NotifyUserAsync(
config.UserId, $"⚠️ Администратор отозвал ваш конфиг «{config.Label ?? config.ClientEmail}».", cancellationToken);
config.UserId,
$"⚠️ Администратор отозвал ваш конфиг «{config.Label ?? config.ClientEmail}».",
cancellationToken
);
return Result.Success();
}
@@ -10,11 +10,20 @@ namespace PnvPanel.Application.Admin.Users;
public sealed class GetUserConfigsQueryHandler(IAppDbContext dbContext)
: 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)
.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)
.ToListAsync(cancellationToken);
@@ -4,4 +4,5 @@ using PnvPanel.Application.Common.Models;
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)
: 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 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);
}
}
@@ -5,17 +5,35 @@ using PnvPanel.Domain.Audit;
namespace PnvPanel.Application.Admin.Users;
public sealed class ResetUserPasswordCommandHandler(IAppDbContext dbContext, IIdentityService identityService, ICurrentUser currentUser)
: ICommandHandler<ResetUserPasswordCommand, Result>
public sealed class ResetUserPasswordCommandHandler(
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)
return result;
dbContext.AuditLogs.Add(AuditLog.Create(
currentUser.UserId, "UserPasswordReset", "User", command.UserId.ToString(), metadata: null, AuditSource.Web));
dbContext.AuditLogs.Add(
AuditLog.Create(
currentUser.UserId,
"UserPasswordReset",
"User",
command.UserId.ToString(),
metadata: null,
AuditSource.Web
)
);
await dbContext.SaveChangesAsync(cancellationToken);
return Result.Success();
@@ -10,32 +10,52 @@ namespace PnvPanel.Application.Admin.Users;
/// <summary>Разблокировка возвращает в 3x-ui только конфиги, погашенные блокировкой (Disabled).</summary>
public sealed class UnblockUserCommandHandler(
IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway,
IRealtimeNotifier notifier, ICurrentUser currentUser, ILogger<UnblockUserCommandHandler> logger)
: ICommandHandler<UnblockUserCommand, Result>
IAppDbContext dbContext,
IIdentityService identityService,
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)
return unblockResult;
var configs = await dbContext.VpnConfigs
.Where(c => c.UserId == command.UserId && c.Status == ConfigStatus.Disabled)
var configs = await dbContext
.VpnConfigs.Where(c => c.UserId == command.UserId && c.Status == ConfigStatus.Disabled)
.ToListAsync(cancellationToken);
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
? 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)
{
var updateResult = await gateway.UpdateClientAsync(
node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol,
config.Label ?? config.ClientEmail, enable: true, cancellationToken);
node,
inbound.RemoteInboundId,
config.ClientExternalId,
config.Protocol,
config.Label ?? config.ClientEmail,
enable: true,
cancellationToken
);
if (!updateResult.IsSuccess)
{
@@ -44,17 +64,34 @@ public sealed class UnblockUserCommandHandler(
// повторным UnblockUserCommand (идемпотентен).
logger.LogWarning(
"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;
}
}
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(
currentUser.UserId, "UserUnblocked", "User", command.UserId.ToString(), metadata: null, AuditSource.Web));
dbContext.AuditLogs.Add(
AuditLog.Create(
currentUser.UserId,
"UserUnblocked",
"User",
command.UserId.ToString(),
metadata: null,
AuditSource.Web
)
);
return Result.Success();
}
@@ -4,8 +4,13 @@ namespace PnvPanel.Application.Admin.Users;
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(
"Users.CannotDeleteSelf", "Нельзя удалить свою учётную запись здесь — используйте удаление аккаунта в Настройках.");
"Users.CannotDeleteSelf",
"Нельзя удалить свою учётную запись здесь — используйте удаление аккаунта в Настройках."
);
}
@@ -2,7 +2,13 @@ using PnvPanel.Domain.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) =>
new(app.Id, app.Name, app.DownloadUrl.ToString(), app.Description, app.IconUrl);
@@ -4,4 +4,6 @@ using PnvPanel.Domain.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;
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(
ListAppsQuery query, CancellationToken cancellationToken)
ListAppsQuery query,
CancellationToken cancellationToken
)
{
var apps = await dbContext.ClientApps.AsNoTracking()
var apps = await dbContext
.ClientApps.AsNoTracking()
.Where(a => a.IsEnabled)
.OrderBy(a => a.SortOrder)
.ToListAsync(cancellationToken);
var grouped = apps
.GroupBy(a => a.OperatingSystem)
var grouped = apps.GroupBy(a => a.OperatingSystem)
.ToDictionary(
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 readonly Error DuplicateUserName =
Error.Conflict("Auth.DuplicateUserName", "Пользователь с таким именем уже существует.");
public static readonly Error DuplicateUserName = Error.Conflict(
"Auth.DuplicateUserName",
"Пользователь с таким именем уже существует."
);
public static readonly Error InvalidCredentials =
Error.Unauthorized("Auth.InvalidCredentials", "Неверное имя пользователя или пароль.");
public static readonly Error InvalidCredentials = Error.Unauthorized(
"Auth.InvalidCredentials",
"Неверное имя пользователя или пароль."
);
public static readonly Error LockedOut =
Error.Unauthorized("Auth.LockedOut", "Слишком много неудачных попыток входа. Попробуйте позже.");
public static readonly Error LockedOut = Error.Unauthorized(
"Auth.LockedOut",
"Слишком много неудачных попыток входа. Попробуйте позже."
);
public static readonly Error InvalidRefreshToken =
Error.Unauthorized("Auth.InvalidRefreshToken", "Недействительный refresh-токен.");
public static readonly Error InvalidRefreshToken = Error.Unauthorized(
"Auth.InvalidRefreshToken",
"Недействительный refresh-токен."
);
public static readonly Error Unauthorized =
Error.Unauthorized("Auth.Unauthorized", "Требуется аутентификация.");
public static readonly Error Unauthorized = Error.Unauthorized(
"Auth.Unauthorized",
"Требуется аутентификация."
);
public static readonly Error UserBlocked =
Error.Forbidden("Auth.UserBlocked", "Аккаунт заблокирован администратором.");
public static readonly Error UserBlocked = Error.Forbidden(
"Auth.UserBlocked",
"Аккаунт заблокирован администратором."
);
public static readonly Error NotActivated =
Error.Forbidden("Auth.NotActivated", "Аккаунт не активирован — обратитесь к администратору.");
public static readonly Error NotActivated = Error.Forbidden(
"Auth.NotActivated",
"Аккаунт не активирован — обратитесь к администратору."
);
}
@@ -5,4 +5,5 @@ public sealed record AuthResult(
DateTimeOffset AccessTokenExpiresAt,
string RefreshToken,
DateTimeOffset RefreshTokenExpiresAt,
CurrentUserDto User);
CurrentUserDto User
);

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