From 1a8d33efa3b76ce81b09b242a9c257cec100c1d9 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Wed, 1 Jul 2026 22:38:01 +0300 Subject: [PATCH] Enhance documentation with new features: added dark/light/system theme support, instructions page, and application catalog. Updated API and domain model for app management and automatic migrations on startup. Improved frontend structure with new routes and features for user instructions and app management. --- .dockerignore | 20 + CLAUDE.md | 9 +- Dockerfile | 37 + backend/Directory.Build.props | 22 + backend/Directory.Packages.props | 29 + backend/PnvPanel.slnx | 8 + .../src/PnvPanel.Api/Common/RateLimiting.cs | 6 + .../PnvPanel.Api/Common/ResultExtensions.cs | 27 + .../Endpoints/ActivationEndpoints.cs | 65 + .../PnvPanel.Api/Endpoints/AppEndpoints.cs | 20 + .../PnvPanel.Api/Endpoints/AuthEndpoints.cs | 116 ++ .../PnvPanel.Api/Endpoints/ConfigEndpoints.cs | 81 ++ .../Endpoints/InboundEndpoints.cs | 38 + .../PnvPanel.Api/Endpoints/NodeEndpoints.cs | 64 + .../PnvPanel.Api/Endpoints/RoleEndpoints.cs | 59 + .../Endpoints/SubscriptionEndpoints.cs | 42 + backend/src/PnvPanel.Api/PnvPanel.Api.csproj | 30 + backend/src/PnvPanel.Api/Program.cs | 83 ++ .../Properties/launchSettings.json | 23 + .../PnvPanel.Api/appsettings.Development.json | 12 + backend/src/PnvPanel.Api/appsettings.json | 32 + backend/src/PnvPanel.Api/wwwroot/index.html | 17 + .../Activation/ActivationErrors.cs | 15 + .../Activation/ActivationRequestDto.cs | 3 + .../Activation/ActivationStatusDto.cs | 3 + .../Activation/GetActivationStatusQuery.cs | 6 + .../GetActivationStatusQueryHandler.cs | 29 + .../Activation/RequestActivationCommand.cs | 6 + .../RequestActivationCommandHandler.cs | 29 + .../RequestActivationCommandValidator.cs | 11 + .../Activation/ActivationRequestAdminDto.cs | 11 + .../Activation/ApproveActivationCommand.cs | 6 + .../ApproveActivationCommandHandler.cs | 32 + .../Activation/ListActivationRequestsQuery.cs | 8 + .../ListActivationRequestsQueryHandler.cs | 35 + .../Activation/RejectActivationCommand.cs | 6 + .../RejectActivationCommandHandler.cs | 31 + .../RejectActivationCommandValidator.cs | 11 + .../Admin/Inbounds/InboundDto.cs | 13 + .../Admin/Inbounds/InboundErrors.cs | 8 + .../Admin/Inbounds/ListInboundsQuery.cs | 6 + .../Inbounds/ListInboundsQueryHandler.cs | 19 + .../Admin/Inbounds/PublishInboundCommand.cs | 8 + .../Inbounds/PublishInboundCommandHandler.cs | 24 + .../PublishInboundCommandValidator.cs | 12 + .../Admin/Nodes/DeleteNodeCommand.cs | 6 + .../Admin/Nodes/DeleteNodeCommandHandler.cs | 24 + .../Admin/Nodes/ListNodesQuery.cs | 6 + .../Admin/Nodes/ListNodesQueryHandler.cs | 15 + .../Admin/Nodes/NodeDto.cs | 13 + .../Admin/Nodes/NodeErrors.cs | 9 + .../Admin/Nodes/ProbeNodeCommand.cs | 9 + .../Admin/Nodes/ProbeNodeCommandHandler.cs | 23 + .../Admin/Nodes/RegisterNodeCommand.cs | 7 + .../Admin/Nodes/RegisterNodeCommandHandler.cs | 27 + .../Nodes/RegisterNodeCommandValidator.cs | 15 + .../Admin/Nodes/SyncNodeCommand.cs | 9 + .../Admin/Nodes/SyncNodeCommandHandler.cs | 48 + .../Admin/Nodes/UpdateNodeCommand.cs | 8 + .../Admin/Nodes/UpdateNodeCommandHandler.cs | 33 + .../Admin/Nodes/UpdateNodeCommandValidator.cs | 13 + .../Admin/Roles/CreateRoleCommand.cs | 7 + .../Admin/Roles/CreateRoleCommandHandler.cs | 11 + .../Admin/Roles/CreateRoleCommandValidator.cs | 16 + .../Admin/Roles/DeleteRoleCommand.cs | 6 + .../Admin/Roles/DeleteRoleCommandHandler.cs | 11 + .../Admin/Roles/ListRolesQuery.cs | 7 + .../Admin/Roles/ListRolesQueryHandler.cs | 12 + .../Admin/Roles/RoleErrors.cs | 11 + .../Admin/Roles/UpdateRoleCommand.cs | 7 + .../Admin/Roles/UpdateRoleCommandHandler.cs | 11 + .../Admin/Roles/UpdateRoleCommandValidator.cs | 11 + .../Admin/Users/ChangeUserRoleCommand.cs | 6 + .../Users/ChangeUserRoleCommandHandler.cs | 11 + .../Admin/Users/UserErrors.cs | 8 + .../PnvPanel.Application/Apps/ClientAppDto.cs | 9 + .../Apps/ListAppsQuery.cs | 7 + .../Apps/ListAppsQueryHandler.cs | 28 + .../PnvPanel.Application/Auth/AuthErrors.cs | 21 + .../PnvPanel.Application/Auth/AuthResult.cs | 8 + .../ChangePassword/ChangePasswordCommand.cs | 6 + .../ChangePasswordCommandHandler.cs | 17 + .../ChangePasswordCommandValidator.cs | 12 + .../Auth/CurrentUserDto.cs | 3 + .../DeleteMyAccount/DeleteMyAccountCommand.cs | 6 + .../DeleteMyAccountCommandHandler.cs | 38 + .../Auth/Login/LoginCommand.cs | 6 + .../Auth/Login/LoginCommandHandler.cs | 35 + .../Auth/Login/LoginCommandValidator.cs | 12 + .../Auth/Logout/LogoutCommand.cs | 6 + .../Auth/Logout/LogoutCommandHandler.cs | 15 + .../Auth/Me/GetCurrentUserQuery.cs | 6 + .../Auth/Me/GetCurrentUserQueryHandler.cs | 21 + .../Auth/Refresh/RefreshCommand.cs | 6 + .../Auth/Refresh/RefreshCommandHandler.cs | 34 + .../Auth/Refresh/RefreshCommandValidator.cs | 11 + .../Auth/Register/RegisterCommand.cs | 8 + .../Auth/Register/RegisterCommandHandler.cs | 18 + .../Auth/Register/RegisterCommandValidator.cs | 19 + .../Common/Behaviors/LoggingBehavior.cs | 20 + .../Common/Behaviors/UnitOfWorkBehavior.cs | 21 + .../Common/Behaviors/ValidationBehavior.cs | 45 + .../Common/Interfaces/IAppDbContext.cs | 27 + .../Common/Interfaces/ICurrentUser.cs | 8 + .../Common/Interfaces/IIdentityService.cs | 33 + .../Common/Interfaces/IJwtTokenService.cs | 6 + .../Common/Interfaces/IRefreshTokenService.cs | 16 + .../Common/Interfaces/IRoleService.cs | 18 + .../Common/Interfaces/ISecretProtector.cs | 9 + .../Common/Interfaces/IXuiPanelGateway.cs | 41 + .../Common/Messaging/ICommand.cs | 4 + .../Common/Messaging/ICommandHandler.cs | 6 + .../Common/Messaging/IPipelineBehavior.cs | 8 + .../Common/Messaging/IQuery.cs | 4 + .../Common/Messaging/IQueryHandler.cs | 6 + .../Common/Messaging/ISender.cs | 8 + .../Common/Messaging/Sender.cs | 32 + .../Common/Models/Error.cs | 23 + .../Common/Models/PagedList.cs | 3 + .../Common/Models/PagedListExtensions.cs | 14 + .../Common/Models/Result.cs | 37 + .../Common/Models/RoleQuota.cs | 6 + .../Configs/ConfigErrors.cs | 24 + .../Configs/Create/CreateVpnConfigCommand.cs | 6 + .../Create/CreateVpnConfigCommandHandler.cs | 92 ++ .../Create/CreateVpnConfigCommandValidator.cs | 13 + .../Configs/Edit/EditVpnConfigCommand.cs | 6 + .../Edit/EditVpnConfigCommandHandler.cs | 45 + .../Edit/EditVpnConfigCommandValidator.cs | 12 + .../GetConfigLink/GetConfigLinkQuery.cs | 9 + .../GetConfigLinkQueryHandler.cs | 38 + .../Configs/GetMyConfigs/GetMyConfigsQuery.cs | 8 + .../GetMyConfigs/GetMyConfigsQueryHandler.cs | 32 + .../ListAvailableInboundsQuery.cs | 10 + .../ListAvailableInboundsQueryHandler.cs | 31 + .../Configs/Revoke/RevokeVpnConfigCommand.cs | 6 + .../Revoke/RevokeVpnConfigCommandHandler.cs | 37 + .../Configs/Rotate/RotateVpnConfigCommand.cs | 6 + .../Rotate/RotateVpnConfigCommandHandler.cs | 53 + .../Configs/VpnConfigDto.cs | 17 + .../DependencyInjection.cs | 44 + .../PnvPanel.Application.csproj | 21 + .../GetConfigSubscriptionQuery.cs | 6 + .../GetConfigSubscriptionQueryHandler.cs | 22 + .../Subscriptions/GetUserSubscriptionQuery.cs | 6 + .../GetUserSubscriptionQueryHandler.cs | 24 + .../Subscriptions/SubscriptionAssembler.cs | 45 + .../Subscriptions/SubscriptionDto.cs | 4 + .../Subscriptions/SubscriptionErrors.cs | 8 + .../Activation/ActivationRequest.cs | 58 + .../Activation/ActivationStatus.cs | 8 + backend/src/PnvPanel.Domain/Apps/ClientApp.cs | 45 + .../src/PnvPanel.Domain/Apps/OsPlatform.cs | 10 + backend/src/PnvPanel.Domain/Common/Entity.cs | 14 + .../PnvPanel.Domain/Configs/ConfigStatus.cs | 10 + .../src/PnvPanel.Domain/Configs/VpnConfig.cs | 89 ++ .../Exceptions/DomainException.cs | 4 + .../src/PnvPanel.Domain/Inbounds/Inbound.cs | 60 + .../PnvPanel.Domain/Inbounds/VpnProtocol.cs | 9 + backend/src/PnvPanel.Domain/Nodes/Node.cs | 58 + .../PnvPanel.Domain/Nodes/NodeCredentials.cs | 10 + .../src/PnvPanel.Domain/Nodes/NodeStatus.cs | 8 + .../PnvPanel.Domain/PnvPanel.Domain.csproj | 9 + .../DependencyInjection.cs | 102 ++ .../Identity/AdminSeedOptions.cs | 9 + .../Identity/AppRole.cs | 21 + .../Identity/AppUser.cs | 17 + .../Identity/CurrentUser.cs | 23 + .../Identity/DbInitializer.cs | 112 ++ .../Identity/DbInitializerExtensions.cs | 13 + .../Identity/IdentityService.cs | 135 ++ .../Identity/JwtOptions.cs | 12 + .../Identity/JwtTokenService.cs | 39 + .../Identity/RefreshToken.cs | 16 + .../Identity/RefreshTokenService.cs | 99 ++ .../Identity/RoleNames.cs | 8 + .../Identity/RoleService.cs | 84 ++ .../Identity/RolesOptions.cs | 8 + .../Persistence/AppDbContext.cs | 37 + .../ActivationRequestConfiguration.cs | 20 + .../Configurations/ClientAppConfiguration.cs | 25 + .../Configurations/InboundConfiguration.cs | 23 + .../Configurations/NodeConfiguration.cs | 30 + .../RefreshTokenConfiguration.cs | 21 + .../Configurations/VpnConfigConfiguration.cs | 25 + .../Persistence/MigrationExtensions.cs | 17 + .../20260701161345_InitialCreate.Designer.cs | 29 + .../20260701161345_InitialCreate.cs | 22 + .../20260701171444_AddIdentity.Designer.cs | 327 +++++ .../Migrations/20260701171444_AddIdentity.cs | 259 ++++ ...01175024_AddActivationRequests.Designer.cs | 365 ++++++ .../20260701175024_AddActivationRequests.cs | 45 + ...0701181642_AddNodesAndInbounds.Designer.cs | 486 +++++++ .../20260701181642_AddNodesAndInbounds.cs | 72 ++ ...260701185508_AddConfigsAndApps.Designer.cs | 601 +++++++++ .../20260701185508_AddConfigsAndApps.cs | 95 ++ .../Migrations/AppDbContextModelSnapshot.cs | 598 +++++++++ .../PnvPanel.Infrastructure.csproj | 26 + .../Security/DataProtectionSecretProtector.cs | 20 + .../Xui/XuiPanelGateway.cs | 197 +++ docker-compose.yml | 33 + docs/architecture.md | 14 +- docs/backend-conventions.md | 1 + docs/domain-model.md | 8 +- docs/frontend.md | 18 +- docs/roadmap.md | 14 +- docs/tech-stack.md | 10 +- docs/vision.md | 4 +- frontend/.gitignore | 24 + frontend/.oxlintrc.json | 8 + frontend/README.md | 32 + frontend/index.html | 13 + frontend/package.json | 31 + frontend/pnpm-lock.yaml | 1147 +++++++++++++++++ frontend/public/favicon.svg | 1 + frontend/public/icons.svg | 24 + frontend/src/App.tsx | 68 + frontend/src/assets/hero.png | Bin 0 -> 13057 bytes frontend/src/assets/react.svg | 1 + frontend/src/assets/vite.svg | 1 + frontend/src/index.css | 46 + frontend/src/lib/i18n.ts | 46 + frontend/src/lib/theme.tsx | 51 + frontend/src/main.tsx | 19 + frontend/tsconfig.app.json | 26 + frontend/tsconfig.json | 7 + frontend/tsconfig.node.json | 23 + frontend/vite.config.ts | 20 + seed/client-apps.json | 182 +++ 229 files changed, 9226 insertions(+), 20 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 backend/Directory.Build.props create mode 100644 backend/Directory.Packages.props create mode 100644 backend/PnvPanel.slnx create mode 100644 backend/src/PnvPanel.Api/Common/RateLimiting.cs create mode 100644 backend/src/PnvPanel.Api/Common/ResultExtensions.cs create mode 100644 backend/src/PnvPanel.Api/Endpoints/ActivationEndpoints.cs create mode 100644 backend/src/PnvPanel.Api/Endpoints/AppEndpoints.cs create mode 100644 backend/src/PnvPanel.Api/Endpoints/AuthEndpoints.cs create mode 100644 backend/src/PnvPanel.Api/Endpoints/ConfigEndpoints.cs create mode 100644 backend/src/PnvPanel.Api/Endpoints/InboundEndpoints.cs create mode 100644 backend/src/PnvPanel.Api/Endpoints/NodeEndpoints.cs create mode 100644 backend/src/PnvPanel.Api/Endpoints/RoleEndpoints.cs create mode 100644 backend/src/PnvPanel.Api/Endpoints/SubscriptionEndpoints.cs create mode 100644 backend/src/PnvPanel.Api/PnvPanel.Api.csproj create mode 100644 backend/src/PnvPanel.Api/Program.cs create mode 100644 backend/src/PnvPanel.Api/Properties/launchSettings.json create mode 100644 backend/src/PnvPanel.Api/appsettings.Development.json create mode 100644 backend/src/PnvPanel.Api/appsettings.json create mode 100644 backend/src/PnvPanel.Api/wwwroot/index.html create mode 100644 backend/src/PnvPanel.Application/Activation/ActivationErrors.cs create mode 100644 backend/src/PnvPanel.Application/Activation/ActivationRequestDto.cs create mode 100644 backend/src/PnvPanel.Application/Activation/ActivationStatusDto.cs create mode 100644 backend/src/PnvPanel.Application/Activation/GetActivationStatusQuery.cs create mode 100644 backend/src/PnvPanel.Application/Activation/GetActivationStatusQueryHandler.cs create mode 100644 backend/src/PnvPanel.Application/Activation/RequestActivationCommand.cs create mode 100644 backend/src/PnvPanel.Application/Activation/RequestActivationCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Activation/RequestActivationCommandValidator.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Activation/ActivationRequestAdminDto.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Activation/ApproveActivationCommand.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Activation/ApproveActivationCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Activation/ListActivationRequestsQuery.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Activation/ListActivationRequestsQueryHandler.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Activation/RejectActivationCommand.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Activation/RejectActivationCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Activation/RejectActivationCommandValidator.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Inbounds/InboundDto.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Inbounds/InboundErrors.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Inbounds/ListInboundsQuery.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Inbounds/ListInboundsQueryHandler.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommand.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommandValidator.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Nodes/DeleteNodeCommand.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Nodes/DeleteNodeCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Nodes/ListNodesQuery.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Nodes/ListNodesQueryHandler.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Nodes/NodeDto.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Nodes/NodeErrors.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Nodes/ProbeNodeCommand.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Nodes/ProbeNodeCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Nodes/RegisterNodeCommand.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Nodes/RegisterNodeCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Nodes/RegisterNodeCommandValidator.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Nodes/SyncNodeCommand.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Nodes/SyncNodeCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommand.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommandValidator.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommand.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandValidator.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Roles/DeleteRoleCommand.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Roles/DeleteRoleCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Roles/ListRolesQuery.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Roles/ListRolesQueryHandler.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Roles/RoleErrors.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommand.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandValidator.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Users/ChangeUserRoleCommand.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Users/ChangeUserRoleCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Users/UserErrors.cs create mode 100644 backend/src/PnvPanel.Application/Apps/ClientAppDto.cs create mode 100644 backend/src/PnvPanel.Application/Apps/ListAppsQuery.cs create mode 100644 backend/src/PnvPanel.Application/Apps/ListAppsQueryHandler.cs create mode 100644 backend/src/PnvPanel.Application/Auth/AuthErrors.cs create mode 100644 backend/src/PnvPanel.Application/Auth/AuthResult.cs create mode 100644 backend/src/PnvPanel.Application/Auth/ChangePassword/ChangePasswordCommand.cs create mode 100644 backend/src/PnvPanel.Application/Auth/ChangePassword/ChangePasswordCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Auth/ChangePassword/ChangePasswordCommandValidator.cs create mode 100644 backend/src/PnvPanel.Application/Auth/CurrentUserDto.cs create mode 100644 backend/src/PnvPanel.Application/Auth/DeleteMyAccount/DeleteMyAccountCommand.cs create mode 100644 backend/src/PnvPanel.Application/Auth/DeleteMyAccount/DeleteMyAccountCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Auth/Login/LoginCommand.cs create mode 100644 backend/src/PnvPanel.Application/Auth/Login/LoginCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Auth/Login/LoginCommandValidator.cs create mode 100644 backend/src/PnvPanel.Application/Auth/Logout/LogoutCommand.cs create mode 100644 backend/src/PnvPanel.Application/Auth/Logout/LogoutCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Auth/Me/GetCurrentUserQuery.cs create mode 100644 backend/src/PnvPanel.Application/Auth/Me/GetCurrentUserQueryHandler.cs create mode 100644 backend/src/PnvPanel.Application/Auth/Refresh/RefreshCommand.cs create mode 100644 backend/src/PnvPanel.Application/Auth/Refresh/RefreshCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Auth/Refresh/RefreshCommandValidator.cs create mode 100644 backend/src/PnvPanel.Application/Auth/Register/RegisterCommand.cs create mode 100644 backend/src/PnvPanel.Application/Auth/Register/RegisterCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Auth/Register/RegisterCommandValidator.cs create mode 100644 backend/src/PnvPanel.Application/Common/Behaviors/LoggingBehavior.cs create mode 100644 backend/src/PnvPanel.Application/Common/Behaviors/UnitOfWorkBehavior.cs create mode 100644 backend/src/PnvPanel.Application/Common/Behaviors/ValidationBehavior.cs create mode 100644 backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs create mode 100644 backend/src/PnvPanel.Application/Common/Interfaces/ICurrentUser.cs create mode 100644 backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs create mode 100644 backend/src/PnvPanel.Application/Common/Interfaces/IJwtTokenService.cs create mode 100644 backend/src/PnvPanel.Application/Common/Interfaces/IRefreshTokenService.cs create mode 100644 backend/src/PnvPanel.Application/Common/Interfaces/IRoleService.cs create mode 100644 backend/src/PnvPanel.Application/Common/Interfaces/ISecretProtector.cs create mode 100644 backend/src/PnvPanel.Application/Common/Interfaces/IXuiPanelGateway.cs create mode 100644 backend/src/PnvPanel.Application/Common/Messaging/ICommand.cs create mode 100644 backend/src/PnvPanel.Application/Common/Messaging/ICommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Common/Messaging/IPipelineBehavior.cs create mode 100644 backend/src/PnvPanel.Application/Common/Messaging/IQuery.cs create mode 100644 backend/src/PnvPanel.Application/Common/Messaging/IQueryHandler.cs create mode 100644 backend/src/PnvPanel.Application/Common/Messaging/ISender.cs create mode 100644 backend/src/PnvPanel.Application/Common/Messaging/Sender.cs create mode 100644 backend/src/PnvPanel.Application/Common/Models/Error.cs create mode 100644 backend/src/PnvPanel.Application/Common/Models/PagedList.cs create mode 100644 backend/src/PnvPanel.Application/Common/Models/PagedListExtensions.cs create mode 100644 backend/src/PnvPanel.Application/Common/Models/Result.cs create mode 100644 backend/src/PnvPanel.Application/Common/Models/RoleQuota.cs create mode 100644 backend/src/PnvPanel.Application/Configs/ConfigErrors.cs create mode 100644 backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommand.cs create mode 100644 backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandValidator.cs create mode 100644 backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommand.cs create mode 100644 backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommandValidator.cs create mode 100644 backend/src/PnvPanel.Application/Configs/GetConfigLink/GetConfigLinkQuery.cs create mode 100644 backend/src/PnvPanel.Application/Configs/GetConfigLink/GetConfigLinkQueryHandler.cs create mode 100644 backend/src/PnvPanel.Application/Configs/GetMyConfigs/GetMyConfigsQuery.cs create mode 100644 backend/src/PnvPanel.Application/Configs/GetMyConfigs/GetMyConfigsQueryHandler.cs create mode 100644 backend/src/PnvPanel.Application/Configs/ListAvailableInbounds/ListAvailableInboundsQuery.cs create mode 100644 backend/src/PnvPanel.Application/Configs/ListAvailableInbounds/ListAvailableInboundsQueryHandler.cs create mode 100644 backend/src/PnvPanel.Application/Configs/Revoke/RevokeVpnConfigCommand.cs create mode 100644 backend/src/PnvPanel.Application/Configs/Revoke/RevokeVpnConfigCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Configs/Rotate/RotateVpnConfigCommand.cs create mode 100644 backend/src/PnvPanel.Application/Configs/Rotate/RotateVpnConfigCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Configs/VpnConfigDto.cs create mode 100644 backend/src/PnvPanel.Application/DependencyInjection.cs create mode 100644 backend/src/PnvPanel.Application/PnvPanel.Application.csproj create mode 100644 backend/src/PnvPanel.Application/Subscriptions/GetConfigSubscriptionQuery.cs create mode 100644 backend/src/PnvPanel.Application/Subscriptions/GetConfigSubscriptionQueryHandler.cs create mode 100644 backend/src/PnvPanel.Application/Subscriptions/GetUserSubscriptionQuery.cs create mode 100644 backend/src/PnvPanel.Application/Subscriptions/GetUserSubscriptionQueryHandler.cs create mode 100644 backend/src/PnvPanel.Application/Subscriptions/SubscriptionAssembler.cs create mode 100644 backend/src/PnvPanel.Application/Subscriptions/SubscriptionDto.cs create mode 100644 backend/src/PnvPanel.Application/Subscriptions/SubscriptionErrors.cs create mode 100644 backend/src/PnvPanel.Domain/Activation/ActivationRequest.cs create mode 100644 backend/src/PnvPanel.Domain/Activation/ActivationStatus.cs create mode 100644 backend/src/PnvPanel.Domain/Apps/ClientApp.cs create mode 100644 backend/src/PnvPanel.Domain/Apps/OsPlatform.cs create mode 100644 backend/src/PnvPanel.Domain/Common/Entity.cs create mode 100644 backend/src/PnvPanel.Domain/Configs/ConfigStatus.cs create mode 100644 backend/src/PnvPanel.Domain/Configs/VpnConfig.cs create mode 100644 backend/src/PnvPanel.Domain/Exceptions/DomainException.cs create mode 100644 backend/src/PnvPanel.Domain/Inbounds/Inbound.cs create mode 100644 backend/src/PnvPanel.Domain/Inbounds/VpnProtocol.cs create mode 100644 backend/src/PnvPanel.Domain/Nodes/Node.cs create mode 100644 backend/src/PnvPanel.Domain/Nodes/NodeCredentials.cs create mode 100644 backend/src/PnvPanel.Domain/Nodes/NodeStatus.cs create mode 100644 backend/src/PnvPanel.Domain/PnvPanel.Domain.csproj create mode 100644 backend/src/PnvPanel.Infrastructure/DependencyInjection.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Identity/AdminSeedOptions.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Identity/AppRole.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Identity/AppUser.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Identity/CurrentUser.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Identity/DbInitializer.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Identity/DbInitializerExtensions.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Identity/JwtOptions.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Identity/JwtTokenService.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Identity/RefreshToken.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Identity/RefreshTokenService.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Identity/RoleNames.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Identity/RoleService.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Identity/RolesOptions.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Configurations/ActivationRequestConfiguration.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Configurations/ClientAppConfiguration.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Configurations/InboundConfiguration.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Configurations/NodeConfiguration.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Configurations/VpnConfigConfiguration.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/MigrationExtensions.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701161345_InitialCreate.Designer.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701161345_InitialCreate.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701171444_AddIdentity.Designer.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701171444_AddIdentity.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701175024_AddActivationRequests.Designer.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701175024_AddActivationRequests.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701181642_AddNodesAndInbounds.Designer.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701181642_AddNodesAndInbounds.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701185508_AddConfigsAndApps.Designer.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701185508_AddConfigsAndApps.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs create mode 100644 backend/src/PnvPanel.Infrastructure/PnvPanel.Infrastructure.csproj create mode 100644 backend/src/PnvPanel.Infrastructure/Security/DataProtectionSecretProtector.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Xui/XuiPanelGateway.cs create mode 100644 docker-compose.yml create mode 100644 frontend/.gitignore create mode 100644 frontend/.oxlintrc.json create mode 100644 frontend/README.md create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/pnpm-lock.yaml create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/icons.svg create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/assets/hero.png create mode 100644 frontend/src/assets/react.svg create mode 100644 frontend/src/assets/vite.svg create mode 100644 frontend/src/index.css create mode 100644 frontend/src/lib/i18n.ts create mode 100644 frontend/src/lib/theme.tsx create mode 100644 frontend/src/main.tsx create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 seed/client-apps.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8654c63 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +# Backend build artifacts +**/bin/ +**/obj/ + +# Frontend +**/node_modules/ +frontend/dist/ + +# VCS / IDE / env +.git/ +.vs/ +.vscode/ +.idea/ +**/.env +**/.env.* +!**/.env.example + +# Docs & misc (не нужны в образе) +docs/ +*.md diff --git a/CLAUDE.md b/CLAUDE.md index d595c9e..14e1b58 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,11 +88,15 @@ - **Конфиг**: пользователь задаёт метку (`Label`) и лимит устройств (`DeviceLimit` → `limitIp` в 3x-ui, 0=без лимита), может редактировать. - **Самоудаление аккаунта** (`DELETE /api/auth/me`): отзыв всех конфигов + удаление данных, аудит анонимизируется. - **API без версионирования** в MVP (`/api` без `v1`). Подписка отдаёт `Subscription-Userinfo`. +- **Тема**: светлая/тёмная/системная (Tailwind `dark`, выбор в localStorage). +- **Инструкции + приложения**: отдельная страница инструкций; каталог `ClientApp` (админ CRUD: + название/ссылка/ОС/порядок/вкл), пользователю `GET /api/apps` отдаётся сгруппированным по ОС. - **Вход — по `UserName`** (email в системе не используется вовсе; SMTP не нужен). Восстановление пароля: через привязанный Telegram (self-service), без привязки — сброс админом (`ResetUserPasswordCommand`). Пока Telegram не привязан — UI настойчиво предлагает его привязать. - **Сидинг из env**: идемпотентный `DbInitializer` на старте создаёт системные роли и учётку админа - (username/пароль/Telegram id) из переменных окружения. Единый источник примера — [`.env.example`](.env.example); + (username/пароль/Telegram id) из переменных окружения; каталог приложений `ClientApp` (если пуст) — + из [`seed/client-apps.json`](seed/client-apps.json). Единый источник примера env — [`.env.example`](.env.example); при добавлении новой настройки обновляй и его. Секреты (пароль админа, JWT-ключ, BotToken) — только через env/secret-store. - Telegram id админов (`AdminSeed__TelegramUserIds`) авторизуют админ-действия в боте и получают уведомления о запросах активации. @@ -116,6 +120,9 @@ - Multi-stage Dockerfile: node (сборка фронта) → dotnet sdk (publish + копирование в `wwwroot`) → aspnet runtime. - docker-compose: `app` (единый образ) + `db` (PostgreSQL). В dev — Vite-прокси `/api`,`/hubs` на бэк. - Не вводи отдельный nginx-контейнер для статики без явной просьбы — это ломает требование единого контейнера. +- **TLS — внешний** (прокси/шлюз вне compose); `app` отдаёт HTTP + доверяет `X-Forwarded-*` через + `ForwardedHeaders` (иначе Secure-cookie/схема за прокси сломаются). Свой nginx/Caddy не добавляй. +- **Миграции** применяются авто на старте (MVP). **CI** (GitHub Actions) — только build/test, без деплоя. ## Соглашения по коду diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..18c2c0c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# syntax=docker/dockerfile:1 + +# ── Stage 1: сборка фронтенда (Vite → dist) ─────────────────────────────── +FROM node:22-alpine AS frontend +WORKDIR /app/frontend +RUN corepack enable +COPY frontend/package.json frontend/pnpm-lock.yaml ./ +RUN corepack prepare pnpm@11.9.0 --activate && pnpm install --frozen-lockfile +COPY frontend/ ./ +RUN pnpm build + +# ── Stage 2: publish бэкенда, статика фронта в wwwroot ──────────────────── +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS backend +WORKDIR /src +# Сначала манифесты для кэша restore +COPY backend/PnvPanel.sln backend/Directory.Build.props backend/Directory.Packages.props ./backend/ +COPY backend/src/PnvPanel.Domain/PnvPanel.Domain.csproj ./backend/src/PnvPanel.Domain/ +COPY backend/src/PnvPanel.Application/PnvPanel.Application.csproj ./backend/src/PnvPanel.Application/ +COPY backend/src/PnvPanel.Infrastructure/PnvPanel.Infrastructure.csproj ./backend/src/PnvPanel.Infrastructure/ +COPY backend/src/PnvPanel.Api/PnvPanel.Api.csproj ./backend/src/PnvPanel.Api/ +RUN dotnet restore backend/PnvPanel.sln +# Исходники бэкенда +COPY backend/ ./backend/ +# Сид каталога приложений (PnvPanel.Api.csproj ссылается на него через ../../../seed/) +COPY seed/ ./seed/ +# Статика собранного фронта → wwwroot (перекрывает заглушку) +COPY --from=frontend /app/frontend/dist/ ./backend/src/PnvPanel.Api/wwwroot/ +RUN dotnet publish backend/src/PnvPanel.Api/PnvPanel.Api.csproj -c Release -o /app/publish --no-restore + +# ── Stage 3: runtime ────────────────────────────────────────────────────── +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app +ENV ASPNETCORE_ENVIRONMENT=Production \ + ASPNETCORE_HTTP_PORTS=8080 +EXPOSE 8080 +COPY --from=backend /app/publish ./ +ENTRYPOINT ["dotnet", "PnvPanel.Api.dll"] diff --git a/backend/Directory.Build.props b/backend/Directory.Build.props new file mode 100644 index 0000000..cb652c6 --- /dev/null +++ b/backend/Directory.Build.props @@ -0,0 +1,22 @@ + + + + net10.0 + latest + enable + enable + true + true + latest + false + true + + $(NoWarn);CA1711;CA1716;CA1848;CA1873 + + + diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props new file mode 100644 index 0000000..15e774b --- /dev/null +++ b/backend/Directory.Packages.props @@ -0,0 +1,29 @@ + + + true + + + + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + \ No newline at end of file diff --git a/backend/PnvPanel.slnx b/backend/PnvPanel.slnx new file mode 100644 index 0000000..811000e --- /dev/null +++ b/backend/PnvPanel.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/backend/src/PnvPanel.Api/Common/RateLimiting.cs b/backend/src/PnvPanel.Api/Common/RateLimiting.cs new file mode 100644 index 0000000..0edb423 --- /dev/null +++ b/backend/src/PnvPanel.Api/Common/RateLimiting.cs @@ -0,0 +1,6 @@ +namespace PnvPanel.Api.Common; + +public static class RateLimiting +{ + public const string AuthPolicy = "auth"; +} diff --git a/backend/src/PnvPanel.Api/Common/ResultExtensions.cs b/backend/src/PnvPanel.Api/Common/ResultExtensions.cs new file mode 100644 index 0000000..86f780d --- /dev/null +++ b/backend/src/PnvPanel.Api/Common/ResultExtensions.cs @@ -0,0 +1,27 @@ +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Api.Common; + +public static class ResultExtensions +{ + public static IResult ToHttpResult(this Result result) + => result.IsSuccess ? Results.NoContent() : ToProblem(result.Error); + + public static IResult ToHttpResult(this Result result) + => result.IsSuccess ? Results.Ok(result.Value) : ToProblem(result.Error); + + private static IResult ToProblem(Error error) + { + var statusCode = error.Type switch + { + ErrorType.Validation => StatusCodes.Status400BadRequest, + ErrorType.Unauthorized => StatusCodes.Status401Unauthorized, + ErrorType.Forbidden => StatusCodes.Status403Forbidden, + ErrorType.NotFound => StatusCodes.Status404NotFound, + ErrorType.Conflict => StatusCodes.Status409Conflict, + _ => StatusCodes.Status422UnprocessableEntity, + }; + + return Results.Problem(title: error.Code, detail: error.Message, statusCode: statusCode); + } +} diff --git a/backend/src/PnvPanel.Api/Endpoints/ActivationEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/ActivationEndpoints.cs new file mode 100644 index 0000000..9dd5cba --- /dev/null +++ b/backend/src/PnvPanel.Api/Endpoints/ActivationEndpoints.cs @@ -0,0 +1,65 @@ +using PnvPanel.Api.Common; +using PnvPanel.Application.Activation; +using PnvPanel.Application.Admin.Activation; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Domain.Activation; +using PnvPanel.Infrastructure.Identity; + +namespace PnvPanel.Api.Endpoints; + +public static class ActivationEndpoints +{ + public static IEndpointRouteBuilder MapActivationEndpoints(this IEndpointRouteBuilder app) + { + var user = app.MapGroup("/api/activation").WithTags("Activation").RequireAuthorization(); + user.MapGet("/status", GetStatus); + user.MapPost("/request", RequestActivation); + + var admin = app.MapGroup("/api/admin/activation-requests") + .WithTags("Admin.Activation") + .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); + + admin.MapGet("", ListRequests); + admin.MapPost("/{id:guid}/approve", Approve); + admin.MapPost("/{id:guid}/reject", Reject); + + return app; + } + + private static async Task GetStatus(ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new GetActivationStatusQuery(), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task RequestActivation(RequestActivationCommand command, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(command, cancellationToken); + return result.ToHttpResult(); + } + + private static async Task ListRequests( + [AsParameters] ListActivationRequestsRequest request, ISender sender, CancellationToken cancellationToken) + { + var query = new ListActivationRequestsQuery(request.StatusFilter, request.Page, request.PageSize); + var result = await sender.Send(query, cancellationToken); + return result.ToHttpResult(); + } + + private static async Task Approve(Guid id, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new ApproveActivationCommand(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task Reject( + Guid id, RejectActivationBody body, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new RejectActivationCommand(id, body.Reason), cancellationToken); + return result.ToHttpResult(); + } +} + +public sealed record ListActivationRequestsRequest(ActivationStatus? StatusFilter, int Page = 1, int PageSize = 20); + +public sealed record RejectActivationBody(string? Reason); diff --git a/backend/src/PnvPanel.Api/Endpoints/AppEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/AppEndpoints.cs new file mode 100644 index 0000000..668676a --- /dev/null +++ b/backend/src/PnvPanel.Api/Endpoints/AppEndpoints.cs @@ -0,0 +1,20 @@ +using PnvPanel.Api.Common; +using PnvPanel.Application.Apps; +using PnvPanel.Application.Common.Messaging; + +namespace PnvPanel.Api.Endpoints; + +public static class AppEndpoints +{ + public static IEndpointRouteBuilder MapAppEndpoints(this IEndpointRouteBuilder app) + { + app.MapGet("/api/apps", ListApps).WithTags("Apps").RequireAuthorization(); + return app; + } + + private static async Task ListApps(ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new ListAppsQuery(), cancellationToken); + return result.ToHttpResult(); + } +} diff --git a/backend/src/PnvPanel.Api/Endpoints/AuthEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/AuthEndpoints.cs new file mode 100644 index 0000000..cf661d4 --- /dev/null +++ b/backend/src/PnvPanel.Api/Endpoints/AuthEndpoints.cs @@ -0,0 +1,116 @@ +using PnvPanel.Api.Common; +using PnvPanel.Application.Auth; +using PnvPanel.Application.Auth.ChangePassword; +using PnvPanel.Application.Auth.DeleteMyAccount; +using PnvPanel.Application.Auth.Login; +using PnvPanel.Application.Auth.Logout; +using PnvPanel.Application.Auth.Me; +using PnvPanel.Application.Auth.Refresh; +using PnvPanel.Application.Auth.Register; +using PnvPanel.Application.Common.Messaging; + +namespace PnvPanel.Api.Endpoints; + +public static class AuthEndpoints +{ + private const string RefreshCookieName = "pnv_refresh_token"; + + public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/auth") + .WithTags("Auth") + .RequireRateLimiting(RateLimiting.AuthPolicy); + + group.MapPost("/register", Register); + group.MapPost("/login", Login); + group.MapPost("/refresh", Refresh); + group.MapPost("/logout", Logout).RequireAuthorization(); + group.MapPost("/change-password", ChangePassword).RequireAuthorization(); + group.MapGet("/me", Me).RequireAuthorization(); + group.MapDelete("/me", DeleteMe).RequireAuthorization(); + + return app; + } + + private static async Task Register(RegisterCommand command, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(command, cancellationToken); + return result.ToHttpResult(); + } + + private static async Task Login(LoginCommand command, ISender sender, HttpResponse response, CancellationToken cancellationToken) + { + var result = await sender.Send(command, cancellationToken); + if (!result.IsSuccess) + return result.ToHttpResult(); + + SetRefreshCookie(response, result.Value.RefreshToken, result.Value.RefreshTokenExpiresAt); + return Results.Ok(ToLoginResponse(result.Value)); + } + + private static async Task Refresh(HttpRequest request, HttpResponse response, ISender sender, CancellationToken cancellationToken) + { + if (!request.Cookies.TryGetValue(RefreshCookieName, out var rawToken) || string.IsNullOrEmpty(rawToken)) + return Results.Unauthorized(); + + var result = await sender.Send(new RefreshCommand(rawToken), cancellationToken); + if (!result.IsSuccess) + { + response.Cookies.Delete(RefreshCookieName, BuildCookieOptions()); + return result.ToHttpResult(); + } + + SetRefreshCookie(response, result.Value.RefreshToken, result.Value.RefreshTokenExpiresAt); + return Results.Ok(ToLoginResponse(result.Value)); + } + + private static async Task Logout(HttpRequest request, HttpResponse response, ISender sender, CancellationToken cancellationToken) + { + if (request.Cookies.TryGetValue(RefreshCookieName, out var rawToken) && !string.IsNullOrEmpty(rawToken)) + await sender.Send(new LogoutCommand(rawToken), cancellationToken); + + response.Cookies.Delete(RefreshCookieName, BuildCookieOptions()); + return Results.NoContent(); + } + + private static async Task ChangePassword(ChangePasswordCommand command, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(command, cancellationToken); + return result.ToHttpResult(); + } + + private static async Task Me(ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new GetCurrentUserQuery(), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task DeleteMe(HttpResponse response, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new DeleteMyAccountCommand(), cancellationToken); + response.Cookies.Delete(RefreshCookieName, BuildCookieOptions()); + return result.ToHttpResult(); + } + + private static object ToLoginResponse(AuthResult auth) => new + { + accessToken = auth.AccessToken, + expiresAt = auth.AccessTokenExpiresAt, + user = auth.User, + }; + + private static void SetRefreshCookie(HttpResponse response, string rawToken, DateTimeOffset expiresAt) + { + var options = BuildCookieOptions(); + options.Expires = expiresAt; + response.Cookies.Append(RefreshCookieName, rawToken, options); + } + + private static CookieOptions BuildCookieOptions() => new() + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Strict, + Path = "/api/auth", + }; +} diff --git a/backend/src/PnvPanel.Api/Endpoints/ConfigEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/ConfigEndpoints.cs new file mode 100644 index 0000000..9b32f7d --- /dev/null +++ b/backend/src/PnvPanel.Api/Endpoints/ConfigEndpoints.cs @@ -0,0 +1,81 @@ +using PnvPanel.Api.Common; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Configs.Create; +using PnvPanel.Application.Configs.Edit; +using PnvPanel.Application.Configs.GetConfigLink; +using PnvPanel.Application.Configs.GetMyConfigs; +using PnvPanel.Application.Configs.ListAvailableInbounds; +using PnvPanel.Application.Configs.Revoke; +using PnvPanel.Application.Configs.Rotate; + +namespace PnvPanel.Api.Endpoints; + +public static class ConfigEndpoints +{ + public static IEndpointRouteBuilder MapConfigEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api").WithTags("Configs").RequireAuthorization(); + + group.MapGet("/inbounds/available", ListAvailableInbounds); + group.MapGet("/configs", GetMyConfigs); + group.MapPost("/configs", CreateConfig); + group.MapPatch("/configs/{id:guid}", EditConfig); + group.MapPost("/configs/{id:guid}/rotate", RotateConfig); + group.MapDelete("/configs/{id:guid}", RevokeConfig); + group.MapGet("/configs/{id:guid}/link", GetConfigLink); + + return app; + } + + private static async Task ListAvailableInbounds(ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new ListAvailableInboundsQuery(), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task GetMyConfigs(ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new GetMyConfigsQuery(), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task CreateConfig(CreateConfigBody body, ISender sender, CancellationToken cancellationToken) + { + var command = new CreateVpnConfigCommand(body.InboundId, body.Label, body.DeviceLimit); + var result = await sender.Send(command, cancellationToken); + return result.ToHttpResult(); + } + + private static async Task EditConfig(Guid id, EditConfigBody body, ISender sender, CancellationToken cancellationToken) + { + var command = new EditVpnConfigCommand(id, body.Label, body.DeviceLimit); + var result = await sender.Send(command, cancellationToken); + return result.ToHttpResult(); + } + + private static async Task RotateConfig(Guid id, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new RotateVpnConfigCommand(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task RevokeConfig(Guid id, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new RevokeVpnConfigCommand(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task GetConfigLink(Guid id, HttpRequest request, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new GetConfigLinkQuery(id), cancellationToken); + if (!result.IsSuccess) + return result.ToHttpResult(); + + var subscriptionUrl = $"{request.Scheme}://{request.Host}/sub/{result.Value.SubscriptionToken}"; + return Results.Ok(new { connectionString = result.Value.ConnectionString, subscriptionUrl }); + } +} + +public sealed record CreateConfigBody(Guid InboundId, string? Label, int? DeviceLimit); + +public sealed record EditConfigBody(string? Label, int? DeviceLimit); diff --git a/backend/src/PnvPanel.Api/Endpoints/InboundEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/InboundEndpoints.cs new file mode 100644 index 0000000..4b31810 --- /dev/null +++ b/backend/src/PnvPanel.Api/Endpoints/InboundEndpoints.cs @@ -0,0 +1,38 @@ +using PnvPanel.Api.Common; +using PnvPanel.Application.Admin.Inbounds; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Infrastructure.Identity; + +namespace PnvPanel.Api.Endpoints; + +public static class InboundEndpoints +{ + public static IEndpointRouteBuilder MapInboundEndpoints(this IEndpointRouteBuilder app) + { + var admin = app.MapGroup("/api/admin/inbounds") + .WithTags("Admin.Inbounds") + .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); + + admin.MapGet("", ListInbounds); + admin.MapPut("/{id:guid}/publish", PublishInbound); + + return app; + } + + private static async Task ListInbounds(Guid? nodeId, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new ListInboundsQuery(nodeId), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task PublishInbound( + Guid id, PublishInboundBody body, ISender sender, CancellationToken cancellationToken) + { + var command = new PublishInboundCommand( + id, body.IsPublished, body.DisplayName, body.AllowedRoleIds ?? [], body.MaxClients); + var result = await sender.Send(command, cancellationToken); + return result.ToHttpResult(); + } +} + +public sealed record PublishInboundBody(bool IsPublished, string? DisplayName, IReadOnlyList? AllowedRoleIds, int? MaxClients); diff --git a/backend/src/PnvPanel.Api/Endpoints/NodeEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/NodeEndpoints.cs new file mode 100644 index 0000000..714d752 --- /dev/null +++ b/backend/src/PnvPanel.Api/Endpoints/NodeEndpoints.cs @@ -0,0 +1,64 @@ +using PnvPanel.Api.Common; +using PnvPanel.Application.Admin.Nodes; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Infrastructure.Identity; + +namespace PnvPanel.Api.Endpoints; + +public static class NodeEndpoints +{ + public static IEndpointRouteBuilder MapNodeEndpoints(this IEndpointRouteBuilder app) + { + var admin = app.MapGroup("/api/admin/nodes") + .WithTags("Admin.Nodes") + .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); + + admin.MapGet("", ListNodes); + admin.MapPost("", RegisterNode); + admin.MapPut("/{id:guid}", UpdateNode); + admin.MapDelete("/{id:guid}", DeleteNode); + admin.MapPost("/{id:guid}/sync", SyncNode); + admin.MapPost("/{id:guid}/probe", ProbeNode); + + return app; + } + + private static async Task ListNodes(ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new ListNodesQuery(), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task RegisterNode(RegisterNodeCommand command, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(command, cancellationToken); + return result.ToHttpResult(); + } + + private static async Task UpdateNode(Guid id, UpdateNodeBody body, ISender sender, CancellationToken cancellationToken) + { + var command = new UpdateNodeCommand(id, body.Name, body.Location, body.IsEnabled, body.Username, body.Password); + var result = await sender.Send(command, cancellationToken); + return result.ToHttpResult(); + } + + private static async Task DeleteNode(Guid id, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new DeleteNodeCommand(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task SyncNode(Guid id, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new SyncNodeCommand(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task 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); diff --git a/backend/src/PnvPanel.Api/Endpoints/RoleEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/RoleEndpoints.cs new file mode 100644 index 0000000..6778d8b --- /dev/null +++ b/backend/src/PnvPanel.Api/Endpoints/RoleEndpoints.cs @@ -0,0 +1,59 @@ +using PnvPanel.Api.Common; +using PnvPanel.Application.Admin.Roles; +using PnvPanel.Application.Admin.Users; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Infrastructure.Identity; + +namespace PnvPanel.Api.Endpoints; + +public static class RoleEndpoints +{ + public static IEndpointRouteBuilder MapRoleEndpoints(this IEndpointRouteBuilder app) + { + var admin = app.MapGroup("/api/admin") + .WithTags("Admin.Roles") + .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); + + admin.MapGet("/roles", ListRoles); + admin.MapPost("/roles", CreateRole); + admin.MapPut("/roles/{id:guid}", UpdateRole); + admin.MapDelete("/roles/{id:guid}", DeleteRole); + admin.MapPatch("/users/{id:guid}/role", ChangeUserRole); + + return app; + } + + private static async Task ListRoles(ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new ListRolesQuery(), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task CreateRole(CreateRoleCommand command, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(command, cancellationToken); + return result.ToHttpResult(); + } + + private static async Task UpdateRole(Guid id, UpdateRoleBody body, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new UpdateRoleCommand(id, body.MaxConfigs), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task DeleteRole(Guid id, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new DeleteRoleCommand(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task ChangeUserRole(Guid id, ChangeUserRoleBody body, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new ChangeUserRoleCommand(id, body.RoleId), cancellationToken); + return result.ToHttpResult(); + } +} + +public sealed record UpdateRoleBody(int MaxConfigs); + +public sealed record ChangeUserRoleBody(Guid RoleId); diff --git a/backend/src/PnvPanel.Api/Endpoints/SubscriptionEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/SubscriptionEndpoints.cs new file mode 100644 index 0000000..cab366f --- /dev/null +++ b/backend/src/PnvPanel.Api/Endpoints/SubscriptionEndpoints.cs @@ -0,0 +1,42 @@ +using System.Text; +using PnvPanel.Api.Common; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Subscriptions; + +namespace PnvPanel.Api.Endpoints; + +public static class SubscriptionEndpoints +{ + public static IEndpointRouteBuilder MapSubscriptionEndpoints(this IEndpointRouteBuilder app) + { + // Вне /api по дизайну (api-design.md) — публичный эндпоинт для VPN-клиентов. + app.MapGet("/sub/{token}", GetSubscription) + .WithTags("Subscription") + .RequireRateLimiting(RateLimiting.AuthPolicy); + + return app; + } + + private static async Task GetSubscription(string token, HttpResponse response, ISender sender, CancellationToken cancellationToken) + { + // Токен — либо AppUser.SubscriptionToken (агрегированная подписка), либо VpnConfig.SubscriptionToken + // (один конфиг). Пробуем пользовательский токен первым. + var userResult = await sender.Send(new GetUserSubscriptionQuery(token), cancellationToken); + var result = userResult.IsSuccess ? userResult : await sender.Send(new GetConfigSubscriptionQuery(token), cancellationToken); + + if (!result.IsSuccess) + return Results.NotFound(); + + var body = string.Join('\n', result.Value.ConnectionStrings); + var base64Body = Convert.ToBase64String(Encoding.UTF8.GetBytes(body)); + + var total = result.Value.UsedUpBytes + result.Value.UsedDownBytes; + var expire = result.Value.ExpiresAt is { } exp ? exp.ToUnixTimeSeconds().ToString() : "0"; + response.Headers.Append( + "Subscription-Userinfo", + $"upload={result.Value.UsedUpBytes}; download={result.Value.UsedDownBytes}; total={total}; expire={expire}"); + response.Headers.Append("Profile-Update-Interval", "12"); + + return Results.Text(base64Body, "text/plain; charset=utf-8"); + } +} diff --git a/backend/src/PnvPanel.Api/PnvPanel.Api.csproj b/backend/src/PnvPanel.Api/PnvPanel.Api.csproj new file mode 100644 index 0000000..e0fcc93 --- /dev/null +++ b/backend/src/PnvPanel.Api/PnvPanel.Api.csproj @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + net10.0 + enable + enable + + + diff --git a/backend/src/PnvPanel.Api/Program.cs b/backend/src/PnvPanel.Api/Program.cs new file mode 100644 index 0000000..87917b1 --- /dev/null +++ b/backend/src/PnvPanel.Api/Program.cs @@ -0,0 +1,83 @@ +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.RateLimiting; +using PnvPanel.Api.Common; +using PnvPanel.Api.Endpoints; +using PnvPanel.Application; +using PnvPanel.Infrastructure; +using PnvPanel.Infrastructure.Identity; +using PnvPanel.Infrastructure.Persistence; +using Scalar.AspNetCore; +using Serilog; + +var builder = WebApplication.CreateBuilder(args); + +// Структурное логирование (Serilog), конфигурация из appsettings/env. +builder.Services.AddSerilog((services, configuration) => configuration + .ReadFrom.Configuration(builder.Configuration) + .ReadFrom.Services(services) + .Enrich.FromLogContext()); + +// За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера). +builder.Services.Configure(options => +{ + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; + options.KnownIPNetworks.Clear(); + options.KnownProxies.Clear(); +}); + +builder.Services.AddHttpContextAccessor(); +builder.Services.AddApplication(); +builder.Services.AddInfrastructure(builder.Configuration); + +builder.Services.AddRateLimiter(options => +{ + options.AddFixedWindowLimiter(RateLimiting.AuthPolicy, limiterOptions => + { + limiterOptions.PermitLimit = 20; + limiterOptions.Window = TimeSpan.FromMinutes(1); + limiterOptions.QueueLimit = 0; + }); + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; +}); + +builder.Services.AddProblemDetails(); +builder.Services.AddOpenApi(); +builder.Services.AddHealthChecks() + .AddDbContextCheck(); + +var app = builder.Build(); + +// Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте. +await app.Services.ApplyMigrationsAsync(); +await app.Services.SeedDataAsync(); + +app.UseForwardedHeaders(); +app.UseSerilogRequestLogging(); +app.UseExceptionHandler(); + +app.UseRateLimiter(); + +app.UseAuthentication(); +app.UseAuthorization(); + +// OpenAPI-схема (/openapi/v1.json) + современный UI Scalar (/scalar). +app.MapOpenApi(); +app.MapScalarApiReference(); + +app.MapHealthChecks("/health"); + +app.MapAuthEndpoints(); +app.MapActivationEndpoints(); +app.MapRoleEndpoints(); +app.MapNodeEndpoints(); +app.MapInboundEndpoints(); +app.MapConfigEndpoints(); +app.MapSubscriptionEndpoints(); +app.MapAppEndpoints(); + +// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов. +app.UseDefaultFiles(); +app.UseStaticFiles(); +app.MapFallbackToFile("index.html"); + +app.Run(); diff --git a/backend/src/PnvPanel.Api/Properties/launchSettings.json b/backend/src/PnvPanel.Api/Properties/launchSettings.json new file mode 100644 index 0000000..24042ab --- /dev/null +++ b/backend/src/PnvPanel.Api/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5278", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7075;http://localhost:5278", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/backend/src/PnvPanel.Api/appsettings.Development.json b/backend/src/PnvPanel.Api/appsettings.Development.json new file mode 100644 index 0000000..c55eb3e --- /dev/null +++ b/backend/src/PnvPanel.Api/appsettings.Development.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AdminSeed": { + "Username": "admin", + "Password": "Passw0rd!Dev" + } +} diff --git a/backend/src/PnvPanel.Api/appsettings.json b/backend/src/PnvPanel.Api/appsettings.json new file mode 100644 index 0000000..7943560 --- /dev/null +++ b/backend/src/PnvPanel.Api/appsettings.json @@ -0,0 +1,32 @@ +{ + "ConnectionStrings": { + "Default": "Host=localhost;Port=5432;Database=pnvpanel;Username=pnvpanel;Password=pnvpanel" + }, + "Jwt": { + "Issuer": "PnvPanel", + "Audience": "PnvPanel", + "SigningKey": "change-me-min-32-chars-random-secret", + "AccessTokenMinutes": 15, + "RefreshTokenDays": 30 + }, + "AdminSeed": { + "Username": "", + "Password": "" + }, + "Roles": { + "DefaultUserMaxConfigs": 3 + }, + "Serilog": { + "Using": [ "Serilog.Sinks.Console" ], + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + }, + "WriteTo": [ { "Name": "Console" } ], + "Enrich": [ "FromLogContext" ] + }, + "AllowedHosts": "*" +} diff --git a/backend/src/PnvPanel.Api/wwwroot/index.html b/backend/src/PnvPanel.Api/wwwroot/index.html new file mode 100644 index 0000000..fce07a0 --- /dev/null +++ b/backend/src/PnvPanel.Api/wwwroot/index.html @@ -0,0 +1,17 @@ + + + + + + PnvPanel + + +
+

PnvPanel

+

Каркас приложения (M0). Здесь будет собранное SPA (React + Vite).

+

+ API: /scalar · Health: /health +

+
+ + diff --git a/backend/src/PnvPanel.Application/Activation/ActivationErrors.cs b/backend/src/PnvPanel.Application/Activation/ActivationErrors.cs new file mode 100644 index 0000000..eed1d2b --- /dev/null +++ b/backend/src/PnvPanel.Application/Activation/ActivationErrors.cs @@ -0,0 +1,15 @@ +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Activation; + +public static class ActivationErrors +{ + public static readonly Error AlreadyPending = + Error.Conflict("Activation.AlreadyPending", "У вас уже есть необработанный запрос на активацию."); + + public static readonly Error NotFound = + Error.NotFound("Activation.NotFound", "Запрос на активацию не найден."); + + public static readonly Error AlreadyDecided = + Error.Conflict("Activation.AlreadyDecided", "Запрос на активацию уже обработан."); +} diff --git a/backend/src/PnvPanel.Application/Activation/ActivationRequestDto.cs b/backend/src/PnvPanel.Application/Activation/ActivationRequestDto.cs new file mode 100644 index 0000000..b36bf5b --- /dev/null +++ b/backend/src/PnvPanel.Application/Activation/ActivationRequestDto.cs @@ -0,0 +1,3 @@ +namespace PnvPanel.Application.Activation; + +public sealed record ActivationRequestDto(Guid Id, string? Comment, DateTimeOffset CreatedAt); diff --git a/backend/src/PnvPanel.Application/Activation/ActivationStatusDto.cs b/backend/src/PnvPanel.Application/Activation/ActivationStatusDto.cs new file mode 100644 index 0000000..187ed46 --- /dev/null +++ b/backend/src/PnvPanel.Application/Activation/ActivationStatusDto.cs @@ -0,0 +1,3 @@ +namespace PnvPanel.Application.Activation; + +public sealed record ActivationStatusDto(bool IsActivated, ActivationRequestDto? PendingRequest); diff --git a/backend/src/PnvPanel.Application/Activation/GetActivationStatusQuery.cs b/backend/src/PnvPanel.Application/Activation/GetActivationStatusQuery.cs new file mode 100644 index 0000000..2e98c66 --- /dev/null +++ b/backend/src/PnvPanel.Application/Activation/GetActivationStatusQuery.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Activation; + +public sealed record GetActivationStatusQuery : IQuery>; diff --git a/backend/src/PnvPanel.Application/Activation/GetActivationStatusQueryHandler.cs b/backend/src/PnvPanel.Application/Activation/GetActivationStatusQueryHandler.cs new file mode 100644 index 0000000..54d2e05 --- /dev/null +++ b/backend/src/PnvPanel.Application/Activation/GetActivationStatusQueryHandler.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Auth; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Activation; + +namespace PnvPanel.Application.Activation; + +public sealed class GetActivationStatusQueryHandler(IIdentityService identityService, IAppDbContext dbContext, ICurrentUser currentUser) + : IQueryHandler> +{ + public async Task> Handle(GetActivationStatusQuery query, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return Result.Failure(AuthErrors.Unauthorized); + + var profile = await identityService.GetProfileAsync(userId, cancellationToken); + if (profile is null) + return Result.Failure(AuthErrors.Unauthorized); + + var pending = await dbContext.ActivationRequests + .Where(r => r.UserId == userId && r.Status == ActivationStatus.Pending) + .Select(r => new ActivationRequestDto(r.Id, r.Comment, r.CreatedAt)) + .FirstOrDefaultAsync(cancellationToken); + + return Result.Success(new ActivationStatusDto(profile.IsActivated, pending)); + } +} diff --git a/backend/src/PnvPanel.Application/Activation/RequestActivationCommand.cs b/backend/src/PnvPanel.Application/Activation/RequestActivationCommand.cs new file mode 100644 index 0000000..a601190 --- /dev/null +++ b/backend/src/PnvPanel.Application/Activation/RequestActivationCommand.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Activation; + +public sealed record RequestActivationCommand(string? Comment) : ICommand>; diff --git a/backend/src/PnvPanel.Application/Activation/RequestActivationCommandHandler.cs b/backend/src/PnvPanel.Application/Activation/RequestActivationCommandHandler.cs new file mode 100644 index 0000000..ebc960c --- /dev/null +++ b/backend/src/PnvPanel.Application/Activation/RequestActivationCommandHandler.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Auth; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Activation; + +namespace PnvPanel.Application.Activation; + +public sealed class RequestActivationCommandHandler(IAppDbContext dbContext, ICurrentUser currentUser) + : ICommandHandler> +{ + public async Task> Handle(RequestActivationCommand command, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return Result.Failure(AuthErrors.Unauthorized); + + var hasPending = await dbContext.ActivationRequests + .AnyAsync(r => r.UserId == userId && r.Status == ActivationStatus.Pending, cancellationToken); + + if (hasPending) + return Result.Failure(ActivationErrors.AlreadyPending); + + var request = ActivationRequest.Create(userId, command.Comment); + dbContext.ActivationRequests.Add(request); + + return Result.Success(new ActivationRequestDto(request.Id, request.Comment, request.CreatedAt)); + } +} diff --git a/backend/src/PnvPanel.Application/Activation/RequestActivationCommandValidator.cs b/backend/src/PnvPanel.Application/Activation/RequestActivationCommandValidator.cs new file mode 100644 index 0000000..d95ca34 --- /dev/null +++ b/backend/src/PnvPanel.Application/Activation/RequestActivationCommandValidator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace PnvPanel.Application.Activation; + +public sealed class RequestActivationCommandValidator : AbstractValidator +{ + public RequestActivationCommandValidator() + { + RuleFor(x => x.Comment).MaximumLength(500); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Activation/ActivationRequestAdminDto.cs b/backend/src/PnvPanel.Application/Admin/Activation/ActivationRequestAdminDto.cs new file mode 100644 index 0000000..21864f9 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Activation/ActivationRequestAdminDto.cs @@ -0,0 +1,11 @@ +using PnvPanel.Domain.Activation; + +namespace PnvPanel.Application.Admin.Activation; + +public sealed record ActivationRequestAdminDto( + Guid Id, + Guid UserId, + string UserName, + string? Comment, + ActivationStatus Status, + DateTimeOffset CreatedAt); diff --git a/backend/src/PnvPanel.Application/Admin/Activation/ApproveActivationCommand.cs b/backend/src/PnvPanel.Application/Admin/Activation/ApproveActivationCommand.cs new file mode 100644 index 0000000..749f7e3 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Activation/ApproveActivationCommand.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Activation; + +public sealed record ApproveActivationCommand(Guid RequestId) : ICommand; diff --git a/backend/src/PnvPanel.Application/Admin/Activation/ApproveActivationCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Activation/ApproveActivationCommandHandler.cs new file mode 100644 index 0000000..1cbbffd --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Activation/ApproveActivationCommandHandler.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Activation; +using PnvPanel.Application.Auth; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Activation; + +namespace PnvPanel.Application.Admin.Activation; + +public sealed class ApproveActivationCommandHandler(IAppDbContext dbContext, IIdentityService identityService, ICurrentUser currentUser) + : ICommandHandler +{ + public async Task Handle(ApproveActivationCommand command, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } adminId) + return Result.Failure(AuthErrors.Unauthorized); + + var request = await dbContext.ActivationRequests + .FirstOrDefaultAsync(r => r.Id == command.RequestId, cancellationToken); + + if (request is null) + return Result.Failure(ActivationErrors.NotFound); + + if (request.Status != ActivationStatus.Pending) + return Result.Failure(ActivationErrors.AlreadyDecided); + + request.Approve(adminId); + + return await identityService.ActivateUserAsync(request.UserId, adminId, cancellationToken); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Activation/ListActivationRequestsQuery.cs b/backend/src/PnvPanel.Application/Admin/Activation/ListActivationRequestsQuery.cs new file mode 100644 index 0000000..b9f3b67 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Activation/ListActivationRequestsQuery.cs @@ -0,0 +1,8 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Activation; + +namespace PnvPanel.Application.Admin.Activation; + +public sealed record ListActivationRequestsQuery(ActivationStatus? StatusFilter, int Page, int PageSize) + : IQuery>>; diff --git a/backend/src/PnvPanel.Application/Admin/Activation/ListActivationRequestsQueryHandler.cs b/backend/src/PnvPanel.Application/Admin/Activation/ListActivationRequestsQueryHandler.cs new file mode 100644 index 0000000..b72d243 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Activation/ListActivationRequestsQueryHandler.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Activation; + +public sealed class ListActivationRequestsQueryHandler(IAppDbContext dbContext, IIdentityService identityService) + : IQueryHandler>> +{ + public async Task>> Handle(ListActivationRequestsQuery query, CancellationToken cancellationToken) + { + var page = query.Page <= 0 ? 1 : query.Page; + var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize; + + var requestsQuery = dbContext.ActivationRequests.AsNoTracking(); + if (query.StatusFilter is { } status) + requestsQuery = requestsQuery.Where(r => r.Status == status); + + var page1 = await requestsQuery + .OrderBy(r => r.CreatedAt) + .ToPagedListAsync(page, pageSize, cancellationToken); + + var userNames = await identityService.GetUserNamesAsync( + page1.Items.Select(r => r.UserId).Distinct().ToList(), + cancellationToken); + + var items = page1.Items + .Select(r => new ActivationRequestAdminDto( + r.Id, r.UserId, userNames.GetValueOrDefault(r.UserId, "?"), r.Comment, r.Status, r.CreatedAt)) + .ToList(); + + return Result.Success(new PagedList(items, page1.Total, page1.Page, page1.PageSize)); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Activation/RejectActivationCommand.cs b/backend/src/PnvPanel.Application/Admin/Activation/RejectActivationCommand.cs new file mode 100644 index 0000000..694c8c9 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Activation/RejectActivationCommand.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Activation; + +public sealed record RejectActivationCommand(Guid RequestId, string? Reason) : ICommand; diff --git a/backend/src/PnvPanel.Application/Admin/Activation/RejectActivationCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Activation/RejectActivationCommandHandler.cs new file mode 100644 index 0000000..087686a --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Activation/RejectActivationCommandHandler.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Activation; +using PnvPanel.Application.Auth; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Activation; + +namespace PnvPanel.Application.Admin.Activation; + +public sealed class RejectActivationCommandHandler(IAppDbContext dbContext, ICurrentUser currentUser) + : ICommandHandler +{ + public async Task Handle(RejectActivationCommand command, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } adminId) + return Result.Failure(AuthErrors.Unauthorized); + + var request = await dbContext.ActivationRequests + .FirstOrDefaultAsync(r => r.Id == command.RequestId, cancellationToken); + + if (request is null) + return Result.Failure(ActivationErrors.NotFound); + + if (request.Status != ActivationStatus.Pending) + return Result.Failure(ActivationErrors.AlreadyDecided); + + request.Reject(adminId, command.Reason); + return Result.Success(); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Activation/RejectActivationCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/Activation/RejectActivationCommandValidator.cs new file mode 100644 index 0000000..6a79605 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Activation/RejectActivationCommandValidator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace PnvPanel.Application.Admin.Activation; + +public sealed class RejectActivationCommandValidator : AbstractValidator +{ + public RejectActivationCommandValidator() + { + RuleFor(x => x.Reason).MaximumLength(500); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Inbounds/InboundDto.cs b/backend/src/PnvPanel.Application/Admin/Inbounds/InboundDto.cs new file mode 100644 index 0000000..d8b260a --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Inbounds/InboundDto.cs @@ -0,0 +1,13 @@ +using PnvPanel.Domain.Inbounds; + +namespace PnvPanel.Application.Admin.Inbounds; + +public sealed record InboundDto( + Guid Id, Guid NodeId, string RemoteInboundId, VpnProtocol Protocol, string Remark, int Port, + bool IsPublished, string? DisplayName, int? MaxClients, IReadOnlyList 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); +} diff --git a/backend/src/PnvPanel.Application/Admin/Inbounds/InboundErrors.cs b/backend/src/PnvPanel.Application/Admin/Inbounds/InboundErrors.cs new file mode 100644 index 0000000..ceac891 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Inbounds/InboundErrors.cs @@ -0,0 +1,8 @@ +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Inbounds; + +public static class InboundErrors +{ + public static readonly Error NotFound = Error.NotFound("Inbounds.NotFound", "Inbound не найден."); +} diff --git a/backend/src/PnvPanel.Application/Admin/Inbounds/ListInboundsQuery.cs b/backend/src/PnvPanel.Application/Admin/Inbounds/ListInboundsQuery.cs new file mode 100644 index 0000000..1d7cc98 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Inbounds/ListInboundsQuery.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Inbounds; + +public sealed record ListInboundsQuery(Guid? NodeId) : IQuery>>; diff --git a/backend/src/PnvPanel.Application/Admin/Inbounds/ListInboundsQueryHandler.cs b/backend/src/PnvPanel.Application/Admin/Inbounds/ListInboundsQueryHandler.cs new file mode 100644 index 0000000..d71e5be --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Inbounds/ListInboundsQueryHandler.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Inbounds; + +public sealed class ListInboundsQueryHandler(IAppDbContext dbContext) : IQueryHandler>> +{ + public async Task>> 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>(inbounds.Select(InboundDto.FromDomain).ToList()); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommand.cs b/backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommand.cs new file mode 100644 index 0000000..e84b41c --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommand.cs @@ -0,0 +1,8 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Inbounds; + +public sealed record PublishInboundCommand( + Guid InboundId, bool IsPublished, string? DisplayName, IReadOnlyList AllowedRoleIds, int? MaxClients) + : ICommand>; diff --git a/backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommandHandler.cs new file mode 100644 index 0000000..11957a5 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommandHandler.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Inbounds; + +public sealed class PublishInboundCommandHandler(IAppDbContext dbContext) + : ICommandHandler> +{ + public async Task> Handle(PublishInboundCommand command, CancellationToken cancellationToken) + { + var inbound = await dbContext.Inbounds.FirstOrDefaultAsync(i => i.Id == command.InboundId, cancellationToken); + if (inbound is null) + return Result.Failure(InboundErrors.NotFound); + + if (command.IsPublished) + inbound.Publish(command.DisplayName, command.AllowedRoleIds, command.MaxClients); + else + inbound.Unpublish(); + + return Result.Success(InboundDto.FromDomain(inbound)); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommandValidator.cs new file mode 100644 index 0000000..31780d9 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace PnvPanel.Application.Admin.Inbounds; + +public sealed class PublishInboundCommandValidator : AbstractValidator +{ + public PublishInboundCommandValidator() + { + RuleFor(x => x.DisplayName).MaximumLength(100); + RuleFor(x => x.MaxClients).GreaterThan(0).When(x => x.MaxClients.HasValue); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/DeleteNodeCommand.cs b/backend/src/PnvPanel.Application/Admin/Nodes/DeleteNodeCommand.cs new file mode 100644 index 0000000..f11322b --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Nodes/DeleteNodeCommand.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Nodes; + +public sealed record DeleteNodeCommand(Guid NodeId) : ICommand; diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/DeleteNodeCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Nodes/DeleteNodeCommandHandler.cs new file mode 100644 index 0000000..690f950 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Nodes/DeleteNodeCommandHandler.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Nodes; + +public sealed class DeleteNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway) + : ICommandHandler +{ + public async Task Handle(DeleteNodeCommand command, CancellationToken cancellationToken) + { + var node = await dbContext.Nodes.FirstOrDefaultAsync(n => n.Id == command.NodeId, cancellationToken); + if (node is null) + return Result.Failure(NodeErrors.NotFound); + + var inbounds = await dbContext.Inbounds.Where(i => i.NodeId == node.Id).ToListAsync(cancellationToken); + dbContext.Inbounds.RemoveRange(inbounds); + dbContext.Nodes.Remove(node); + gateway.InvalidateClient(node.Id); + + return Result.Success(); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/ListNodesQuery.cs b/backend/src/PnvPanel.Application/Admin/Nodes/ListNodesQuery.cs new file mode 100644 index 0000000..e5aa661 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Nodes/ListNodesQuery.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Nodes; + +public sealed record ListNodesQuery : IQuery>>; diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/ListNodesQueryHandler.cs b/backend/src/PnvPanel.Application/Admin/Nodes/ListNodesQueryHandler.cs new file mode 100644 index 0000000..5491a4a --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Nodes/ListNodesQueryHandler.cs @@ -0,0 +1,15 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Nodes; + +public sealed class ListNodesQueryHandler(IAppDbContext dbContext) : IQueryHandler>> +{ + public async Task>> Handle(ListNodesQuery query, CancellationToken cancellationToken) + { + var nodes = await dbContext.Nodes.AsNoTracking().OrderBy(n => n.Name).ToListAsync(cancellationToken); + return Result.Success>(nodes.Select(NodeDto.FromDomain).ToList()); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/NodeDto.cs b/backend/src/PnvPanel.Application/Admin/Nodes/NodeDto.cs new file mode 100644 index 0000000..923964b --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Nodes/NodeDto.cs @@ -0,0 +1,13 @@ +using PnvPanel.Domain.Nodes; + +namespace PnvPanel.Application.Admin.Nodes; + +/// Админский DTO ноды. Пароль никогда не попадает в ответ API. +public sealed record NodeDto( + Guid Id, string Name, string BaseAddress, string Username, string? Location, + NodeStatus Status, bool IsEnabled, DateTimeOffset? LastSyncAt) +{ + public static NodeDto FromDomain(Node node) => new( + node.Id, node.Name, node.BaseAddress.ToString(), node.Credentials.Username, node.Location, + node.Status, node.IsEnabled, node.LastSyncAt); +} diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/NodeErrors.cs b/backend/src/PnvPanel.Application/Admin/Nodes/NodeErrors.cs new file mode 100644 index 0000000..d76fe9c --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Nodes/NodeErrors.cs @@ -0,0 +1,9 @@ +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Nodes; + +public static class NodeErrors +{ + public static readonly Error NotFound = Error.NotFound("Nodes.NotFound", "Нода не найдена."); + public static readonly Error InvalidBaseAddress = Error.Validation("Nodes.InvalidBaseAddress", "Некорректный адрес панели."); +} diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/ProbeNodeCommand.cs b/backend/src/PnvPanel.Application/Admin/Nodes/ProbeNodeCommand.cs new file mode 100644 index 0000000..794b5db --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Nodes/ProbeNodeCommand.cs @@ -0,0 +1,9 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Nodes; + +namespace PnvPanel.Application.Admin.Nodes; + +public sealed record ProbeNodeCommand(Guid NodeId) : ICommand>; + +public sealed record NodeProbeResultDto(bool IsReachable, string? ErrorMessage, NodeStatus Status); diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/ProbeNodeCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Nodes/ProbeNodeCommandHandler.cs new file mode 100644 index 0000000..a2ebb69 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Nodes/ProbeNodeCommandHandler.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Nodes; + +namespace PnvPanel.Application.Admin.Nodes; + +public sealed class ProbeNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway) + : ICommandHandler> +{ + public async Task> Handle(ProbeNodeCommand command, CancellationToken cancellationToken) + { + var node = await dbContext.Nodes.FirstOrDefaultAsync(n => n.Id == command.NodeId, cancellationToken); + if (node is null) + return Result.Failure(NodeErrors.NotFound); + + var 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)); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/RegisterNodeCommand.cs b/backend/src/PnvPanel.Application/Admin/Nodes/RegisterNodeCommand.cs new file mode 100644 index 0000000..900e957 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Nodes/RegisterNodeCommand.cs @@ -0,0 +1,7 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Nodes; + +public sealed record RegisterNodeCommand(string Name, string BaseAddress, string Username, string Password, string? Location) + : ICommand>; diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/RegisterNodeCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Nodes/RegisterNodeCommandHandler.cs new file mode 100644 index 0000000..4dfe409 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Nodes/RegisterNodeCommandHandler.cs @@ -0,0 +1,27 @@ +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Nodes; + +namespace PnvPanel.Application.Admin.Nodes; + +public sealed class RegisterNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway, ISecretProtector secretProtector) + : ICommandHandler> +{ + public Task> Handle(RegisterNodeCommand command, CancellationToken cancellationToken) + { + if (!Uri.TryCreate(command.BaseAddress, UriKind.Absolute, out var baseAddress)) + return Task.FromResult(Result.Failure(NodeErrors.InvalidBaseAddress)); + + var validation = gateway.ValidateBaseAddress(baseAddress); + if (!validation.IsSuccess) + return Task.FromResult(Result.Failure(validation.Error)); + + var credentials = new NodeCredentials(command.Username, secretProtector.Protect(command.Password)); + var node = Node.Register(command.Name, baseAddress, credentials, command.Location); + + dbContext.Nodes.Add(node); + + return Task.FromResult(Result.Success(NodeDto.FromDomain(node))); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/RegisterNodeCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/Nodes/RegisterNodeCommandValidator.cs new file mode 100644 index 0000000..62cd779 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Nodes/RegisterNodeCommandValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; + +namespace PnvPanel.Application.Admin.Nodes; + +public sealed class RegisterNodeCommandValidator : AbstractValidator +{ + public RegisterNodeCommandValidator() + { + RuleFor(x => x.Name).NotEmpty().MaximumLength(100); + RuleFor(x => x.BaseAddress).NotEmpty().MaximumLength(500); + RuleFor(x => x.Username).NotEmpty().MaximumLength(200); + RuleFor(x => x.Password).NotEmpty(); + RuleFor(x => x.Location).MaximumLength(100); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/SyncNodeCommand.cs b/backend/src/PnvPanel.Application/Admin/Nodes/SyncNodeCommand.cs new file mode 100644 index 0000000..6e4c02a --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Nodes/SyncNodeCommand.cs @@ -0,0 +1,9 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Nodes; + +namespace PnvPanel.Application.Admin.Nodes; + +public sealed record SyncNodeCommand(Guid NodeId) : ICommand>; + +public sealed record SyncNodeResultDto(int InboundsSynced, NodeStatus Status); diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/SyncNodeCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Nodes/SyncNodeCommandHandler.cs new file mode 100644 index 0000000..32f0897 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Nodes/SyncNodeCommandHandler.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Inbounds; +using PnvPanel.Domain.Nodes; + +namespace PnvPanel.Application.Admin.Nodes; + +public sealed class SyncNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway) + : ICommandHandler> +{ + public async Task> Handle(SyncNodeCommand command, CancellationToken cancellationToken) + { + var node = await dbContext.Nodes.FirstOrDefaultAsync(n => n.Id == command.NodeId, cancellationToken); + if (node is null) + return Result.Failure(NodeErrors.NotFound); + + var remoteResult = await gateway.ListInboundsAsync(node, cancellationToken); + if (!remoteResult.IsSuccess) + { + node.UpdateStatus(NodeStatus.Offline); + return Result.Failure(remoteResult.Error); + } + + var existing = await dbContext.Inbounds.Where(i => i.NodeId == node.Id).ToListAsync(cancellationToken); + var existingByRemoteId = existing.ToDictionary(i => i.RemoteInboundId); + + foreach (var remote in remoteResult.Value) + { + if (existingByRemoteId.TryGetValue(remote.RemoteInboundId, out var inbound)) + inbound.UpdateFromRemote(remote.Protocol, remote.Remark, remote.Port); + else + dbContext.Inbounds.Add(Inbound.FromRemote(node.Id, remote.RemoteInboundId, remote.Protocol, remote.Remark, remote.Port)); + } + + // Inbound, пропавший на панели, снимаем с публикации (не удаляем — реконсиляция дрейфа, + // см. architecture.md); новые конфиги на нём создать будет нельзя, старые не трогаем. + var remoteIds = remoteResult.Value.Select(r => r.RemoteInboundId).ToHashSet(); + foreach (var stale in existing.Where(i => i.IsPublished && !remoteIds.Contains(i.RemoteInboundId))) + stale.Unpublish(); + + node.UpdateStatus(NodeStatus.Online); + node.MarkSynced(); + + return Result.Success(new SyncNodeResultDto(remoteResult.Value.Count, node.Status)); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommand.cs b/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommand.cs new file mode 100644 index 0000000..f67c93f --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommand.cs @@ -0,0 +1,8 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Nodes; + +public sealed record UpdateNodeCommand( + Guid NodeId, string Name, string? Location, bool IsEnabled, string? Username, string? Password) + : ICommand>; diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommandHandler.cs new file mode 100644 index 0000000..a78934e --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommandHandler.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Nodes; + +namespace PnvPanel.Application.Admin.Nodes; + +public sealed class UpdateNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway, ISecretProtector secretProtector) + : ICommandHandler> +{ + public async Task> Handle(UpdateNodeCommand command, CancellationToken cancellationToken) + { + var node = await dbContext.Nodes.FirstOrDefaultAsync(n => n.Id == command.NodeId, cancellationToken); + if (node is null) + return Result.Failure(NodeErrors.NotFound); + + node.UpdateDetails(command.Name, command.Location); + + if (command.IsEnabled) + node.Enable(); + else + node.Disable(); + + if (!string.IsNullOrWhiteSpace(command.Username) && !string.IsNullOrWhiteSpace(command.Password)) + { + node.UpdateCredentials(new NodeCredentials(command.Username, secretProtector.Protect(command.Password))); + gateway.InvalidateClient(node.Id); + } + + return Result.Success(NodeDto.FromDomain(node)); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommandValidator.cs new file mode 100644 index 0000000..a5c06d7 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommandValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace PnvPanel.Application.Admin.Nodes; + +public sealed class UpdateNodeCommandValidator : AbstractValidator +{ + public UpdateNodeCommandValidator() + { + RuleFor(x => x.Name).NotEmpty().MaximumLength(100); + RuleFor(x => x.Location).MaximumLength(100); + RuleFor(x => x.Username).MaximumLength(200); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommand.cs b/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommand.cs new file mode 100644 index 0000000..134d630 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommand.cs @@ -0,0 +1,7 @@ +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Roles; + +public sealed record CreateRoleCommand(string Name, int MaxConfigs) : ICommand>; diff --git a/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandHandler.cs new file mode 100644 index 0000000..600cccf --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandHandler.cs @@ -0,0 +1,11 @@ +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Roles; + +public sealed class CreateRoleCommandHandler(IRoleService roleService) : ICommandHandler> +{ + public Task> Handle(CreateRoleCommand command, CancellationToken cancellationToken) + => roleService.CreateRoleAsync(command.Name, command.MaxConfigs, cancellationToken); +} diff --git a/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandValidator.cs new file mode 100644 index 0000000..5ddfb40 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandValidator.cs @@ -0,0 +1,16 @@ +using FluentValidation; + +namespace PnvPanel.Application.Admin.Roles; + +public sealed class CreateRoleCommandValidator : AbstractValidator +{ + public CreateRoleCommandValidator() + { + RuleFor(x => x.Name) + .NotEmpty() + .Length(2, 32) + .Matches("^[a-zA-Z0-9_-]+$"); + + RuleFor(x => x.MaxConfigs).GreaterThanOrEqualTo(-1); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Roles/DeleteRoleCommand.cs b/backend/src/PnvPanel.Application/Admin/Roles/DeleteRoleCommand.cs new file mode 100644 index 0000000..a67395f --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Roles/DeleteRoleCommand.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Roles; + +public sealed record DeleteRoleCommand(Guid RoleId) : ICommand; diff --git a/backend/src/PnvPanel.Application/Admin/Roles/DeleteRoleCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Roles/DeleteRoleCommandHandler.cs new file mode 100644 index 0000000..bb76045 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Roles/DeleteRoleCommandHandler.cs @@ -0,0 +1,11 @@ +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Roles; + +public sealed class DeleteRoleCommandHandler(IRoleService roleService) : ICommandHandler +{ + public Task Handle(DeleteRoleCommand command, CancellationToken cancellationToken) + => roleService.DeleteRoleAsync(command.RoleId, cancellationToken); +} diff --git a/backend/src/PnvPanel.Application/Admin/Roles/ListRolesQuery.cs b/backend/src/PnvPanel.Application/Admin/Roles/ListRolesQuery.cs new file mode 100644 index 0000000..80f2419 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Roles/ListRolesQuery.cs @@ -0,0 +1,7 @@ +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Roles; + +public sealed record ListRolesQuery : IQuery>>; diff --git a/backend/src/PnvPanel.Application/Admin/Roles/ListRolesQueryHandler.cs b/backend/src/PnvPanel.Application/Admin/Roles/ListRolesQueryHandler.cs new file mode 100644 index 0000000..6634db8 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Roles/ListRolesQueryHandler.cs @@ -0,0 +1,12 @@ +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Roles; + +public sealed class ListRolesQueryHandler(IRoleService roleService) + : IQueryHandler>> +{ + public async Task>> Handle(ListRolesQuery query, CancellationToken cancellationToken) + => Result.Success(await roleService.ListRolesAsync(cancellationToken)); +} diff --git a/backend/src/PnvPanel.Application/Admin/Roles/RoleErrors.cs b/backend/src/PnvPanel.Application/Admin/Roles/RoleErrors.cs new file mode 100644 index 0000000..55d12f6 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Roles/RoleErrors.cs @@ -0,0 +1,11 @@ +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Roles; + +public static class RoleErrors +{ + public static readonly Error NotFound = Error.NotFound("Roles.NotFound", "Роль не найдена."); + public static readonly Error DuplicateName = Error.Conflict("Roles.DuplicateName", "Роль с таким именем уже существует."); + public static readonly Error CannotModifySystemRole = Error.Forbidden("Roles.CannotModifySystemRole", "Системную роль нельзя удалить."); + public static readonly Error RoleInUse = Error.Conflict("Roles.RoleInUse", "Роль назначена пользователям — сначала переназначьте их."); +} diff --git a/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommand.cs b/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommand.cs new file mode 100644 index 0000000..79775e0 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommand.cs @@ -0,0 +1,7 @@ +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Roles; + +public sealed record UpdateRoleCommand(Guid RoleId, int MaxConfigs) : ICommand>; diff --git a/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandHandler.cs new file mode 100644 index 0000000..38e71f6 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandHandler.cs @@ -0,0 +1,11 @@ +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Roles; + +public sealed class UpdateRoleCommandHandler(IRoleService roleService) : ICommandHandler> +{ + public Task> Handle(UpdateRoleCommand command, CancellationToken cancellationToken) + => roleService.UpdateRoleAsync(command.RoleId, command.MaxConfigs, cancellationToken); +} diff --git a/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandValidator.cs new file mode 100644 index 0000000..ff5f251 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandValidator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace PnvPanel.Application.Admin.Roles; + +public sealed class UpdateRoleCommandValidator : AbstractValidator +{ + public UpdateRoleCommandValidator() + { + RuleFor(x => x.MaxConfigs).GreaterThanOrEqualTo(-1); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Users/ChangeUserRoleCommand.cs b/backend/src/PnvPanel.Application/Admin/Users/ChangeUserRoleCommand.cs new file mode 100644 index 0000000..6cbc2cd --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Users/ChangeUserRoleCommand.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Users; + +public sealed record ChangeUserRoleCommand(Guid UserId, Guid RoleId) : ICommand; diff --git a/backend/src/PnvPanel.Application/Admin/Users/ChangeUserRoleCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Users/ChangeUserRoleCommandHandler.cs new file mode 100644 index 0000000..aed80e5 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Users/ChangeUserRoleCommandHandler.cs @@ -0,0 +1,11 @@ +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Users; + +public sealed class ChangeUserRoleCommandHandler(IRoleService roleService) : ICommandHandler +{ + public Task Handle(ChangeUserRoleCommand command, CancellationToken cancellationToken) + => roleService.ChangeUserRoleAsync(command.UserId, command.RoleId, cancellationToken); +} diff --git a/backend/src/PnvPanel.Application/Admin/Users/UserErrors.cs b/backend/src/PnvPanel.Application/Admin/Users/UserErrors.cs new file mode 100644 index 0000000..6cb9654 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Users/UserErrors.cs @@ -0,0 +1,8 @@ +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Users; + +public static class UserErrors +{ + public static readonly Error NotFound = Error.NotFound("Users.NotFound", "Пользователь не найден."); +} diff --git a/backend/src/PnvPanel.Application/Apps/ClientAppDto.cs b/backend/src/PnvPanel.Application/Apps/ClientAppDto.cs new file mode 100644 index 0000000..5d66310 --- /dev/null +++ b/backend/src/PnvPanel.Application/Apps/ClientAppDto.cs @@ -0,0 +1,9 @@ +using PnvPanel.Domain.Apps; + +namespace PnvPanel.Application.Apps; + +public sealed record ClientAppDto(Guid Id, string Name, string DownloadUrl, string? Description, string? IconUrl) +{ + public static ClientAppDto FromDomain(ClientApp app) => + new(app.Id, app.Name, app.DownloadUrl.ToString(), app.Description, app.IconUrl); +} diff --git a/backend/src/PnvPanel.Application/Apps/ListAppsQuery.cs b/backend/src/PnvPanel.Application/Apps/ListAppsQuery.cs new file mode 100644 index 0000000..b529714 --- /dev/null +++ b/backend/src/PnvPanel.Application/Apps/ListAppsQuery.cs @@ -0,0 +1,7 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Apps; + +namespace PnvPanel.Application.Apps; + +public sealed record ListAppsQuery : IQuery>>>; diff --git a/backend/src/PnvPanel.Application/Apps/ListAppsQueryHandler.cs b/backend/src/PnvPanel.Application/Apps/ListAppsQueryHandler.cs new file mode 100644 index 0000000..df66f69 --- /dev/null +++ b/backend/src/PnvPanel.Application/Apps/ListAppsQueryHandler.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Apps; + +namespace PnvPanel.Application.Apps; + +public sealed class ListAppsQueryHandler(IAppDbContext dbContext) + : IQueryHandler>>> +{ + public async Task>>> Handle( + ListAppsQuery query, CancellationToken cancellationToken) + { + var apps = await dbContext.ClientApps.AsNoTracking() + .Where(a => a.IsEnabled) + .OrderBy(a => a.SortOrder) + .ToListAsync(cancellationToken); + + var grouped = apps + .GroupBy(a => a.OperatingSystem) + .ToDictionary( + g => g.Key, + g => (IReadOnlyList)g.Select(ClientAppDto.FromDomain).ToList()); + + return Result.Success>>(grouped); + } +} diff --git a/backend/src/PnvPanel.Application/Auth/AuthErrors.cs b/backend/src/PnvPanel.Application/Auth/AuthErrors.cs new file mode 100644 index 0000000..3e4afc8 --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/AuthErrors.cs @@ -0,0 +1,21 @@ +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Auth; + +public static class AuthErrors +{ + public static readonly Error DuplicateUserName = + Error.Conflict("Auth.DuplicateUserName", "Пользователь с таким именем уже существует."); + + public static readonly Error InvalidCredentials = + Error.Unauthorized("Auth.InvalidCredentials", "Неверное имя пользователя или пароль."); + + public static readonly Error LockedOut = + Error.Unauthorized("Auth.LockedOut", "Слишком много неудачных попыток входа. Попробуйте позже."); + + public static readonly Error InvalidRefreshToken = + Error.Unauthorized("Auth.InvalidRefreshToken", "Недействительный refresh-токен."); + + public static readonly Error Unauthorized = + Error.Unauthorized("Auth.Unauthorized", "Требуется аутентификация."); +} diff --git a/backend/src/PnvPanel.Application/Auth/AuthResult.cs b/backend/src/PnvPanel.Application/Auth/AuthResult.cs new file mode 100644 index 0000000..fe246c0 --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/AuthResult.cs @@ -0,0 +1,8 @@ +namespace PnvPanel.Application.Auth; + +public sealed record AuthResult( + string AccessToken, + DateTimeOffset AccessTokenExpiresAt, + string RefreshToken, + DateTimeOffset RefreshTokenExpiresAt, + CurrentUserDto User); diff --git a/backend/src/PnvPanel.Application/Auth/ChangePassword/ChangePasswordCommand.cs b/backend/src/PnvPanel.Application/Auth/ChangePassword/ChangePasswordCommand.cs new file mode 100644 index 0000000..122e399 --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/ChangePassword/ChangePasswordCommand.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Auth.ChangePassword; + +public sealed record ChangePasswordCommand(string CurrentPassword, string NewPassword) : ICommand; diff --git a/backend/src/PnvPanel.Application/Auth/ChangePassword/ChangePasswordCommandHandler.cs b/backend/src/PnvPanel.Application/Auth/ChangePassword/ChangePasswordCommandHandler.cs new file mode 100644 index 0000000..87b97a1 --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/ChangePassword/ChangePasswordCommandHandler.cs @@ -0,0 +1,17 @@ +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Auth.ChangePassword; + +public sealed class ChangePasswordCommandHandler(IIdentityService identityService, ICurrentUser currentUser) + : ICommandHandler +{ + public Task Handle(ChangePasswordCommand command, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return Task.FromResult(Result.Failure(AuthErrors.Unauthorized)); + + return identityService.ChangePasswordAsync(userId, command.CurrentPassword, command.NewPassword, cancellationToken); + } +} diff --git a/backend/src/PnvPanel.Application/Auth/ChangePassword/ChangePasswordCommandValidator.cs b/backend/src/PnvPanel.Application/Auth/ChangePassword/ChangePasswordCommandValidator.cs new file mode 100644 index 0000000..d8f6acd --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/ChangePassword/ChangePasswordCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace PnvPanel.Application.Auth.ChangePassword; + +public sealed class ChangePasswordCommandValidator : AbstractValidator +{ + public ChangePasswordCommandValidator() + { + RuleFor(x => x.CurrentPassword).NotEmpty(); + RuleFor(x => x.NewPassword).NotEmpty().MinimumLength(8); + } +} diff --git a/backend/src/PnvPanel.Application/Auth/CurrentUserDto.cs b/backend/src/PnvPanel.Application/Auth/CurrentUserDto.cs new file mode 100644 index 0000000..5d7276c --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/CurrentUserDto.cs @@ -0,0 +1,3 @@ +namespace PnvPanel.Application.Auth; + +public sealed record CurrentUserDto(Guid Id, string UserName, string Role, bool IsActivated); diff --git a/backend/src/PnvPanel.Application/Auth/DeleteMyAccount/DeleteMyAccountCommand.cs b/backend/src/PnvPanel.Application/Auth/DeleteMyAccount/DeleteMyAccountCommand.cs new file mode 100644 index 0000000..d76567e --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/DeleteMyAccount/DeleteMyAccountCommand.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Auth.DeleteMyAccount; + +public sealed record DeleteMyAccountCommand : ICommand; diff --git a/backend/src/PnvPanel.Application/Auth/DeleteMyAccount/DeleteMyAccountCommandHandler.cs b/backend/src/PnvPanel.Application/Auth/DeleteMyAccount/DeleteMyAccountCommandHandler.cs new file mode 100644 index 0000000..c8d3c9e --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/DeleteMyAccount/DeleteMyAccountCommandHandler.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Configs; + +namespace PnvPanel.Application.Auth.DeleteMyAccount; + +public sealed class DeleteMyAccountCommandHandler(IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway, ICurrentUser currentUser) + : ICommandHandler +{ + public async Task Handle(DeleteMyAccountCommand command, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return Result.Failure(AuthErrors.Unauthorized); + + var configs = await dbContext.VpnConfigs + .Where(c => c.UserId == userId && c.Status != ConfigStatus.Revoked) + .ToListAsync(cancellationToken); + + foreach (var config in configs) + { + var inbound = await dbContext.Inbounds.AsNoTracking().FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken); + var node = inbound is null + ? null + : await dbContext.Nodes.AsNoTracking().FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken); + + if (inbound is not null && node is not null) + await gateway.RemoveClientAsync(node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, cancellationToken); + + config.Revoke(); + } + + await dbContext.SaveChangesAsync(cancellationToken); + + return await identityService.DeleteUserAsync(userId, cancellationToken); + } +} diff --git a/backend/src/PnvPanel.Application/Auth/Login/LoginCommand.cs b/backend/src/PnvPanel.Application/Auth/Login/LoginCommand.cs new file mode 100644 index 0000000..09a63a3 --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/Login/LoginCommand.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Auth.Login; + +public sealed record LoginCommand(string UserName, string Password) : ICommand>; diff --git a/backend/src/PnvPanel.Application/Auth/Login/LoginCommandHandler.cs b/backend/src/PnvPanel.Application/Auth/Login/LoginCommandHandler.cs new file mode 100644 index 0000000..1210a40 --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/Login/LoginCommandHandler.cs @@ -0,0 +1,35 @@ +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Auth.Login; + +public sealed class LoginCommandHandler( + IIdentityService identityService, + IJwtTokenService jwtTokenService, + IRefreshTokenService refreshTokenService) : ICommandHandler> +{ + public async Task> Handle(LoginCommand command, CancellationToken cancellationToken) + { + var credentialsResult = await identityService.ValidateCredentialsAsync(command.UserName, command.Password, cancellationToken); + if (!credentialsResult.IsSuccess) + return Result.Failure(credentialsResult.Error); + + var user = credentialsResult.Value; + var profile = await identityService.GetProfileAsync(user.Id, cancellationToken); + if (profile is null) + return Result.Failure(AuthErrors.InvalidCredentials); + + var (accessToken, accessExpiresAt) = jwtTokenService.GenerateAccessToken(user); + var refreshToken = await refreshTokenService.IssueAsync(user.Id, cancellationToken); + + var dto = new CurrentUserDto(profile.Id, profile.UserName, profile.Role, profile.IsActivated); + + return Result.Success(new AuthResult( + accessToken, + accessExpiresAt, + refreshToken.RawToken, + refreshToken.ExpiresAt, + dto)); + } +} diff --git a/backend/src/PnvPanel.Application/Auth/Login/LoginCommandValidator.cs b/backend/src/PnvPanel.Application/Auth/Login/LoginCommandValidator.cs new file mode 100644 index 0000000..f5090c1 --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/Login/LoginCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace PnvPanel.Application.Auth.Login; + +public sealed class LoginCommandValidator : AbstractValidator +{ + public LoginCommandValidator() + { + RuleFor(x => x.UserName).NotEmpty(); + RuleFor(x => x.Password).NotEmpty(); + } +} diff --git a/backend/src/PnvPanel.Application/Auth/Logout/LogoutCommand.cs b/backend/src/PnvPanel.Application/Auth/Logout/LogoutCommand.cs new file mode 100644 index 0000000..7a490cb --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/Logout/LogoutCommand.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Auth.Logout; + +public sealed record LogoutCommand(string RawRefreshToken) : ICommand; diff --git a/backend/src/PnvPanel.Application/Auth/Logout/LogoutCommandHandler.cs b/backend/src/PnvPanel.Application/Auth/Logout/LogoutCommandHandler.cs new file mode 100644 index 0000000..64bdf7a --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/Logout/LogoutCommandHandler.cs @@ -0,0 +1,15 @@ +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Auth.Logout; + +public sealed class LogoutCommandHandler(IRefreshTokenService refreshTokenService) + : ICommandHandler +{ + public async Task Handle(LogoutCommand command, CancellationToken cancellationToken) + { + await refreshTokenService.RevokeAsync(command.RawRefreshToken, cancellationToken); + return Result.Success(); + } +} diff --git a/backend/src/PnvPanel.Application/Auth/Me/GetCurrentUserQuery.cs b/backend/src/PnvPanel.Application/Auth/Me/GetCurrentUserQuery.cs new file mode 100644 index 0000000..d9f9b64 --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/Me/GetCurrentUserQuery.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Auth.Me; + +public sealed record GetCurrentUserQuery : IQuery>; diff --git a/backend/src/PnvPanel.Application/Auth/Me/GetCurrentUserQueryHandler.cs b/backend/src/PnvPanel.Application/Auth/Me/GetCurrentUserQueryHandler.cs new file mode 100644 index 0000000..6c39d19 --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/Me/GetCurrentUserQueryHandler.cs @@ -0,0 +1,21 @@ +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Auth.Me; + +public sealed class GetCurrentUserQueryHandler(IIdentityService identityService, ICurrentUser currentUser) + : IQueryHandler> +{ + public async Task> Handle(GetCurrentUserQuery query, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return Result.Failure(AuthErrors.Unauthorized); + + var profile = await identityService.GetProfileAsync(userId, cancellationToken); + if (profile is null) + return Result.Failure(AuthErrors.Unauthorized); + + return Result.Success(new CurrentUserDto(profile.Id, profile.UserName, profile.Role, profile.IsActivated)); + } +} diff --git a/backend/src/PnvPanel.Application/Auth/Refresh/RefreshCommand.cs b/backend/src/PnvPanel.Application/Auth/Refresh/RefreshCommand.cs new file mode 100644 index 0000000..c0c6ee5 --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/Refresh/RefreshCommand.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Auth.Refresh; + +public sealed record RefreshCommand(string RawRefreshToken) : ICommand>; diff --git a/backend/src/PnvPanel.Application/Auth/Refresh/RefreshCommandHandler.cs b/backend/src/PnvPanel.Application/Auth/Refresh/RefreshCommandHandler.cs new file mode 100644 index 0000000..a4ddc64 --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/Refresh/RefreshCommandHandler.cs @@ -0,0 +1,34 @@ +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Auth.Refresh; + +public sealed class RefreshCommandHandler( + IIdentityService identityService, + IJwtTokenService jwtTokenService, + IRefreshTokenService refreshTokenService) : ICommandHandler> +{ + public async Task> Handle(RefreshCommand command, CancellationToken cancellationToken) + { + var rotated = await refreshTokenService.RotateAsync(command.RawRefreshToken, cancellationToken); + if (!rotated.IsSuccess) + return Result.Failure(rotated.Error); + + var profile = await identityService.GetProfileAsync(rotated.Value.UserId, cancellationToken); + if (profile is null) + return Result.Failure(AuthErrors.InvalidRefreshToken); + + var authUser = new AuthenticatedUser(profile.Id, profile.UserName, profile.Role); + var (accessToken, accessExpiresAt) = jwtTokenService.GenerateAccessToken(authUser); + + var dto = new CurrentUserDto(profile.Id, profile.UserName, profile.Role, profile.IsActivated); + + return Result.Success(new AuthResult( + accessToken, + accessExpiresAt, + rotated.Value.RawToken, + rotated.Value.ExpiresAt, + dto)); + } +} diff --git a/backend/src/PnvPanel.Application/Auth/Refresh/RefreshCommandValidator.cs b/backend/src/PnvPanel.Application/Auth/Refresh/RefreshCommandValidator.cs new file mode 100644 index 0000000..9563969 --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/Refresh/RefreshCommandValidator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace PnvPanel.Application.Auth.Refresh; + +public sealed class RefreshCommandValidator : AbstractValidator +{ + public RefreshCommandValidator() + { + RuleFor(x => x.RawRefreshToken).NotEmpty(); + } +} diff --git a/backend/src/PnvPanel.Application/Auth/Register/RegisterCommand.cs b/backend/src/PnvPanel.Application/Auth/Register/RegisterCommand.cs new file mode 100644 index 0000000..a0481ea --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/Register/RegisterCommand.cs @@ -0,0 +1,8 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Auth.Register; + +public sealed record RegisterCommand(string UserName, string Password) : ICommand>; + +public sealed record RegisterResult(Guid Id, string UserName); diff --git a/backend/src/PnvPanel.Application/Auth/Register/RegisterCommandHandler.cs b/backend/src/PnvPanel.Application/Auth/Register/RegisterCommandHandler.cs new file mode 100644 index 0000000..54409f8 --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/Register/RegisterCommandHandler.cs @@ -0,0 +1,18 @@ +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Auth.Register; + +public sealed class RegisterCommandHandler(IIdentityService identityService) + : ICommandHandler> +{ + public async Task> Handle(RegisterCommand command, CancellationToken cancellationToken) + { + var result = await identityService.CreateUserAsync(command.UserName, command.Password, cancellationToken); + + return result.IsSuccess + ? Result.Success(new RegisterResult(result.Value, command.UserName)) + : Result.Failure(result.Error); + } +} diff --git a/backend/src/PnvPanel.Application/Auth/Register/RegisterCommandValidator.cs b/backend/src/PnvPanel.Application/Auth/Register/RegisterCommandValidator.cs new file mode 100644 index 0000000..a763e27 --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/Register/RegisterCommandValidator.cs @@ -0,0 +1,19 @@ +using FluentValidation; + +namespace PnvPanel.Application.Auth.Register; + +public sealed class RegisterCommandValidator : AbstractValidator +{ + public RegisterCommandValidator() + { + RuleFor(x => x.UserName) + .NotEmpty() + .Length(3, 32) + .Matches("^[a-zA-Z0-9_.-]+$") + .WithMessage("Имя пользователя может содержать только латиницу, цифры, '_', '.', '-'."); + + RuleFor(x => x.Password) + .NotEmpty() + .MinimumLength(8); + } +} diff --git a/backend/src/PnvPanel.Application/Common/Behaviors/LoggingBehavior.cs b/backend/src/PnvPanel.Application/Common/Behaviors/LoggingBehavior.cs new file mode 100644 index 0000000..7cb8660 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Behaviors/LoggingBehavior.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.Logging; +using PnvPanel.Application.Common.Messaging; + +namespace PnvPanel.Application.Common.Behaviors; + +public sealed class LoggingBehavior(ILogger> logger) + : IPipelineBehavior + where TRequest : notnull +{ + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + var requestName = typeof(TRequest).Name; + logger.LogInformation("Обработка {RequestName}", requestName); + + var response = await next(); + + logger.LogInformation("Обработан {RequestName}", requestName); + return response; + } +} diff --git a/backend/src/PnvPanel.Application/Common/Behaviors/UnitOfWorkBehavior.cs b/backend/src/PnvPanel.Application/Common/Behaviors/UnitOfWorkBehavior.cs new file mode 100644 index 0000000..e4953e0 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Behaviors/UnitOfWorkBehavior.cs @@ -0,0 +1,21 @@ +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; + +namespace PnvPanel.Application.Common.Behaviors; + +/// +/// Коммитит изменения после успешного выполнения команды. Применяется автоматически только +/// к запросам, реализующим — благодаря generic-ограничению +/// DI-контейнер не сможет сконструировать это поведение для запросов (IQuery). +/// +public sealed class UnitOfWorkBehavior(IAppDbContext dbContext) + : IPipelineBehavior + where TRequest : ICommand +{ + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + var response = await next(); + await dbContext.SaveChangesAsync(cancellationToken); + return response; + } +} diff --git a/backend/src/PnvPanel.Application/Common/Behaviors/ValidationBehavior.cs b/backend/src/PnvPanel.Application/Common/Behaviors/ValidationBehavior.cs new file mode 100644 index 0000000..f40d78e --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Behaviors/ValidationBehavior.cs @@ -0,0 +1,45 @@ +using FluentValidation; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Common.Behaviors; + +public sealed class ValidationBehavior(IEnumerable> validators) + : IPipelineBehavior + where TRequest : notnull + where TResponse : Result +{ + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + if (!validators.Any()) + return await next(); + + var context = new ValidationContext(request); + var failures = validators + .Select(v => v.Validate(context)) + .SelectMany(r => r.Errors) + .ToList(); + + if (failures.Count == 0) + return await next(); + + var error = Error.Validation( + "Validation.Failed", + string.Join("; ", failures.Select(f => f.ErrorMessage))); + + return CreateFailure(error); + } + + private static TResponse CreateFailure(Error error) + { + if (typeof(TResponse) == typeof(Result)) + return (TResponse)(object)Result.Failure(error); + + var valueType = typeof(TResponse).GetGenericArguments()[0]; + var method = typeof(Result) + .GetMethod(nameof(Result.Failure), 1, [typeof(Error)])! + .MakeGenericMethod(valueType); + + return (TResponse)method.Invoke(null, [error])!; + } +} diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs new file mode 100644 index 0000000..2380c90 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using PnvPanel.Domain.Activation; +using PnvPanel.Domain.Apps; +using PnvPanel.Domain.Configs; +using PnvPanel.Domain.Inbounds; +using PnvPanel.Domain.Nodes; + +namespace PnvPanel.Application.Common.Interfaces; + +public interface IAppDbContext +{ + DbSet ActivationRequests { get; } + + DbSet Nodes { get; } + + DbSet Inbounds { get; } + + DbSet VpnConfigs { get; } + + DbSet ClientApps { get; } + + /// Нужен для advisory-lock при проверке квоты конфигов (см. CreateVpnConfigCommandHandler). + DatabaseFacade Database { get; } + + Task SaveChangesAsync(CancellationToken cancellationToken); +} diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/ICurrentUser.cs b/backend/src/PnvPanel.Application/Common/Interfaces/ICurrentUser.cs new file mode 100644 index 0000000..5918a9b --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Interfaces/ICurrentUser.cs @@ -0,0 +1,8 @@ +namespace PnvPanel.Application.Common.Interfaces; + +public interface ICurrentUser +{ + Guid? UserId { get; } + string? UserName { get; } + bool IsAuthenticated { get; } +} diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs new file mode 100644 index 0000000..2822b31 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs @@ -0,0 +1,33 @@ +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Common.Interfaces; + +public sealed record AuthenticatedUser(Guid Id, string UserName, string Role); + +public sealed record CurrentUserProfile(Guid Id, string UserName, Guid RoleId, string Role, bool IsActivated, int MaxConfigs); + +public interface IIdentityService +{ + Task> CreateUserAsync(string userName, string password, CancellationToken cancellationToken); + + Task> ValidateCredentialsAsync(string userName, string password, CancellationToken cancellationToken); + + Task GetProfileAsync(Guid userId, CancellationToken cancellationToken); + + Task ChangePasswordAsync(Guid userId, string currentPassword, string newPassword, CancellationToken cancellationToken); + + /// + /// Помечает пользователя активированным. Изменение не коммитится немедленно (в отличие от + /// CreateUserAsync/ChangePasswordAsync) — оно попадает в трекер того же DbContext и сохраняется + /// вместе с изменением ActivationRequest одной транзакцией через UnitOfWorkBehavior. + /// + Task ActivateUserAsync(Guid userId, Guid activatedBy, CancellationToken cancellationToken); + + Task> GetUserNamesAsync(IReadOnlyCollection userIds, CancellationToken cancellationToken); + + /// Удаляет аккаунт (самоудаление). Конфиги должны быть отозваны заранее вызывающей стороной. + Task DeleteUserAsync(Guid userId, CancellationToken cancellationToken); + + /// Для агрегированной подписки /sub/{userToken} (все активные конфиги пользователя). + Task FindUserIdBySubscriptionTokenAsync(string token, CancellationToken cancellationToken); +} diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IJwtTokenService.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IJwtTokenService.cs new file mode 100644 index 0000000..b4d0f21 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IJwtTokenService.cs @@ -0,0 +1,6 @@ +namespace PnvPanel.Application.Common.Interfaces; + +public interface IJwtTokenService +{ + (string AccessToken, DateTimeOffset ExpiresAt) GenerateAccessToken(AuthenticatedUser user); +} diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IRefreshTokenService.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IRefreshTokenService.cs new file mode 100644 index 0000000..78b3908 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IRefreshTokenService.cs @@ -0,0 +1,16 @@ +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Common.Interfaces; + +public sealed record IssuedRefreshToken(string RawToken, DateTimeOffset ExpiresAt); + +public sealed record RotatedRefreshToken(Guid UserId, string RawToken, DateTimeOffset ExpiresAt); + +public interface IRefreshTokenService +{ + Task IssueAsync(Guid userId, CancellationToken cancellationToken); + + Task> RotateAsync(string rawToken, CancellationToken cancellationToken); + + Task RevokeAsync(string rawToken, CancellationToken cancellationToken); +} diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IRoleService.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IRoleService.cs new file mode 100644 index 0000000..2f9ce64 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IRoleService.cs @@ -0,0 +1,18 @@ +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Common.Interfaces; + +public sealed record RoleDto(Guid Id, string Name, int MaxConfigs, bool IsSystem); + +public interface IRoleService +{ + Task> CreateRoleAsync(string name, int maxConfigs, CancellationToken cancellationToken); + + Task> UpdateRoleAsync(Guid roleId, int maxConfigs, CancellationToken cancellationToken); + + Task DeleteRoleAsync(Guid roleId, CancellationToken cancellationToken); + + Task> ListRolesAsync(CancellationToken cancellationToken); + + Task ChangeUserRoleAsync(Guid userId, Guid roleId, CancellationToken cancellationToken); +} diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/ISecretProtector.cs b/backend/src/PnvPanel.Application/Common/Interfaces/ISecretProtector.cs new file mode 100644 index 0000000..9b03706 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Interfaces/ISecretProtector.cs @@ -0,0 +1,9 @@ +namespace PnvPanel.Application.Common.Interfaces; + +/// Шифрование секретов at-rest (пароли нод). Реализация — ASP.NET Core Data Protection. +public interface ISecretProtector +{ + string Protect(string plaintext); + + string Unprotect(string protectedValue); +} diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IXuiPanelGateway.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IXuiPanelGateway.cs new file mode 100644 index 0000000..5cef6c3 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IXuiPanelGateway.cs @@ -0,0 +1,41 @@ +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Inbounds; +using PnvPanel.Domain.Nodes; + +namespace PnvPanel.Application.Common.Interfaces; + +public sealed record RemoteInboundInfo(string RemoteInboundId, VpnProtocol Protocol, string Remark, int Port); + +public sealed record NodeProbeResult(bool IsReachable, string? ErrorMessage); + +/// +/// Оркестрация панелей 3x-ui через ThreeXui.Net. Один BaseAddress в библиотеке, но нод много — +/// реализация держит клиента per-node (кэш по NodeId), см. XuiPanelGateway. +/// +public interface IXuiPanelGateway +{ + Result ValidateBaseAddress(Uri baseAddress); + + Task ProbeAsync(Node node, CancellationToken cancellationToken); + + Task>> ListInboundsAsync(Node node, CancellationToken cancellationToken); + + void InvalidateClient(Guid nodeId); + + /// Возвращает ClientExternalId, присвоенный панелью (UUID для VLESS/VMess, пароль для Trojan/Shadowsocks). + Task> AddClientAsync( + Node node, string inboundRemoteId, VpnProtocol protocol, string clientEmail, string clientName, + int deviceLimit, CancellationToken cancellationToken); + + Task RemoveClientAsync( + Node node, string inboundRemoteId, string clientExternalId, VpnProtocol protocol, + CancellationToken cancellationToken); + + Task UpdateClientAsync( + Node node, string inboundRemoteId, string clientExternalId, VpnProtocol protocol, + string name, int deviceLimit, bool enable, CancellationToken cancellationToken); + + Task> BuildConnectionStringAsync( + Node node, Inbound inbound, string clientExternalId, string clientName, string publicHost, + CancellationToken cancellationToken); +} diff --git a/backend/src/PnvPanel.Application/Common/Messaging/ICommand.cs b/backend/src/PnvPanel.Application/Common/Messaging/ICommand.cs new file mode 100644 index 0000000..ae73398 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Messaging/ICommand.cs @@ -0,0 +1,4 @@ +namespace PnvPanel.Application.Common.Messaging; + +/// Маркер команды CQRS. Команды меняют состояние и идут в транзакции (см. UnitOfWorkBehavior). +public interface ICommand; diff --git a/backend/src/PnvPanel.Application/Common/Messaging/ICommandHandler.cs b/backend/src/PnvPanel.Application/Common/Messaging/ICommandHandler.cs new file mode 100644 index 0000000..963ee24 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Messaging/ICommandHandler.cs @@ -0,0 +1,6 @@ +namespace PnvPanel.Application.Common.Messaging; + +public interface ICommandHandler where TCommand : ICommand +{ + Task Handle(TCommand command, CancellationToken cancellationToken); +} diff --git a/backend/src/PnvPanel.Application/Common/Messaging/IPipelineBehavior.cs b/backend/src/PnvPanel.Application/Common/Messaging/IPipelineBehavior.cs new file mode 100644 index 0000000..dbbce6d --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Messaging/IPipelineBehavior.cs @@ -0,0 +1,8 @@ +namespace PnvPanel.Application.Common.Messaging; + +public delegate Task RequestHandlerDelegate(); + +public interface IPipelineBehavior +{ + Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken); +} diff --git a/backend/src/PnvPanel.Application/Common/Messaging/IQuery.cs b/backend/src/PnvPanel.Application/Common/Messaging/IQuery.cs new file mode 100644 index 0000000..b273f98 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Messaging/IQuery.cs @@ -0,0 +1,4 @@ +namespace PnvPanel.Application.Common.Messaging; + +/// Маркер запроса CQRS. Запросы только читают, без побочных эффектов. +public interface IQuery; diff --git a/backend/src/PnvPanel.Application/Common/Messaging/IQueryHandler.cs b/backend/src/PnvPanel.Application/Common/Messaging/IQueryHandler.cs new file mode 100644 index 0000000..fc8f6b9 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Messaging/IQueryHandler.cs @@ -0,0 +1,6 @@ +namespace PnvPanel.Application.Common.Messaging; + +public interface IQueryHandler where TQuery : IQuery +{ + Task Handle(TQuery query, CancellationToken cancellationToken); +} diff --git a/backend/src/PnvPanel.Application/Common/Messaging/ISender.cs b/backend/src/PnvPanel.Application/Common/Messaging/ISender.cs new file mode 100644 index 0000000..35484f8 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Messaging/ISender.cs @@ -0,0 +1,8 @@ +namespace PnvPanel.Application.Common.Messaging; + +/// Собственный тонкий CQRS-диспетчер (без MediatR). +public interface ISender +{ + Task Send(ICommand command, CancellationToken cancellationToken = default); + Task Send(IQuery query, CancellationToken cancellationToken = default); +} diff --git a/backend/src/PnvPanel.Application/Common/Messaging/Sender.cs b/backend/src/PnvPanel.Application/Common/Messaging/Sender.cs new file mode 100644 index 0000000..6a706f3 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Messaging/Sender.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace PnvPanel.Application.Common.Messaging; + +internal sealed class Sender(IServiceProvider serviceProvider) : ISender +{ + public Task Send(ICommand command, CancellationToken cancellationToken = default) + => Dispatch(command, typeof(ICommandHandler<,>), cancellationToken); + + public Task Send(IQuery query, CancellationToken cancellationToken = default) + => Dispatch(query, typeof(IQueryHandler<,>), cancellationToken); + + private Task Dispatch(object request, Type handlerOpenType, CancellationToken cancellationToken) + { + var requestType = request.GetType(); + var handlerType = handlerOpenType.MakeGenericType(requestType, typeof(TResponse)); + var behaviorType = typeof(IPipelineBehavior<,>).MakeGenericType(requestType, typeof(TResponse)); + + dynamic handler = serviceProvider.GetRequiredService(handlerType); + var behaviors = ((IEnumerable)serviceProvider.GetServices(behaviorType)).Reverse(); + + RequestHandlerDelegate pipeline = () => handler.Handle((dynamic)request, cancellationToken); + + foreach (dynamic behavior in behaviors) + { + var next = pipeline; + pipeline = () => behavior.Handle((dynamic)request, next, cancellationToken); + } + + return pipeline(); + } +} diff --git a/backend/src/PnvPanel.Application/Common/Models/Error.cs b/backend/src/PnvPanel.Application/Common/Models/Error.cs new file mode 100644 index 0000000..2487383 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Models/Error.cs @@ -0,0 +1,23 @@ +namespace PnvPanel.Application.Common.Models; + +public enum ErrorType +{ + Failure, + Validation, + NotFound, + Conflict, + Unauthorized, + Forbidden, +} + +public sealed record Error(string Code, string Message, ErrorType Type = ErrorType.Failure) +{ + public static readonly Error None = new(string.Empty, string.Empty); + + public static Error Validation(string code, string message) => new(code, message, ErrorType.Validation); + public static Error NotFound(string code, string message) => new(code, message, ErrorType.NotFound); + public static Error Conflict(string code, string message) => new(code, message, ErrorType.Conflict); + public static Error Unauthorized(string code, string message) => new(code, message, ErrorType.Unauthorized); + public static Error Forbidden(string code, string message) => new(code, message, ErrorType.Forbidden); + public static Error Failure(string code, string message) => new(code, message, ErrorType.Failure); +} diff --git a/backend/src/PnvPanel.Application/Common/Models/PagedList.cs b/backend/src/PnvPanel.Application/Common/Models/PagedList.cs new file mode 100644 index 0000000..58e402d --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Models/PagedList.cs @@ -0,0 +1,3 @@ +namespace PnvPanel.Application.Common.Models; + +public sealed record PagedList(IReadOnlyList Items, int Total, int Page, int PageSize); diff --git a/backend/src/PnvPanel.Application/Common/Models/PagedListExtensions.cs b/backend/src/PnvPanel.Application/Common/Models/PagedListExtensions.cs new file mode 100644 index 0000000..148e53d --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Models/PagedListExtensions.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; + +namespace PnvPanel.Application.Common.Models; + +public static class PagedListExtensions +{ + public static async Task> ToPagedListAsync( + this IQueryable query, int page, int pageSize, CancellationToken cancellationToken) + { + var total = await query.CountAsync(cancellationToken); + var items = await query.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync(cancellationToken); + return new PagedList(items, total, page, pageSize); + } +} diff --git a/backend/src/PnvPanel.Application/Common/Models/Result.cs b/backend/src/PnvPanel.Application/Common/Models/Result.cs new file mode 100644 index 0000000..533c306 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Models/Result.cs @@ -0,0 +1,37 @@ +namespace PnvPanel.Application.Common.Models; + +public class Result +{ + public bool IsSuccess { get; } + public Error Error { get; } + + protected Result(bool isSuccess, Error error) + { + if (isSuccess && error != Error.None) + throw new InvalidOperationException("Успешный результат не может содержать ошибку."); + if (!isSuccess && error == Error.None) + throw new InvalidOperationException("Неуспешный результат обязан содержать ошибку."); + + IsSuccess = isSuccess; + Error = error; + } + + public static Result Success() => new(true, Error.None); + public static Result Failure(Error error) => new(false, error); + + public static Result Success(T value) => new(value, true, Error.None); + public static Result Failure(Error error) => new(default, false, error); +} + +public class Result : Result +{ + private readonly T? _value; + + internal Result(T? value, bool isSuccess, Error error) : base(isSuccess, error) => _value = value; + + public T Value => IsSuccess + ? _value! + : throw new InvalidOperationException("Нельзя получить значение неуспешного результата."); + + public static implicit operator Result(T value) => Success(value); +} diff --git a/backend/src/PnvPanel.Application/Common/Models/RoleQuota.cs b/backend/src/PnvPanel.Application/Common/Models/RoleQuota.cs new file mode 100644 index 0000000..9f9a183 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Models/RoleQuota.cs @@ -0,0 +1,6 @@ +namespace PnvPanel.Application.Common.Models; + +public static class RoleQuota +{ + public const int Unlimited = -1; +} diff --git a/backend/src/PnvPanel.Application/Configs/ConfigErrors.cs b/backend/src/PnvPanel.Application/Configs/ConfigErrors.cs new file mode 100644 index 0000000..8a733e3 --- /dev/null +++ b/backend/src/PnvPanel.Application/Configs/ConfigErrors.cs @@ -0,0 +1,24 @@ +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Configs; + +public static class ConfigErrors +{ + public static readonly Error NotActivated = + Error.Forbidden("Configs.NotActivated", "Аккаунт не активирован — обратитесь к администратору."); + + public static readonly Error InboundNotAvailable = + Error.NotFound("Configs.InboundNotAvailable", "Инбаунд недоступен."); + + public static readonly Error InboundNotAllowedForRole = + Error.Forbidden("Configs.InboundNotAllowedForRole", "Ваша роль не даёт доступ к этому инбаунду."); + + public static readonly Error NodeDisabled = + Error.Forbidden("Configs.NodeDisabled", "Сервер временно недоступен для новых конфигов."); + + public static readonly Error QuotaExceeded = + Error.Conflict("Configs.QuotaExceeded", "Достигнут лимит конфигов для вашей роли."); + + public static readonly Error NotFound = + Error.NotFound("Configs.NotFound", "Конфиг не найден."); +} diff --git a/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommand.cs b/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommand.cs new file mode 100644 index 0000000..ae2bf6c --- /dev/null +++ b/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommand.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Configs.Create; + +public sealed record CreateVpnConfigCommand(Guid InboundId, string? Label, int? DeviceLimit) : ICommand>; diff --git a/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs b/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs new file mode 100644 index 0000000..4b4e6d3 --- /dev/null +++ b/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs @@ -0,0 +1,92 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Auth; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Configs; + +namespace PnvPanel.Application.Configs.Create; + +public sealed class CreateVpnConfigCommandHandler( + IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway, ICurrentUser currentUser) + : ICommandHandler> +{ + public async Task> Handle(CreateVpnConfigCommand command, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return Result.Failure(AuthErrors.Unauthorized); + + var profile = await identityService.GetProfileAsync(userId, cancellationToken); + if (profile is null) + return Result.Failure(AuthErrors.Unauthorized); + + if (!profile.IsActivated) + return Result.Failure(ConfigErrors.NotActivated); + + var inbound = await dbContext.Inbounds.AsNoTracking() + .FirstOrDefaultAsync(i => i.Id == command.InboundId, cancellationToken); + + if (inbound is null || !inbound.IsPublished) + return Result.Failure(ConfigErrors.InboundNotAvailable); + + if (!inbound.AllowedRoleIds.Contains(profile.RoleId)) + return Result.Failure(ConfigErrors.InboundNotAllowedForRole); + + var node = await dbContext.Nodes.AsNoTracking().FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken); + if (node is null || !node.IsEnabled) + return Result.Failure(ConfigErrors.NodeDisabled); + + var config = VpnConfig.Create(userId, inbound.Id, inbound.Protocol, command.Label, command.DeviceLimit ?? 0); + + var reserveResult = await ReserveQuotaSlotAsync(userId, profile.MaxConfigs, config, cancellationToken); + if (!reserveResult.IsSuccess) + return Result.Failure(reserveResult.Error); + + var addResult = await gateway.AddClientAsync( + node, inbound.RemoteInboundId, inbound.Protocol, config.ClientEmail, + config.Label ?? config.ClientEmail, config.DeviceLimit, cancellationToken); + + if (!addResult.IsSuccess) + { + // Компенсация: квота была зарезервирована локально, но клиент в 3x-ui не создался — + // откатываем резервирование, наружу не оставляем "мёртвую" запись. + dbContext.VpnConfigs.Remove(config); + await dbContext.SaveChangesAsync(cancellationToken); + return Result.Failure(addResult.Error); + } + + config.AssignRemoteClient(addResult.Value); + await dbContext.SaveChangesAsync(cancellationToken); + + return Result.Success(VpnConfigDto.FromDomain(config, inbound)); + } + + /// + /// Проверка квоты + резервирование строки — под pg_advisory_xact_lock (гонки параллельных + /// созданий, см. CLAUDE.md). Лок держится только на время короткой транзакции count+insert, + /// НЕ на время внешнего HTTP-вызова к 3x-ui — иначе рискуем держать соединение к БД открытым + /// на секунды под внешним I/O. + /// + private async Task ReserveQuotaSlotAsync(Guid userId, int maxConfigs, VpnConfig config, CancellationToken cancellationToken) + { + await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); + + await dbContext.Database.ExecuteSqlInterpolatedAsync( + $"SELECT pg_advisory_xact_lock(hashtext({userId.ToString()}))", cancellationToken); + + var activeCount = await dbContext.VpnConfigs + .CountAsync(c => c.UserId == userId && c.Status == ConfigStatus.Active, cancellationToken); + + if (maxConfigs != RoleQuota.Unlimited && activeCount >= maxConfigs) + { + await transaction.RollbackAsync(cancellationToken); + return Result.Failure(ConfigErrors.QuotaExceeded); + } + + dbContext.VpnConfigs.Add(config); + await dbContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandValidator.cs b/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandValidator.cs new file mode 100644 index 0000000..2b62b36 --- /dev/null +++ b/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace PnvPanel.Application.Configs.Create; + +public sealed class CreateVpnConfigCommandValidator : AbstractValidator +{ + public CreateVpnConfigCommandValidator() + { + RuleFor(x => x.InboundId).NotEmpty(); + RuleFor(x => x.Label).MaximumLength(100); + RuleFor(x => x.DeviceLimit).GreaterThanOrEqualTo(0).When(x => x.DeviceLimit.HasValue); + } +} diff --git a/backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommand.cs b/backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommand.cs new file mode 100644 index 0000000..0a44716 --- /dev/null +++ b/backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommand.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Configs.Edit; + +public sealed record EditVpnConfigCommand(Guid ConfigId, string? Label, int? DeviceLimit) : ICommand>; diff --git a/backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommandHandler.cs b/backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommandHandler.cs new file mode 100644 index 0000000..4a00e6b --- /dev/null +++ b/backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommandHandler.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Auth; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Configs.Edit; + +public sealed class EditVpnConfigCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway, ICurrentUser currentUser) + : ICommandHandler> +{ + public async Task> Handle(EditVpnConfigCommand command, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return Result.Failure(AuthErrors.Unauthorized); + + var config = await dbContext.VpnConfigs + .FirstOrDefaultAsync(c => c.Id == command.ConfigId && c.UserId == userId, cancellationToken); + if (config is null) + return Result.Failure(ConfigErrors.NotFound); + + var inbound = await dbContext.Inbounds.AsNoTracking() + .FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken); + if (inbound is null) + return Result.Failure(ConfigErrors.InboundNotAvailable); + + if (command.Label is not null) + config.Rename(command.Label); + + if (command.DeviceLimit is { } deviceLimit) + { + config.SetDeviceLimit(deviceLimit); + + var node = await dbContext.Nodes.AsNoTracking().FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken); + if (node is not null) + { + await gateway.UpdateClientAsync( + node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, + config.Label ?? config.ClientEmail, deviceLimit, enable: true, cancellationToken); + } + } + + return Result.Success(VpnConfigDto.FromDomain(config, inbound)); + } +} diff --git a/backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommandValidator.cs b/backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommandValidator.cs new file mode 100644 index 0000000..48d5735 --- /dev/null +++ b/backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace PnvPanel.Application.Configs.Edit; + +public sealed class EditVpnConfigCommandValidator : AbstractValidator +{ + public EditVpnConfigCommandValidator() + { + RuleFor(x => x.Label).MaximumLength(100); + RuleFor(x => x.DeviceLimit).GreaterThanOrEqualTo(0).When(x => x.DeviceLimit.HasValue); + } +} diff --git a/backend/src/PnvPanel.Application/Configs/GetConfigLink/GetConfigLinkQuery.cs b/backend/src/PnvPanel.Application/Configs/GetConfigLink/GetConfigLinkQuery.cs new file mode 100644 index 0000000..b73ecc1 --- /dev/null +++ b/backend/src/PnvPanel.Application/Configs/GetConfigLink/GetConfigLinkQuery.cs @@ -0,0 +1,9 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Configs.GetConfigLink; + +public sealed record GetConfigLinkQuery(Guid ConfigId) : IQuery>; + +/// SubscriptionToken — Api-слой строит из него абсолютный URL (знает scheme/host запроса). +public sealed record ConfigLinkDto(string ConnectionString, string SubscriptionToken); diff --git a/backend/src/PnvPanel.Application/Configs/GetConfigLink/GetConfigLinkQueryHandler.cs b/backend/src/PnvPanel.Application/Configs/GetConfigLink/GetConfigLinkQueryHandler.cs new file mode 100644 index 0000000..542ddeb --- /dev/null +++ b/backend/src/PnvPanel.Application/Configs/GetConfigLink/GetConfigLinkQueryHandler.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Auth; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Configs.GetConfigLink; + +public sealed class GetConfigLinkQueryHandler(IAppDbContext dbContext, IXuiPanelGateway gateway, ICurrentUser currentUser) + : IQueryHandler> +{ + public async Task> Handle(GetConfigLinkQuery query, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return Result.Failure(AuthErrors.Unauthorized); + + var config = await dbContext.VpnConfigs.AsNoTracking() + .FirstOrDefaultAsync(c => c.Id == query.ConfigId && c.UserId == userId, cancellationToken); + if (config is null) + return Result.Failure(ConfigErrors.NotFound); + + var inbound = await dbContext.Inbounds.AsNoTracking().FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken); + if (inbound is null) + return Result.Failure(ConfigErrors.InboundNotAvailable); + + var node = await dbContext.Nodes.AsNoTracking().FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken); + if (node is null) + return Result.Failure(ConfigErrors.NodeDisabled); + + var linkResult = await gateway.BuildConnectionStringAsync( + node, inbound, config.ClientExternalId, config.Label ?? config.ClientEmail, node.BaseAddress.Host, cancellationToken); + + if (!linkResult.IsSuccess) + return Result.Failure(linkResult.Error); + + return Result.Success(new ConfigLinkDto(linkResult.Value, config.SubscriptionToken)); + } +} diff --git a/backend/src/PnvPanel.Application/Configs/GetMyConfigs/GetMyConfigsQuery.cs b/backend/src/PnvPanel.Application/Configs/GetMyConfigs/GetMyConfigsQuery.cs new file mode 100644 index 0000000..7098eb6 --- /dev/null +++ b/backend/src/PnvPanel.Application/Configs/GetMyConfigs/GetMyConfigsQuery.cs @@ -0,0 +1,8 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Configs.GetMyConfigs; + +public sealed record GetMyConfigsQuery : IQuery>; + +public sealed record GetMyConfigsResult(IReadOnlyList Configs, int MaxConfigs); diff --git a/backend/src/PnvPanel.Application/Configs/GetMyConfigs/GetMyConfigsQueryHandler.cs b/backend/src/PnvPanel.Application/Configs/GetMyConfigs/GetMyConfigsQueryHandler.cs new file mode 100644 index 0000000..d97b66e --- /dev/null +++ b/backend/src/PnvPanel.Application/Configs/GetMyConfigs/GetMyConfigsQueryHandler.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Auth; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Configs; + +namespace PnvPanel.Application.Configs.GetMyConfigs; + +public sealed class GetMyConfigsQueryHandler(IAppDbContext dbContext, IIdentityService identityService, ICurrentUser currentUser) + : IQueryHandler> +{ + public async Task> Handle(GetMyConfigsQuery query, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return Result.Failure(AuthErrors.Unauthorized); + + var profile = await identityService.GetProfileAsync(userId, cancellationToken); + if (profile is null) + return Result.Failure(AuthErrors.Unauthorized); + + var rows = await dbContext.VpnConfigs.AsNoTracking() + .Where(c => c.UserId == userId && c.Status != ConfigStatus.Revoked) + .Join(dbContext.Inbounds.AsNoTracking(), c => c.InboundId, i => i.Id, (c, i) => new { Config = c, Inbound = i }) + .OrderByDescending(x => x.Config.CreatedAt) + .ToListAsync(cancellationToken); + + var dtos = rows.Select(x => VpnConfigDto.FromDomain(x.Config, x.Inbound)).ToList(); + + return Result.Success(new GetMyConfigsResult(dtos, profile.MaxConfigs)); + } +} diff --git a/backend/src/PnvPanel.Application/Configs/ListAvailableInbounds/ListAvailableInboundsQuery.cs b/backend/src/PnvPanel.Application/Configs/ListAvailableInbounds/ListAvailableInboundsQuery.cs new file mode 100644 index 0000000..d531332 --- /dev/null +++ b/backend/src/PnvPanel.Application/Configs/ListAvailableInbounds/ListAvailableInboundsQuery.cs @@ -0,0 +1,10 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Inbounds; + +namespace PnvPanel.Application.Configs.ListAvailableInbounds; + +public sealed record ListAvailableInboundsQuery : IQuery>>; + +/// Витринная карточка инбаунда для выбора при создании конфига — без деталей 3x-ui. +public sealed record AvailableInboundDto(Guid InboundId, string DisplayName, VpnProtocol Protocol); diff --git a/backend/src/PnvPanel.Application/Configs/ListAvailableInbounds/ListAvailableInboundsQueryHandler.cs b/backend/src/PnvPanel.Application/Configs/ListAvailableInbounds/ListAvailableInboundsQueryHandler.cs new file mode 100644 index 0000000..387841c --- /dev/null +++ b/backend/src/PnvPanel.Application/Configs/ListAvailableInbounds/ListAvailableInboundsQueryHandler.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Auth; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Configs.ListAvailableInbounds; + +public sealed class ListAvailableInboundsQueryHandler(IAppDbContext dbContext, IIdentityService identityService, ICurrentUser currentUser) + : IQueryHandler>> +{ + public async Task>> Handle(ListAvailableInboundsQuery query, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return Result.Failure>(AuthErrors.Unauthorized); + + var profile = await identityService.GetProfileAsync(userId, cancellationToken); + if (profile is null) + return Result.Failure>(AuthErrors.Unauthorized); + + var enabledNodeIds = dbContext.Nodes.AsNoTracking().Where(n => n.IsEnabled).Select(n => n.Id); + + var inbounds = await dbContext.Inbounds.AsNoTracking() + .Where(i => i.IsPublished && enabledNodeIds.Contains(i.NodeId) && i.AllowedRoleIds.Contains(profile.RoleId)) + .OrderBy(i => i.DisplayName ?? i.Remark) + .Select(i => new AvailableInboundDto(i.Id, i.DisplayName ?? i.Remark, i.Protocol)) + .ToListAsync(cancellationToken); + + return Result.Success>(inbounds); + } +} diff --git a/backend/src/PnvPanel.Application/Configs/Revoke/RevokeVpnConfigCommand.cs b/backend/src/PnvPanel.Application/Configs/Revoke/RevokeVpnConfigCommand.cs new file mode 100644 index 0000000..28b87bf --- /dev/null +++ b/backend/src/PnvPanel.Application/Configs/Revoke/RevokeVpnConfigCommand.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Configs.Revoke; + +public sealed record RevokeVpnConfigCommand(Guid ConfigId) : ICommand; diff --git a/backend/src/PnvPanel.Application/Configs/Revoke/RevokeVpnConfigCommandHandler.cs b/backend/src/PnvPanel.Application/Configs/Revoke/RevokeVpnConfigCommandHandler.cs new file mode 100644 index 0000000..4e1270a --- /dev/null +++ b/backend/src/PnvPanel.Application/Configs/Revoke/RevokeVpnConfigCommandHandler.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Auth; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Configs; + +namespace PnvPanel.Application.Configs.Revoke; + +public sealed class RevokeVpnConfigCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway, ICurrentUser currentUser) + : ICommandHandler +{ + public async Task Handle(RevokeVpnConfigCommand command, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return Result.Failure(AuthErrors.Unauthorized); + + var config = await dbContext.VpnConfigs + .FirstOrDefaultAsync(c => c.Id == command.ConfigId && c.UserId == userId, 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 node = inbound is null + ? null + : await dbContext.Nodes.AsNoTracking().FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken); + + if (inbound is not null && node is not null) + await gateway.RemoveClientAsync(node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, cancellationToken); + + config.Revoke(); + return Result.Success(); + } +} diff --git a/backend/src/PnvPanel.Application/Configs/Rotate/RotateVpnConfigCommand.cs b/backend/src/PnvPanel.Application/Configs/Rotate/RotateVpnConfigCommand.cs new file mode 100644 index 0000000..68327fb --- /dev/null +++ b/backend/src/PnvPanel.Application/Configs/Rotate/RotateVpnConfigCommand.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Configs.Rotate; + +public sealed record RotateVpnConfigCommand(Guid ConfigId) : ICommand>; diff --git a/backend/src/PnvPanel.Application/Configs/Rotate/RotateVpnConfigCommandHandler.cs b/backend/src/PnvPanel.Application/Configs/Rotate/RotateVpnConfigCommandHandler.cs new file mode 100644 index 0000000..d3aa912 --- /dev/null +++ b/backend/src/PnvPanel.Application/Configs/Rotate/RotateVpnConfigCommandHandler.cs @@ -0,0 +1,53 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Auth; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Configs; + +namespace PnvPanel.Application.Configs.Rotate; + +public sealed class RotateVpnConfigCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway, ICurrentUser currentUser) + : ICommandHandler> +{ + public async Task> Handle(RotateVpnConfigCommand command, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return Result.Failure(AuthErrors.Unauthorized); + + var config = await dbContext.VpnConfigs + .FirstOrDefaultAsync(c => c.Id == command.ConfigId && c.UserId == userId, cancellationToken); + if (config is null) + return Result.Failure(ConfigErrors.NotFound); + + if (config.Status != ConfigStatus.Active) + return Result.Failure(ConfigErrors.NotFound); + + var inbound = await dbContext.Inbounds.AsNoTracking() + .FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken); + if (inbound is null) + return Result.Failure(ConfigErrors.InboundNotAvailable); + + var node = await dbContext.Nodes.AsNoTracking().FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken); + if (node is null) + return Result.Failure(ConfigErrors.NodeDisabled); + + var newClientEmail = VpnConfig.GenerateClientEmail(userId); + var addResult = await gateway.AddClientAsync( + node, inbound.RemoteInboundId, config.Protocol, newClientEmail, + config.Label ?? newClientEmail, config.DeviceLimit, cancellationToken); + + if (!addResult.IsSuccess) + return Result.Failure(addResult.Error); + + var oldClientExternalId = config.ClientExternalId; + config.Rotate(newClientEmail, addResult.Value); + await dbContext.SaveChangesAsync(cancellationToken); + + // Старого клиента удаляем ПОСЛЕ коммита нового состояния: если удаление не выйдет, + // у пользователя просто останется лишний нерабочий-для-него клиент в панели — не критично. + await gateway.RemoveClientAsync(node, inbound.RemoteInboundId, oldClientExternalId, config.Protocol, cancellationToken); + + return Result.Success(VpnConfigDto.FromDomain(config, inbound)); + } +} diff --git a/backend/src/PnvPanel.Application/Configs/VpnConfigDto.cs b/backend/src/PnvPanel.Application/Configs/VpnConfigDto.cs new file mode 100644 index 0000000..05dec67 --- /dev/null +++ b/backend/src/PnvPanel.Application/Configs/VpnConfigDto.cs @@ -0,0 +1,17 @@ +using PnvPanel.Domain.Configs; +using PnvPanel.Domain.Inbounds; + +namespace PnvPanel.Application.Configs; + +/// +/// Пользователю показываем только DisplayName + протокол инбаунда — адрес/хост ноды и прочие +/// детали 3x-ui в этот DTO не попадают (см. domain-model.md). +/// +public sealed record VpnConfigDto( + Guid Id, string? Label, VpnProtocol Protocol, string Location, int DeviceLimit, + long UsedUpBytes, long UsedDownBytes, DateTimeOffset? ExpiresAt, ConfigStatus Status, DateTimeOffset CreatedAt) +{ + public static VpnConfigDto FromDomain(VpnConfig config, Inbound inbound) => new( + config.Id, config.Label, config.Protocol, inbound.DisplayName ?? inbound.Remark, config.DeviceLimit, + config.UsedUpBytes, config.UsedDownBytes, config.ExpiresAt, config.Status, config.CreatedAt); +} diff --git a/backend/src/PnvPanel.Application/DependencyInjection.cs b/backend/src/PnvPanel.Application/DependencyInjection.cs new file mode 100644 index 0000000..2c88066 --- /dev/null +++ b/backend/src/PnvPanel.Application/DependencyInjection.cs @@ -0,0 +1,44 @@ +using System.Reflection; +using FluentValidation; +using Microsoft.Extensions.DependencyInjection; +using PnvPanel.Application.Common.Behaviors; +using PnvPanel.Application.Common.Messaging; + +namespace PnvPanel.Application; + +/// +/// Точка регистрации сервисов слоя Application: собственный CQRS-диспетчер (ISender), +/// хендлеры/валидаторы (авто-сканирование сборки) и pipeline behaviors — в порядке выполнения. +/// +public static class DependencyInjection +{ + public static IServiceCollection AddApplication(this IServiceCollection services) + { + var assembly = typeof(DependencyInjection).Assembly; + + services.AddScoped(); + + RegisterClosedGeneric(services, assembly, typeof(ICommandHandler<,>)); + RegisterClosedGeneric(services, assembly, typeof(IQueryHandler<,>)); + RegisterClosedGeneric(services, assembly, typeof(IValidator<>)); + + // Порядок важен: Logging (снаружи) -> Validation -> UnitOfWork (ближе к хендлеру). + services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>)); + services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); + services.AddScoped(typeof(IPipelineBehavior<,>), typeof(UnitOfWorkBehavior<,>)); + + return services; + } + + private static void RegisterClosedGeneric(IServiceCollection services, Assembly assembly, Type openInterface) + { + var implementations = assembly.GetTypes() + .Where(t => t is { IsClass: true, IsAbstract: false }) + .SelectMany(t => t.GetInterfaces() + .Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == openInterface) + .Select(i => (Service: i, Implementation: t))); + + foreach (var (service, implementation) in implementations) + services.AddScoped(service, implementation); + } +} diff --git a/backend/src/PnvPanel.Application/PnvPanel.Application.csproj b/backend/src/PnvPanel.Application/PnvPanel.Application.csproj new file mode 100644 index 0000000..80636e6 --- /dev/null +++ b/backend/src/PnvPanel.Application/PnvPanel.Application.csproj @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/backend/src/PnvPanel.Application/Subscriptions/GetConfigSubscriptionQuery.cs b/backend/src/PnvPanel.Application/Subscriptions/GetConfigSubscriptionQuery.cs new file mode 100644 index 0000000..7749653 --- /dev/null +++ b/backend/src/PnvPanel.Application/Subscriptions/GetConfigSubscriptionQuery.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Subscriptions; + +public sealed record GetConfigSubscriptionQuery(string Token) : IQuery>; diff --git a/backend/src/PnvPanel.Application/Subscriptions/GetConfigSubscriptionQueryHandler.cs b/backend/src/PnvPanel.Application/Subscriptions/GetConfigSubscriptionQueryHandler.cs new file mode 100644 index 0000000..eaf9da1 --- /dev/null +++ b/backend/src/PnvPanel.Application/Subscriptions/GetConfigSubscriptionQueryHandler.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Configs; + +namespace PnvPanel.Application.Subscriptions; + +public sealed class GetConfigSubscriptionQueryHandler(IAppDbContext dbContext, IXuiPanelGateway gateway) + : IQueryHandler> +{ + public async Task> Handle(GetConfigSubscriptionQuery query, CancellationToken cancellationToken) + { + var config = await dbContext.VpnConfigs.AsNoTracking() + .FirstOrDefaultAsync(c => c.SubscriptionToken == query.Token && c.Status == ConfigStatus.Active, cancellationToken); + + if (config is null) + return Result.Failure(SubscriptionErrors.NotFound); + + return await SubscriptionAssembler.BuildAsync([config], dbContext, gateway, cancellationToken); + } +} diff --git a/backend/src/PnvPanel.Application/Subscriptions/GetUserSubscriptionQuery.cs b/backend/src/PnvPanel.Application/Subscriptions/GetUserSubscriptionQuery.cs new file mode 100644 index 0000000..612f620 --- /dev/null +++ b/backend/src/PnvPanel.Application/Subscriptions/GetUserSubscriptionQuery.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Subscriptions; + +public sealed record GetUserSubscriptionQuery(string Token) : IQuery>; diff --git a/backend/src/PnvPanel.Application/Subscriptions/GetUserSubscriptionQueryHandler.cs b/backend/src/PnvPanel.Application/Subscriptions/GetUserSubscriptionQueryHandler.cs new file mode 100644 index 0000000..bd64411 --- /dev/null +++ b/backend/src/PnvPanel.Application/Subscriptions/GetUserSubscriptionQueryHandler.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Configs; + +namespace PnvPanel.Application.Subscriptions; + +public sealed class GetUserSubscriptionQueryHandler(IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway) + : IQueryHandler> +{ + public async Task> Handle(GetUserSubscriptionQuery query, CancellationToken cancellationToken) + { + var userId = await identityService.FindUserIdBySubscriptionTokenAsync(query.Token, cancellationToken); + if (userId is null) + return Result.Failure(SubscriptionErrors.NotFound); + + var configs = await dbContext.VpnConfigs.AsNoTracking() + .Where(c => c.UserId == userId && c.Status == ConfigStatus.Active) + .ToListAsync(cancellationToken); + + return await SubscriptionAssembler.BuildAsync(configs, dbContext, gateway, cancellationToken); + } +} diff --git a/backend/src/PnvPanel.Application/Subscriptions/SubscriptionAssembler.cs b/backend/src/PnvPanel.Application/Subscriptions/SubscriptionAssembler.cs new file mode 100644 index 0000000..211658f --- /dev/null +++ b/backend/src/PnvPanel.Application/Subscriptions/SubscriptionAssembler.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Configs; + +namespace PnvPanel.Application.Subscriptions; + +/// Общая сборка подписки: и агрегированная (все конфиги юзера), и по одному конфигу. +internal static class SubscriptionAssembler +{ + public static async Task> BuildAsync( + IReadOnlyList configs, IAppDbContext dbContext, IXuiPanelGateway gateway, CancellationToken cancellationToken) + { + var lines = new List(); + long usedUp = 0; + long usedDown = 0; + DateTimeOffset? expiresAt = null; + + foreach (var config in configs) + { + var inbound = await dbContext.Inbounds.AsNoTracking() + .FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken); + if (inbound is null) + continue; + + var node = await dbContext.Nodes.AsNoTracking() + .FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken); + if (node is null || !node.IsEnabled) + continue; + + var linkResult = await gateway.BuildConnectionStringAsync( + node, inbound, config.ClientExternalId, config.Label ?? config.ClientEmail, node.BaseAddress.Host, cancellationToken); + + if (linkResult.IsSuccess) + lines.Add(linkResult.Value); + + usedUp += config.UsedUpBytes; + usedDown += config.UsedDownBytes; + if (config.ExpiresAt is { } exp && (expiresAt is null || exp > expiresAt)) + expiresAt = exp; + } + + return Result.Success(new SubscriptionDto(lines, usedUp, usedDown, expiresAt)); + } +} diff --git a/backend/src/PnvPanel.Application/Subscriptions/SubscriptionDto.cs b/backend/src/PnvPanel.Application/Subscriptions/SubscriptionDto.cs new file mode 100644 index 0000000..cbe3743 --- /dev/null +++ b/backend/src/PnvPanel.Application/Subscriptions/SubscriptionDto.cs @@ -0,0 +1,4 @@ +namespace PnvPanel.Application.Subscriptions; + +public sealed record SubscriptionDto( + IReadOnlyList ConnectionStrings, long UsedUpBytes, long UsedDownBytes, DateTimeOffset? ExpiresAt); diff --git a/backend/src/PnvPanel.Application/Subscriptions/SubscriptionErrors.cs b/backend/src/PnvPanel.Application/Subscriptions/SubscriptionErrors.cs new file mode 100644 index 0000000..ee80330 --- /dev/null +++ b/backend/src/PnvPanel.Application/Subscriptions/SubscriptionErrors.cs @@ -0,0 +1,8 @@ +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Subscriptions; + +public static class SubscriptionErrors +{ + public static readonly Error NotFound = Error.NotFound("Subscription.NotFound", "Подписка не найдена."); +} diff --git a/backend/src/PnvPanel.Domain/Activation/ActivationRequest.cs b/backend/src/PnvPanel.Domain/Activation/ActivationRequest.cs new file mode 100644 index 0000000..e1a037f --- /dev/null +++ b/backend/src/PnvPanel.Domain/Activation/ActivationRequest.cs @@ -0,0 +1,58 @@ +using PnvPanel.Domain.Common; +using PnvPanel.Domain.Exceptions; + +namespace PnvPanel.Domain.Activation; + +/// +/// Запрос пользователя на активацию (с комментарием), решение принимает админ на сайте или в Telegram. +/// Одновременно не более одного Pending-запроса на пользователя (инвариант проверяется на уровне Application). +/// +public sealed class ActivationRequest : Entity +{ + public Guid UserId { get; private set; } + public string? Comment { get; private set; } + public ActivationStatus Status { get; private set; } + public Guid? DecidedBy { get; private set; } + public DateTimeOffset? DecidedAt { get; private set; } + public string? RejectionReason { get; private set; } + public DateTimeOffset CreatedAt { get; private set; } + + private ActivationRequest() + { + } + + public static ActivationRequest Create(Guid userId, string? comment) + { + return new ActivationRequest + { + Id = Guid.NewGuid(), + UserId = userId, + Comment = comment, + Status = ActivationStatus.Pending, + CreatedAt = DateTimeOffset.UtcNow, + }; + } + + public void Approve(Guid decidedBy) + { + EnsurePending(); + Status = ActivationStatus.Approved; + DecidedBy = decidedBy; + DecidedAt = DateTimeOffset.UtcNow; + } + + public void Reject(Guid decidedBy, string? reason) + { + EnsurePending(); + Status = ActivationStatus.Rejected; + DecidedBy = decidedBy; + DecidedAt = DateTimeOffset.UtcNow; + RejectionReason = reason; + } + + private void EnsurePending() + { + if (Status != ActivationStatus.Pending) + throw new DomainException("Запрос на активацию уже обработан."); + } +} diff --git a/backend/src/PnvPanel.Domain/Activation/ActivationStatus.cs b/backend/src/PnvPanel.Domain/Activation/ActivationStatus.cs new file mode 100644 index 0000000..5cd92bf --- /dev/null +++ b/backend/src/PnvPanel.Domain/Activation/ActivationStatus.cs @@ -0,0 +1,8 @@ +namespace PnvPanel.Domain.Activation; + +public enum ActivationStatus +{ + Pending, + Approved, + Rejected, +} diff --git a/backend/src/PnvPanel.Domain/Apps/ClientApp.cs b/backend/src/PnvPanel.Domain/Apps/ClientApp.cs new file mode 100644 index 0000000..b9d8182 --- /dev/null +++ b/backend/src/PnvPanel.Domain/Apps/ClientApp.cs @@ -0,0 +1,45 @@ +using PnvPanel.Domain.Common; + +namespace PnvPanel.Domain.Apps; + +/// Каталог рекомендуемых приложений-клиентов для подключения, ведёт админ. +public sealed class ClientApp : Entity +{ + public string Name { get; private set; } = string.Empty; + public Uri DownloadUrl { get; private set; } = null!; + public OsPlatform OperatingSystem { get; private set; } + public string? Description { get; private set; } + public string? IconUrl { get; private set; } + public int SortOrder { get; private set; } + public bool IsEnabled { get; private set; } + + private ClientApp() + { + } + + public static ClientApp Create(string name, Uri downloadUrl, OsPlatform operatingSystem, string? description, string? iconUrl, int sortOrder) + { + return new ClientApp + { + Id = Guid.NewGuid(), + Name = name, + DownloadUrl = downloadUrl, + OperatingSystem = operatingSystem, + Description = description, + IconUrl = iconUrl, + SortOrder = sortOrder, + IsEnabled = true, + }; + } + + public void Update(string name, Uri downloadUrl, OsPlatform operatingSystem, string? description, string? iconUrl, int sortOrder, bool isEnabled) + { + Name = name; + DownloadUrl = downloadUrl; + OperatingSystem = operatingSystem; + Description = description; + IconUrl = iconUrl; + SortOrder = sortOrder; + IsEnabled = isEnabled; + } +} diff --git a/backend/src/PnvPanel.Domain/Apps/OsPlatform.cs b/backend/src/PnvPanel.Domain/Apps/OsPlatform.cs new file mode 100644 index 0000000..fabb7d1 --- /dev/null +++ b/backend/src/PnvPanel.Domain/Apps/OsPlatform.cs @@ -0,0 +1,10 @@ +namespace PnvPanel.Domain.Apps; + +public enum OsPlatform +{ + IOS, + Android, + Windows, + MacOS, + Linux, +} diff --git a/backend/src/PnvPanel.Domain/Common/Entity.cs b/backend/src/PnvPanel.Domain/Common/Entity.cs new file mode 100644 index 0000000..01ede97 --- /dev/null +++ b/backend/src/PnvPanel.Domain/Common/Entity.cs @@ -0,0 +1,14 @@ +namespace PnvPanel.Domain.Common; + +public abstract class Entity +{ + public Guid Id { get; protected init; } + + public override bool Equals(object? obj) => obj is Entity other && other.GetType() == GetType() && other.Id == Id; + + public override int GetHashCode() => HashCode.Combine(GetType(), Id); + + public static bool operator ==(Entity? left, Entity? right) => Equals(left, right); + + public static bool operator !=(Entity? left, Entity? right) => !Equals(left, right); +} diff --git a/backend/src/PnvPanel.Domain/Configs/ConfigStatus.cs b/backend/src/PnvPanel.Domain/Configs/ConfigStatus.cs new file mode 100644 index 0000000..161c09a --- /dev/null +++ b/backend/src/PnvPanel.Domain/Configs/ConfigStatus.cs @@ -0,0 +1,10 @@ +namespace PnvPanel.Domain.Configs; + +public enum ConfigStatus +{ + Active, + Disabled, + Expired, + LimitReached, + Revoked, +} diff --git a/backend/src/PnvPanel.Domain/Configs/VpnConfig.cs b/backend/src/PnvPanel.Domain/Configs/VpnConfig.cs new file mode 100644 index 0000000..1bee4f9 --- /dev/null +++ b/backend/src/PnvPanel.Domain/Configs/VpnConfig.cs @@ -0,0 +1,89 @@ +using System.Security.Cryptography; +using PnvPanel.Domain.Common; +using PnvPanel.Domain.Exceptions; +using PnvPanel.Domain.Inbounds; + +namespace PnvPanel.Domain.Configs; + +/// +/// Один конфиг = один клиент в 3x-ui, привязанный к пользователю. ClientExternalId — то, что +/// вернула панель при создании клиента (ThreeXui.Net отдаёт его как string — формат зависит от +/// протокола: UUID для VLESS/VMess, пароль для Trojan/Shadowsocks). +/// +public sealed class VpnConfig : Entity +{ + public Guid UserId { get; private set; } + public Guid InboundId { get; private set; } + public string? Label { get; private set; } + public string ClientEmail { get; private set; } = string.Empty; + public string ClientExternalId { get; private set; } = string.Empty; + public VpnProtocol Protocol { get; private set; } + public int DeviceLimit { get; private set; } + public long UsedUpBytes { get; private set; } + public long UsedDownBytes { get; private set; } + public DateTimeOffset? ExpiresAt { get; private set; } + public ConfigStatus Status { get; private set; } + public string SubscriptionToken { get; private set; } = string.Empty; + public DateTimeOffset? LastSyncAt { get; private set; } + public DateTimeOffset CreatedAt { get; private set; } + + private VpnConfig() + { + } + + public static VpnConfig Create(Guid userId, Guid inboundId, VpnProtocol protocol, string? label, int deviceLimit) + { + return new VpnConfig + { + Id = Guid.NewGuid(), + UserId = userId, + InboundId = inboundId, + Protocol = protocol, + ClientEmail = GenerateClientEmail(userId), + ClientExternalId = string.Empty, + Label = label, + DeviceLimit = deviceLimit, + Status = ConfigStatus.Active, + SubscriptionToken = GenerateToken(), + CreatedAt = DateTimeOffset.UtcNow, + }; + } + + /// Проставляется после успешного ответа от 3x-ui (см. IXuiPanelGateway.AddClientAsync). + public void AssignRemoteClient(string clientExternalId) => ClientExternalId = clientExternalId; + + public void Rename(string? label) => Label = label; + + public void SetDeviceLimit(int deviceLimit) => DeviceLimit = deviceLimit; + + public void Rotate(string newClientEmail, string newClientExternalId) + { + EnsureActive("перевыпустить"); + ClientEmail = newClientEmail; + ClientExternalId = newClientExternalId; + SubscriptionToken = GenerateToken(); + } + + public void Revoke() + { + if (Status == ConfigStatus.Revoked) + throw new DomainException("Конфиг уже отозван."); + + Status = ConfigStatus.Revoked; + } + + private void EnsureActive(string action) + { + if (Status != ConfigStatus.Active) + throw new DomainException($"Нельзя {action} конфиг в статусе {Status}."); + } + + public static string GenerateClientEmail(Guid userId) + { + var shortId = userId.ToString("N")[..8]; + var rand = Convert.ToHexString(RandomNumberGenerator.GetBytes(4)).ToLowerInvariant(); + return $"pnv_{shortId}_{rand}"; + } + + private static string GenerateToken() => Convert.ToHexString(RandomNumberGenerator.GetBytes(32)); +} diff --git a/backend/src/PnvPanel.Domain/Exceptions/DomainException.cs b/backend/src/PnvPanel.Domain/Exceptions/DomainException.cs new file mode 100644 index 0000000..55c6e3d --- /dev/null +++ b/backend/src/PnvPanel.Domain/Exceptions/DomainException.cs @@ -0,0 +1,4 @@ +namespace PnvPanel.Domain.Exceptions; + +/// Нарушение инварианта домена. На границе Application транслируется в Result-ошибку. +public class DomainException(string message) : Exception(message); diff --git a/backend/src/PnvPanel.Domain/Inbounds/Inbound.cs b/backend/src/PnvPanel.Domain/Inbounds/Inbound.cs new file mode 100644 index 0000000..ac372ae --- /dev/null +++ b/backend/src/PnvPanel.Domain/Inbounds/Inbound.cs @@ -0,0 +1,60 @@ +using PnvPanel.Domain.Common; + +namespace PnvPanel.Domain.Inbounds; + +/// +/// Проекция inbound из 3x-ui. — идентификатор на стороне панели +/// (в ThreeXui.Net это string, а не число — так отдаёт API 3x-ui). +/// AllowedRoleIds хранит только Guid ролей (не навигацию на AppRole — тот живёт в Infrastructure/Identity, +/// Domain не должен на него ссылаться). +/// +public sealed class Inbound : Entity +{ + public Guid NodeId { get; private set; } + public string RemoteInboundId { get; private set; } = string.Empty; + public VpnProtocol Protocol { get; private set; } + public string Remark { get; private set; } = string.Empty; + public int Port { get; private set; } + public bool IsPublished { get; private set; } + public string? DisplayName { get; private set; } + public int? MaxClients { get; private set; } + public IReadOnlyList AllowedRoleIds { get; private set; } = []; + public DateTimeOffset? LastSyncAt { get; private set; } + + private Inbound() + { + } + + public static Inbound FromRemote(Guid nodeId, string remoteInboundId, VpnProtocol protocol, string remark, int port) + { + return new Inbound + { + Id = Guid.NewGuid(), + NodeId = nodeId, + RemoteInboundId = remoteInboundId, + Protocol = protocol, + Remark = remark, + Port = port, + IsPublished = false, + LastSyncAt = DateTimeOffset.UtcNow, + }; + } + + public void UpdateFromRemote(VpnProtocol protocol, string remark, int port) + { + Protocol = protocol; + Remark = remark; + Port = port; + LastSyncAt = DateTimeOffset.UtcNow; + } + + public void Publish(string? displayName, IReadOnlyCollection allowedRoleIds, int? maxClients) + { + IsPublished = true; + DisplayName = displayName; + AllowedRoleIds = allowedRoleIds.Distinct().ToList(); + MaxClients = maxClients; + } + + public void Unpublish() => IsPublished = false; +} diff --git a/backend/src/PnvPanel.Domain/Inbounds/VpnProtocol.cs b/backend/src/PnvPanel.Domain/Inbounds/VpnProtocol.cs new file mode 100644 index 0000000..4965388 --- /dev/null +++ b/backend/src/PnvPanel.Domain/Inbounds/VpnProtocol.cs @@ -0,0 +1,9 @@ +namespace PnvPanel.Domain.Inbounds; + +public enum VpnProtocol +{ + Vless, + Vmess, + Trojan, + Shadowsocks, +} diff --git a/backend/src/PnvPanel.Domain/Nodes/Node.cs b/backend/src/PnvPanel.Domain/Nodes/Node.cs new file mode 100644 index 0000000..50f40c2 --- /dev/null +++ b/backend/src/PnvPanel.Domain/Nodes/Node.cs @@ -0,0 +1,58 @@ +using PnvPanel.Domain.Common; +using PnvPanel.Domain.Exceptions; + +namespace PnvPanel.Domain.Nodes; + +/// +/// Подключённая администратором панель 3x-ui. Недоступность/выключение ноды блокирует только +/// новые конфиги — существующие клиенты в 3x-ui не трогаем. +/// +public sealed class Node : Entity +{ + public string Name { get; private set; } = string.Empty; + public Uri BaseAddress { get; private set; } = null!; + public NodeCredentials Credentials { get; private set; } = null!; + public string? Location { get; private set; } + public NodeStatus Status { get; private set; } + public bool IsEnabled { get; private set; } + public DateTimeOffset? LastSyncAt { get; private set; } + public DateTimeOffset CreatedAt { get; private set; } + + private Node() + { + } + + public static Node Register(string name, Uri baseAddress, NodeCredentials credentials, string? location) + { + if (!baseAddress.IsAbsoluteUri) + throw new DomainException("Адрес ноды должен быть абсолютным URI."); + + return new Node + { + Id = Guid.NewGuid(), + Name = name, + BaseAddress = baseAddress, + Credentials = credentials, + Location = location, + Status = NodeStatus.Unknown, + IsEnabled = true, + CreatedAt = DateTimeOffset.UtcNow, + }; + } + + public void UpdateDetails(string name, string? location) + { + Name = name; + Location = location; + } + + public void UpdateCredentials(NodeCredentials credentials) => Credentials = credentials; + + public void Enable() => IsEnabled = true; + + public void Disable() => IsEnabled = false; + + public void UpdateStatus(NodeStatus status) => Status = status; + + public void MarkSynced() => LastSyncAt = DateTimeOffset.UtcNow; +} diff --git a/backend/src/PnvPanel.Domain/Nodes/NodeCredentials.cs b/backend/src/PnvPanel.Domain/Nodes/NodeCredentials.cs new file mode 100644 index 0000000..6dfefeb --- /dev/null +++ b/backend/src/PnvPanel.Domain/Nodes/NodeCredentials.cs @@ -0,0 +1,10 @@ +namespace PnvPanel.Domain.Nodes; + +/// +/// Логин + зашифрованный пароль панели. Domain не знает о механизме шифрования (ISecretProtector — +/// порт Infrastructure); здесь хранится уже готовый шифротекст. +/// +public sealed record NodeCredentials(string Username, string ProtectedPassword) +{ + public override string ToString() => $"NodeCredentials {{ Username = {Username}, ProtectedPassword = [REDACTED] }}"; +} diff --git a/backend/src/PnvPanel.Domain/Nodes/NodeStatus.cs b/backend/src/PnvPanel.Domain/Nodes/NodeStatus.cs new file mode 100644 index 0000000..adcdace --- /dev/null +++ b/backend/src/PnvPanel.Domain/Nodes/NodeStatus.cs @@ -0,0 +1,8 @@ +namespace PnvPanel.Domain.Nodes; + +public enum NodeStatus +{ + Unknown, + Online, + Offline, +} diff --git a/backend/src/PnvPanel.Domain/PnvPanel.Domain.csproj b/backend/src/PnvPanel.Domain/PnvPanel.Domain.csproj new file mode 100644 index 0000000..6d36c6d --- /dev/null +++ b/backend/src/PnvPanel.Domain/PnvPanel.Domain.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/backend/src/PnvPanel.Infrastructure/DependencyInjection.cs b/backend/src/PnvPanel.Infrastructure/DependencyInjection.cs new file mode 100644 index 0000000..73fb77e --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/DependencyInjection.cs @@ -0,0 +1,102 @@ +using System.Text; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Infrastructure.Identity; +using PnvPanel.Infrastructure.Persistence; +using PnvPanel.Infrastructure.Security; +using PnvPanel.Infrastructure.Xui; +using ThreeXui.ConnectionStrings; +using ThreeXui.Http; + +namespace PnvPanel.Infrastructure; + +/// +/// Регистрация сервисов инфраструктуры: EF Core (PostgreSQL), Identity/JWT-аутентификация, +/// XuiPanelGateway, шифрование секретов, (в будущем) SignalR-пуш, фоновые сервисы. +/// +public static class DependencyInjection +{ + public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) + { + var connectionString = configuration["ConnectionStrings:Default"] + ?? throw new InvalidOperationException("Строка подключения 'ConnectionStrings:Default' не сконфигурирована."); + + services.AddDbContext(options => options.UseNpgsql(connectionString)); + services.AddScoped(sp => sp.GetRequiredService()); + + services + .AddIdentityCore(options => + { + options.User.RequireUniqueEmail = false; + options.Password.RequiredLength = 8; + options.Lockout.MaxFailedAccessAttempts = 5; + options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5); + options.Lockout.AllowedForNewUsers = true; + }) + .AddRoles() + .AddEntityFrameworkStores() + .AddSignInManager() + .AddDefaultTokenProviders(); + + services.Configure(configuration.GetSection(JwtOptions.SectionName)); + services.Configure(configuration.GetSection(AdminSeedOptions.SectionName)); + services.Configure(configuration.GetSection(RolesOptions.SectionName)); + + var jwtOptions = configuration.GetSection(JwtOptions.SectionName).Get() + ?? throw new InvalidOperationException("Секция конфигурации 'Jwt' не задана."); + + services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = jwtOptions.Issuer, + ValidateAudience = true, + ValidAudience = jwtOptions.Audience, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SigningKey)), + ValidateLifetime = true, + ClockSkew = TimeSpan.FromSeconds(30), + }; + }); + + services.AddAuthorization(); + + // Шифрование секретов нод at-rest (ASP.NET Core Data Protection). Key-ring — на постоянном + // томе (DataProtection__KeyRingPath), иначе секреты станут нечитаемы при пересоздании контейнера. + var keyRingPath = configuration["DataProtection:KeyRingPath"]; + var dataProtectionBuilder = services.AddDataProtection(); + if (!string.IsNullOrWhiteSpace(keyRingPath)) + dataProtectionBuilder.PersistKeysToFileSystem(new DirectoryInfo(keyRingPath)); + + services.AddSingleton(); + + // Построители connection string (по одному на протокол) + резолвер по имени протокола. + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + // Singleton: держит кэш per-node XUI-клиентов между запросами (см. XuiPanelGateway). + services.AddSingleton(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Identity/AdminSeedOptions.cs b/backend/src/PnvPanel.Infrastructure/Identity/AdminSeedOptions.cs new file mode 100644 index 0000000..78b439f --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Identity/AdminSeedOptions.cs @@ -0,0 +1,9 @@ +namespace PnvPanel.Infrastructure.Identity; + +public sealed class AdminSeedOptions +{ + public const string SectionName = "AdminSeed"; + + public string? Username { get; init; } + public string? Password { get; init; } +} diff --git a/backend/src/PnvPanel.Infrastructure/Identity/AppRole.cs b/backend/src/PnvPanel.Infrastructure/Identity/AppRole.cs new file mode 100644 index 0000000..c083e7d --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Identity/AppRole.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Identity; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Infrastructure.Identity; + +/// Роль с квотой на число конфигов. У пользователя ровно одна роль. +public class AppRole : IdentityRole +{ + public const int UnlimitedMaxConfigs = RoleQuota.Unlimited; + + public int MaxConfigs { get; set; } + public bool IsSystem { get; set; } + + public AppRole() + { + } + + public AppRole(string name) : base(name) + { + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Identity/AppUser.cs b/backend/src/PnvPanel.Infrastructure/Identity/AppUser.cs new file mode 100644 index 0000000..cd9f566 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Identity/AppUser.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Identity; + +namespace PnvPanel.Infrastructure.Identity; + +/// +/// Пользователь. Вход — по UserName; Email в системе не используется. +/// Активация ("может создавать конфиги") — отдельный флаг, назначаемый админом (см. M2). +/// +public class AppUser : IdentityUser +{ + public bool IsActivated { get; set; } + public DateTimeOffset? ActivatedAt { get; set; } + public Guid? ActivatedBy { get; set; } + + /// Секрет для агрегированной подписки /sub/{token} (все активные конфиги пользователя). + public string SubscriptionToken { get; set; } = string.Empty; +} diff --git a/backend/src/PnvPanel.Infrastructure/Identity/CurrentUser.cs b/backend/src/PnvPanel.Infrastructure/Identity/CurrentUser.cs new file mode 100644 index 0000000..e0e85d1 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Identity/CurrentUser.cs @@ -0,0 +1,23 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using PnvPanel.Application.Common.Interfaces; + +namespace PnvPanel.Infrastructure.Identity; + +internal sealed class CurrentUser(IHttpContextAccessor httpContextAccessor) : ICurrentUser +{ + private ClaimsPrincipal? Principal => httpContextAccessor.HttpContext?.User; + + public Guid? UserId + { + get + { + var value = Principal?.FindFirstValue(ClaimTypes.NameIdentifier); + return Guid.TryParse(value, out var id) ? id : null; + } + } + + public string? UserName => Principal?.FindFirstValue(ClaimTypes.Name); + + public bool IsAuthenticated => Principal?.Identity?.IsAuthenticated ?? false; +} diff --git a/backend/src/PnvPanel.Infrastructure/Identity/DbInitializer.cs b/backend/src/PnvPanel.Infrastructure/Identity/DbInitializer.cs new file mode 100644 index 0000000..1ca3e38 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Identity/DbInitializer.cs @@ -0,0 +1,112 @@ +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using PnvPanel.Domain.Apps; +using PnvPanel.Infrastructure.Persistence; + +namespace PnvPanel.Infrastructure.Identity; + +/// Идемпотентный сидинг: системные роли, учётка администратора из env, каталог приложений. +public sealed class DbInitializer( + RoleManager roleManager, + UserManager userManager, + AppDbContext dbContext, + IOptions adminSeedOptions, + IOptions rolesOptions, + ILogger logger) +{ + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; + + public async Task SeedAsync(CancellationToken cancellationToken = default) + { + await EnsureRoleAsync(RoleNames.Admin, AppRole.UnlimitedMaxConfigs, isSystem: true); + await EnsureRoleAsync(RoleNames.User, rolesOptions.Value.DefaultUserMaxConfigs, isSystem: true); + await SeedAdminAsync(); + await SeedClientAppsAsync(cancellationToken); + } + + private async Task EnsureRoleAsync(string name, int maxConfigs, bool isSystem) + { + if (await roleManager.RoleExistsAsync(name)) + return; + + var role = new AppRole(name) { MaxConfigs = maxConfigs, IsSystem = isSystem }; + var result = await roleManager.CreateAsync(role); + if (!result.Succeeded) + { + throw new InvalidOperationException( + $"Не удалось создать роль '{name}': {string.Join(", ", result.Errors.Select(e => e.Description))}"); + } + + logger.LogInformation("Создана системная роль {RoleName}", name); + } + + private async Task SeedAdminAsync() + { + var options = adminSeedOptions.Value; + if (string.IsNullOrWhiteSpace(options.Username) || string.IsNullOrWhiteSpace(options.Password)) + { + logger.LogWarning("AdminSeed__Username/AdminSeed__Password не заданы — учётка администратора не создана"); + return; + } + + if (await userManager.FindByNameAsync(options.Username) is not null) + return; + + var admin = new AppUser + { + UserName = options.Username, + IsActivated = true, + ActivatedAt = DateTimeOffset.UtcNow, + SubscriptionToken = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)), + }; + + var createResult = await userManager.CreateAsync(admin, options.Password); + if (!createResult.Succeeded) + { + throw new InvalidOperationException( + $"Не удалось создать администратора: {string.Join(", ", createResult.Errors.Select(e => e.Description))}"); + } + + await userManager.AddToRoleAsync(admin, RoleNames.Admin); + logger.LogInformation("Создана учётка администратора {Username}", options.Username); + } + + private async Task SeedClientAppsAsync(CancellationToken cancellationToken) + { + if (await dbContext.ClientApps.AnyAsync(cancellationToken)) + return; + + var path = Path.Combine(AppContext.BaseDirectory, "seed", "client-apps.json"); + if (!File.Exists(path)) + { + logger.LogWarning("Файл сида каталога приложений не найден: {Path}", path); + return; + } + + var json = await File.ReadAllTextAsync(path, cancellationToken); + var entries = JsonSerializer.Deserialize>(json, JsonOptions) ?? []; + + foreach (var entry in entries) + { + if (!Enum.TryParse(entry.OperatingSystem, ignoreCase: true, out var os)) + { + logger.LogWarning("Неизвестная ОС '{Os}' в сиде каталога приложений — пропущено", entry.OperatingSystem); + continue; + } + + var app = ClientApp.Create( + entry.Name, new Uri(entry.DownloadUrl, UriKind.Absolute), os, entry.Description, iconUrl: null, entry.SortOrder); + dbContext.ClientApps.Add(app); + } + + await dbContext.SaveChangesAsync(cancellationToken); + logger.LogInformation("Засеян каталог приложений: {Count} записей", entries.Count); + } + + private sealed record ClientAppSeedEntry( + string Name, string OperatingSystem, string DownloadUrl, string? Description, int SortOrder, bool IsEnabled); +} diff --git a/backend/src/PnvPanel.Infrastructure/Identity/DbInitializerExtensions.cs b/backend/src/PnvPanel.Infrastructure/Identity/DbInitializerExtensions.cs new file mode 100644 index 0000000..e1ed44a --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Identity/DbInitializerExtensions.cs @@ -0,0 +1,13 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace PnvPanel.Infrastructure.Identity; + +public static class DbInitializerExtensions +{ + public static async Task SeedDataAsync(this IServiceProvider services, CancellationToken cancellationToken = default) + { + await using var scope = services.CreateAsyncScope(); + var initializer = scope.ServiceProvider.GetRequiredService(); + await initializer.SeedAsync(cancellationToken); + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs b/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs new file mode 100644 index 0000000..ff9a8f3 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs @@ -0,0 +1,135 @@ +using System.Security.Cryptography; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Auth; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Infrastructure.Identity; + +internal sealed class IdentityService(UserManager userManager, SignInManager signInManager, RoleManager roleManager) + : IIdentityService +{ + public async Task> CreateUserAsync(string userName, string password, CancellationToken cancellationToken) + { + var user = new AppUser + { + UserName = userName, + IsActivated = false, + SubscriptionToken = GenerateSubscriptionToken(), + }; + var createResult = await userManager.CreateAsync(user, password); + + if (!createResult.Succeeded) + { + return createResult.Errors.Any(e => e.Code == nameof(IdentityErrorDescriber.DuplicateUserName)) + ? Result.Failure(AuthErrors.DuplicateUserName) + : Result.Failure(Error.Validation( + "Auth.RegistrationFailed", + string.Join("; ", createResult.Errors.Select(e => e.Description)))); + } + + await userManager.AddToRoleAsync(user, RoleNames.User); + return Result.Success(user.Id); + } + + public async Task> ValidateCredentialsAsync(string userName, string password, CancellationToken cancellationToken) + { + var user = await userManager.FindByNameAsync(userName); + if (user is null) + return Result.Failure(AuthErrors.InvalidCredentials); + + var checkResult = await signInManager.CheckPasswordSignInAsync(user, password, lockoutOnFailure: true); + if (checkResult.IsLockedOut) + return Result.Failure(AuthErrors.LockedOut); + if (!checkResult.Succeeded) + return Result.Failure(AuthErrors.InvalidCredentials); + + var roleName = await GetPrimaryRoleNameAsync(user); + return Result.Success(new AuthenticatedUser(user.Id, user.UserName!, roleName)); + } + + public async Task GetProfileAsync(Guid userId, CancellationToken cancellationToken) + { + var user = await userManager.FindByIdAsync(userId.ToString()); + if (user is null) + return null; + + var role = await GetPrimaryRoleAsync(user); + return new CurrentUserProfile(user.Id, user.UserName!, role.Id, role.Name!, user.IsActivated, role.MaxConfigs); + } + + public async Task ChangePasswordAsync(Guid userId, string currentPassword, string newPassword, CancellationToken cancellationToken) + { + var user = await userManager.FindByIdAsync(userId.ToString()); + if (user is null) + return Result.Failure(AuthErrors.Unauthorized); + + var result = await userManager.ChangePasswordAsync(user, currentPassword, newPassword); + return result.Succeeded + ? Result.Success() + : Result.Failure(Error.Validation( + "Auth.PasswordChangeFailed", + string.Join("; ", result.Errors.Select(e => e.Description)))); + } + + public async Task ActivateUserAsync(Guid userId, Guid activatedBy, CancellationToken cancellationToken) + { + var user = await userManager.FindByIdAsync(userId.ToString()); + if (user is null) + return Result.Failure(AuthErrors.Unauthorized); + + // Мутируем уже отслеживаемую EF-сущность напрямую, без UserManager.UpdateAsync (который + // закоммитил бы немедленно) — изменение попадёт в общий SaveChanges вместе с ActivationRequest. + user.IsActivated = true; + user.ActivatedAt = DateTimeOffset.UtcNow; + user.ActivatedBy = activatedBy; + + return Result.Success(); + } + + public async Task> GetUserNamesAsync(IReadOnlyCollection userIds, CancellationToken cancellationToken) + { + if (userIds.Count == 0) + return new Dictionary(); + + return await userManager.Users + .Where(u => userIds.Contains(u.Id)) + .ToDictionaryAsync(u => u.Id, u => u.UserName!, cancellationToken); + } + + public async Task DeleteUserAsync(Guid userId, CancellationToken cancellationToken) + { + var user = await userManager.FindByIdAsync(userId.ToString()); + if (user is null) + return Result.Failure(AuthErrors.Unauthorized); + + var result = await userManager.DeleteAsync(user); + return result.Succeeded + ? Result.Success() + : Result.Failure(Error.Failure( + "Auth.DeleteAccountFailed", string.Join("; ", result.Errors.Select(e => e.Description)))); + } + + public async Task FindUserIdBySubscriptionTokenAsync(string token, CancellationToken cancellationToken) + { + var user = await userManager.Users.AsNoTracking() + .FirstOrDefaultAsync(u => u.SubscriptionToken == token, cancellationToken); + return user?.Id; + } + + private async Task GetPrimaryRoleNameAsync(AppUser user) + { + var roles = await userManager.GetRolesAsync(user); + return roles.FirstOrDefault() ?? RoleNames.User; + } + + private async Task GetPrimaryRoleAsync(AppUser user) + { + var roleName = await GetPrimaryRoleNameAsync(user); + return await roleManager.FindByNameAsync(roleName) + ?? throw new InvalidOperationException($"Роль '{roleName}' не найдена."); + } + + private static string GenerateSubscriptionToken() => Convert.ToHexString(RandomNumberGenerator.GetBytes(32)); +} diff --git a/backend/src/PnvPanel.Infrastructure/Identity/JwtOptions.cs b/backend/src/PnvPanel.Infrastructure/Identity/JwtOptions.cs new file mode 100644 index 0000000..f8ce3e2 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Identity/JwtOptions.cs @@ -0,0 +1,12 @@ +namespace PnvPanel.Infrastructure.Identity; + +public sealed class JwtOptions +{ + public const string SectionName = "Jwt"; + + public string Issuer { get; init; } = string.Empty; + public string Audience { get; init; } = string.Empty; + public string SigningKey { get; init; } = string.Empty; + public int AccessTokenMinutes { get; init; } = 15; + public int RefreshTokenDays { get; init; } = 30; +} diff --git a/backend/src/PnvPanel.Infrastructure/Identity/JwtTokenService.cs b/backend/src/PnvPanel.Infrastructure/Identity/JwtTokenService.cs new file mode 100644 index 0000000..4d03e40 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Identity/JwtTokenService.cs @@ -0,0 +1,39 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using PnvPanel.Application.Common.Interfaces; + +namespace PnvPanel.Infrastructure.Identity; + +internal sealed class JwtTokenService(IOptions options) : IJwtTokenService +{ + private readonly JwtOptions _options = options.Value; + + public (string AccessToken, DateTimeOffset ExpiresAt) GenerateAccessToken(AuthenticatedUser user) + { + var expiresAt = DateTimeOffset.UtcNow.AddMinutes(_options.AccessTokenMinutes); + + Claim[] claims = + [ + new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()), + new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), + new Claim(ClaimTypes.Name, user.UserName), + new Claim(ClaimTypes.Role, user.Role), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + ]; + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SigningKey)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: _options.Issuer, + audience: _options.Audience, + claims: claims, + expires: expiresAt.UtcDateTime, + signingCredentials: credentials); + + return (new JwtSecurityTokenHandler().WriteToken(token), expiresAt); + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Identity/RefreshToken.cs b/backend/src/PnvPanel.Infrastructure/Identity/RefreshToken.cs new file mode 100644 index 0000000..bc4c313 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Identity/RefreshToken.cs @@ -0,0 +1,16 @@ +namespace PnvPanel.Infrastructure.Identity; + +/// +/// Хранится только хэш токена (см. RefreshTokenService). Ротация при использовании: +/// старый токен помечается RevokedAt + ReplacedByTokenHash, выпускается новый. +/// +public class RefreshToken +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + public string TokenHash { get; set; } = string.Empty; + public DateTimeOffset ExpiresAt { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset? RevokedAt { get; set; } + public string? ReplacedByTokenHash { get; set; } +} diff --git a/backend/src/PnvPanel.Infrastructure/Identity/RefreshTokenService.cs b/backend/src/PnvPanel.Infrastructure/Identity/RefreshTokenService.cs new file mode 100644 index 0000000..48a1097 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Identity/RefreshTokenService.cs @@ -0,0 +1,99 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using PnvPanel.Application.Auth; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Models; +using PnvPanel.Infrastructure.Persistence; + +namespace PnvPanel.Infrastructure.Identity; + +internal sealed class RefreshTokenService(AppDbContext dbContext, IOptions options) : IRefreshTokenService +{ + private readonly JwtOptions _options = options.Value; + + public async Task IssueAsync(Guid userId, CancellationToken cancellationToken) + { + var rawToken = GenerateRawToken(); + var expiresAt = DateTimeOffset.UtcNow.AddDays(_options.RefreshTokenDays); + + dbContext.RefreshTokens.Add(new RefreshToken + { + Id = Guid.NewGuid(), + UserId = userId, + TokenHash = Hash(rawToken), + ExpiresAt = expiresAt, + CreatedAt = DateTimeOffset.UtcNow, + }); + await dbContext.SaveChangesAsync(cancellationToken); + + return new IssuedRefreshToken(rawToken, expiresAt); + } + + public async Task> RotateAsync(string rawToken, CancellationToken cancellationToken) + { + var hash = Hash(rawToken); + var existing = await dbContext.RefreshTokens.SingleOrDefaultAsync(t => t.TokenHash == hash, cancellationToken); + + if (existing is null) + return Result.Failure(AuthErrors.InvalidRefreshToken); + + if (existing.RevokedAt is not null) + { + // Повторное использование уже отозванного токена — признак компрометации: гасим все токены пользователя. + await RevokeAllForUserAsync(existing.UserId, cancellationToken); + return Result.Failure(AuthErrors.InvalidRefreshToken); + } + + if (existing.ExpiresAt <= DateTimeOffset.UtcNow) + return Result.Failure(AuthErrors.InvalidRefreshToken); + + var newRawToken = GenerateRawToken(); + var newExpiresAt = DateTimeOffset.UtcNow.AddDays(_options.RefreshTokenDays); + var newHash = Hash(newRawToken); + + existing.RevokedAt = DateTimeOffset.UtcNow; + existing.ReplacedByTokenHash = newHash; + + dbContext.RefreshTokens.Add(new RefreshToken + { + Id = Guid.NewGuid(), + UserId = existing.UserId, + TokenHash = newHash, + ExpiresAt = newExpiresAt, + CreatedAt = DateTimeOffset.UtcNow, + }); + + await dbContext.SaveChangesAsync(cancellationToken); + + return Result.Success(new RotatedRefreshToken(existing.UserId, newRawToken, newExpiresAt)); + } + + public async Task RevokeAsync(string rawToken, CancellationToken cancellationToken) + { + var hash = Hash(rawToken); + var existing = await dbContext.RefreshTokens.SingleOrDefaultAsync(t => t.TokenHash == hash, cancellationToken); + if (existing is null || existing.RevokedAt is not null) + return; + + existing.RevokedAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + } + + private async Task RevokeAllForUserAsync(Guid userId, CancellationToken cancellationToken) + { + var activeTokens = await dbContext.RefreshTokens + .Where(t => t.UserId == userId && t.RevokedAt == null) + .ToListAsync(cancellationToken); + + foreach (var token in activeTokens) + token.RevokedAt = DateTimeOffset.UtcNow; + + await dbContext.SaveChangesAsync(cancellationToken); + } + + private static string GenerateRawToken() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(64)); + + private static string Hash(string rawToken) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(rawToken))); +} diff --git a/backend/src/PnvPanel.Infrastructure/Identity/RoleNames.cs b/backend/src/PnvPanel.Infrastructure/Identity/RoleNames.cs new file mode 100644 index 0000000..713be26 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Identity/RoleNames.cs @@ -0,0 +1,8 @@ +namespace PnvPanel.Infrastructure.Identity; + +/// Системные роли — не удаляются и не переименовываются. +public static class RoleNames +{ + public const string Admin = "admin"; + public const string User = "user"; +} diff --git a/backend/src/PnvPanel.Infrastructure/Identity/RoleService.cs b/backend/src/PnvPanel.Infrastructure/Identity/RoleService.cs new file mode 100644 index 0000000..5eef3af --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Identity/RoleService.cs @@ -0,0 +1,84 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Admin.Roles; +using PnvPanel.Application.Admin.Users; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Infrastructure.Identity; + +internal sealed class RoleService(RoleManager roleManager, UserManager userManager) : IRoleService +{ + public async Task> CreateRoleAsync(string name, int maxConfigs, CancellationToken cancellationToken) + { + if (await roleManager.RoleExistsAsync(name)) + return Result.Failure(RoleErrors.DuplicateName); + + var role = new AppRole(name) { MaxConfigs = maxConfigs, IsSystem = false }; + var result = await roleManager.CreateAsync(role); + if (!result.Succeeded) + { + return Result.Failure(Error.Validation( + "Roles.CreateFailed", string.Join("; ", result.Errors.Select(e => e.Description)))); + } + + return Result.Success(ToDto(role)); + } + + public async Task> UpdateRoleAsync(Guid roleId, int maxConfigs, CancellationToken cancellationToken) + { + var role = await roleManager.FindByIdAsync(roleId.ToString()); + if (role is null) + return Result.Failure(RoleErrors.NotFound); + + role.MaxConfigs = maxConfigs; + await roleManager.UpdateAsync(role); + + return Result.Success(ToDto(role)); + } + + public async Task DeleteRoleAsync(Guid roleId, CancellationToken cancellationToken) + { + var role = await roleManager.FindByIdAsync(roleId.ToString()); + if (role is null) + return Result.Failure(RoleErrors.NotFound); + + if (role.IsSystem) + return Result.Failure(RoleErrors.CannotModifySystemRole); + + var usersInRole = await userManager.GetUsersInRoleAsync(role.Name!); + if (usersInRole.Count > 0) + return Result.Failure(RoleErrors.RoleInUse); + + await roleManager.DeleteAsync(role); + return Result.Success(); + } + + public async Task> ListRolesAsync(CancellationToken cancellationToken) + { + return await roleManager.Roles + .OrderBy(r => r.Name) + .Select(r => new RoleDto(r.Id, r.Name!, r.MaxConfigs, r.IsSystem)) + .ToListAsync(cancellationToken); + } + + public async Task ChangeUserRoleAsync(Guid userId, Guid roleId, CancellationToken cancellationToken) + { + var user = await userManager.FindByIdAsync(userId.ToString()); + if (user is null) + return Result.Failure(UserErrors.NotFound); + + var role = await roleManager.FindByIdAsync(roleId.ToString()); + if (role is null) + return Result.Failure(RoleErrors.NotFound); + + var currentRoles = await userManager.GetRolesAsync(user); + if (currentRoles.Count > 0) + await userManager.RemoveFromRolesAsync(user, currentRoles); + + await userManager.AddToRoleAsync(user, role.Name!); + return Result.Success(); + } + + private static RoleDto ToDto(AppRole role) => new(role.Id, role.Name!, role.MaxConfigs, role.IsSystem); +} diff --git a/backend/src/PnvPanel.Infrastructure/Identity/RolesOptions.cs b/backend/src/PnvPanel.Infrastructure/Identity/RolesOptions.cs new file mode 100644 index 0000000..f44c205 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Identity/RolesOptions.cs @@ -0,0 +1,8 @@ +namespace PnvPanel.Infrastructure.Identity; + +public sealed class RolesOptions +{ + public const string SectionName = "Roles"; + + public int DefaultUserMaxConfigs { get; init; } = 3; +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs b/backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs new file mode 100644 index 0000000..4e18d9a --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Domain.Activation; +using PnvPanel.Domain.Apps; +using PnvPanel.Domain.Configs; +using PnvPanel.Domain.Inbounds; +using PnvPanel.Domain.Nodes; +using PnvPanel.Infrastructure.Identity; + +namespace PnvPanel.Infrastructure.Persistence; + +/// +/// Корневой DbContext приложения: Identity-схема (пользователи/роли) + сущности домена +/// (добавляются по мере реализации фич, см. docs/roadmap.md). +/// +public class AppDbContext(DbContextOptions options) + : IdentityDbContext(options), IAppDbContext +{ + public DbSet RefreshTokens => Set(); + + public DbSet ActivationRequests => Set(); + + public DbSet Nodes => Set(); + + public DbSet Inbounds => Set(); + + public DbSet VpnConfigs => Set(); + + public DbSet ClientApps => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/ActivationRequestConfiguration.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/ActivationRequestConfiguration.cs new file mode 100644 index 0000000..81ea595 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/ActivationRequestConfiguration.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PnvPanel.Domain.Activation; + +namespace PnvPanel.Infrastructure.Persistence.Configurations; + +public class ActivationRequestConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("ActivationRequests"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.Comment).HasMaxLength(500); + builder.Property(x => x.RejectionReason).HasMaxLength(500); + builder.Property(x => x.Status).HasConversion().HasMaxLength(32); + + builder.HasIndex(x => new { x.UserId, x.Status }); + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/ClientAppConfiguration.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/ClientAppConfiguration.cs new file mode 100644 index 0000000..01b28bf --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/ClientAppConfiguration.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PnvPanel.Domain.Apps; + +namespace PnvPanel.Infrastructure.Persistence.Configurations; + +public class ClientAppConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("ClientApps"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(100); + + builder.Property(x => x.DownloadUrl) + .IsRequired() + .HasMaxLength(500) + .HasConversion(uri => uri.ToString(), s => new Uri(s, UriKind.Absolute)); + + builder.Property(x => x.OperatingSystem).HasConversion().HasMaxLength(32); + builder.Property(x => x.Description).HasMaxLength(300); + builder.Property(x => x.IconUrl).HasMaxLength(500); + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/InboundConfiguration.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/InboundConfiguration.cs new file mode 100644 index 0000000..551a50c --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/InboundConfiguration.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PnvPanel.Domain.Inbounds; + +namespace PnvPanel.Infrastructure.Persistence.Configurations; + +public class InboundConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Inbounds"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.RemoteInboundId).IsRequired().HasMaxLength(64); + builder.Property(x => x.Protocol).HasConversion().HasMaxLength(32); + builder.Property(x => x.Remark).IsRequired().HasMaxLength(200); + builder.Property(x => x.DisplayName).HasMaxLength(100); + + builder.Property(x => x.AllowedRoleIds).HasColumnType("uuid[]"); + + builder.HasIndex(x => new { x.NodeId, x.RemoteInboundId }).IsUnique(); + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/NodeConfiguration.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/NodeConfiguration.cs new file mode 100644 index 0000000..d02ddd8 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/NodeConfiguration.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PnvPanel.Domain.Nodes; + +namespace PnvPanel.Infrastructure.Persistence.Configurations; + +public class NodeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Nodes"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(100); + + builder.Property(x => x.BaseAddress) + .IsRequired() + .HasMaxLength(500) + .HasConversion(uri => uri.ToString(), s => new Uri(s, UriKind.Absolute)); + + builder.Property(x => x.Location).HasMaxLength(100); + builder.Property(x => x.Status).HasConversion().HasMaxLength(32); + + builder.OwnsOne(x => x.Credentials, credentials => + { + credentials.Property(c => c.Username).HasColumnName("CredentialsUsername").IsRequired().HasMaxLength(200); + credentials.Property(c => c.ProtectedPassword).HasColumnName("CredentialsProtectedPassword").IsRequired(); + }); + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs new file mode 100644 index 0000000..ad88d83 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PnvPanel.Infrastructure.Identity; + +namespace PnvPanel.Infrastructure.Persistence.Configurations; + +public class RefreshTokenConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("RefreshTokens"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.TokenHash) + .IsRequired() + .HasMaxLength(128); + + builder.HasIndex(x => x.TokenHash).IsUnique(); + builder.HasIndex(x => x.UserId); + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/VpnConfigConfiguration.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/VpnConfigConfiguration.cs new file mode 100644 index 0000000..8220bf2 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/VpnConfigConfiguration.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PnvPanel.Domain.Configs; + +namespace PnvPanel.Infrastructure.Persistence.Configurations; + +public class VpnConfigConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("VpnConfigs"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.Label).HasMaxLength(100); + builder.Property(x => x.ClientEmail).IsRequired().HasMaxLength(200); + builder.Property(x => x.ClientExternalId).IsRequired().HasMaxLength(200); + builder.Property(x => x.Protocol).HasConversion().HasMaxLength(32); + builder.Property(x => x.Status).HasConversion().HasMaxLength(32); + builder.Property(x => x.SubscriptionToken).IsRequired().HasMaxLength(128); + + builder.HasIndex(x => x.SubscriptionToken).IsUnique(); + builder.HasIndex(x => new { x.UserId, x.Status }); + builder.HasIndex(x => x.InboundId); + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/MigrationExtensions.cs b/backend/src/PnvPanel.Infrastructure/Persistence/MigrationExtensions.cs new file mode 100644 index 0000000..dc86a3e --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/MigrationExtensions.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace PnvPanel.Infrastructure.Persistence; + +/// +/// Применение EF Core-миграций при старте приложения (стратегия MVP — авто-миграции). +/// +public static class MigrationExtensions +{ + public static async Task ApplyMigrationsAsync(this IServiceProvider services, CancellationToken cancellationToken = default) + { + await using var scope = services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + await dbContext.Database.MigrateAsync(cancellationToken); + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701161345_InitialCreate.Designer.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701161345_InitialCreate.Designer.cs new file mode 100644 index 0000000..ad92a9d --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701161345_InitialCreate.Designer.cs @@ -0,0 +1,29 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PnvPanel.Infrastructure.Persistence; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260701161345_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701161345_InitialCreate.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701161345_InitialCreate.cs new file mode 100644 index 0000000..3152b3c --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701161345_InitialCreate.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701171444_AddIdentity.Designer.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701171444_AddIdentity.Designer.cs new file mode 100644 index 0000000..4c0c624 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701171444_AddIdentity.Designer.cs @@ -0,0 +1,327 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PnvPanel.Infrastructure.Persistence; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260701171444_AddIdentity")] + partial class AddIdentity + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("MaxConfigs") + .HasColumnType("integer"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ActivatedBy") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActivated") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701171444_AddIdentity.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701171444_AddIdentity.cs new file mode 100644 index 0000000..53bbbdc --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701171444_AddIdentity.cs @@ -0,0 +1,259 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddIdentity : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AspNetRoles", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + MaxConfigs = table.Column(type: "integer", nullable: false), + IsSystem = table.Column(type: "boolean", nullable: false), + Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetUsers", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + IsActivated = table.Column(type: "boolean", nullable: false), + ActivatedAt = table.Column(type: "timestamp with time zone", nullable: true), + ActivatedBy = table.Column(type: "uuid", nullable: true), + UserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + Email = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "boolean", nullable: false), + PasswordHash = table.Column(type: "text", nullable: true), + SecurityStamp = table.Column(type: "text", nullable: true), + ConcurrencyStamp = table.Column(type: "text", nullable: true), + PhoneNumber = table.Column(type: "text", nullable: true), + PhoneNumberConfirmed = table.Column(type: "boolean", nullable: false), + TwoFactorEnabled = table.Column(type: "boolean", nullable: false), + LockoutEnd = table.Column(type: "timestamp with time zone", nullable: true), + LockoutEnabled = table.Column(type: "boolean", nullable: false), + AccessFailedCount = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUsers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "RefreshTokens", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + TokenHash = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + RevokedAt = table.Column(type: "timestamp with time zone", nullable: true), + ReplacedByTokenHash = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RefreshTokens", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetRoleClaims", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + RoleId = table.Column(type: "uuid", nullable: false), + ClaimType = table.Column(type: "text", nullable: true), + ClaimValue = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserClaims", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "uuid", nullable: false), + ClaimType = table.Column(type: "text", nullable: true), + ClaimValue = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetUserClaims_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserLogins", + columns: table => new + { + LoginProvider = table.Column(type: "text", nullable: false), + ProviderKey = table.Column(type: "text", nullable: false), + ProviderDisplayName = table.Column(type: "text", nullable: true), + UserId = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_AspNetUserLogins_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserRoles", + columns: table => new + { + UserId = table.Column(type: "uuid", nullable: false), + RoleId = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserTokens", + columns: table => new + { + UserId = table.Column(type: "uuid", nullable: false), + LoginProvider = table.Column(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + Value = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_AspNetUserTokens_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AspNetRoleClaims_RoleId", + table: "AspNetRoleClaims", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "AspNetRoles", + column: "NormalizedName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserClaims_UserId", + table: "AspNetUserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserLogins_UserId", + table: "AspNetUserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserRoles_RoleId", + table: "AspNetUserRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "AspNetUsers", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "AspNetUsers", + column: "NormalizedUserName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_TokenHash", + table: "RefreshTokens", + column: "TokenHash", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_UserId", + table: "RefreshTokens", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AspNetRoleClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserLogins"); + + migrationBuilder.DropTable( + name: "AspNetUserRoles"); + + migrationBuilder.DropTable( + name: "AspNetUserTokens"); + + migrationBuilder.DropTable( + name: "RefreshTokens"); + + migrationBuilder.DropTable( + name: "AspNetRoles"); + + migrationBuilder.DropTable( + name: "AspNetUsers"); + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701175024_AddActivationRequests.Designer.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701175024_AddActivationRequests.Designer.cs new file mode 100644 index 0000000..cbc6546 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701175024_AddActivationRequests.Designer.cs @@ -0,0 +1,365 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PnvPanel.Infrastructure.Persistence; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260701175024_AddActivationRequests")] + partial class AddActivationRequests + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Activation.ActivationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Comment") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedBy") + .HasColumnType("uuid"); + + b.Property("RejectionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("ActivationRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("MaxConfigs") + .HasColumnType("integer"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ActivatedBy") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActivated") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701175024_AddActivationRequests.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701175024_AddActivationRequests.cs new file mode 100644 index 0000000..b3fe79d --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701175024_AddActivationRequests.cs @@ -0,0 +1,45 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddActivationRequests : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ActivationRequests", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + Comment = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + Status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + DecidedBy = table.Column(type: "uuid", nullable: true), + DecidedAt = table.Column(type: "timestamp with time zone", nullable: true), + RejectionReason = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ActivationRequests", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_ActivationRequests_UserId_Status", + table: "ActivationRequests", + columns: new[] { "UserId", "Status" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ActivationRequests"); + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701181642_AddNodesAndInbounds.Designer.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701181642_AddNodesAndInbounds.Designer.cs new file mode 100644 index 0000000..d777554 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701181642_AddNodesAndInbounds.Designer.cs @@ -0,0 +1,486 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PnvPanel.Infrastructure.Persistence; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260701181642_AddNodesAndInbounds")] + partial class AddNodesAndInbounds + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Activation.ActivationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Comment") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedBy") + .HasColumnType("uuid"); + + b.Property("RejectionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("ActivationRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Inbounds.Inbound", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.PrimitiveCollection("AllowedRoleIds") + .IsRequired() + .HasColumnType("uuid[]"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxClients") + .HasColumnType("integer"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Remark") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RemoteInboundId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "RemoteInboundId") + .IsUnique(); + + b.ToTable("Inbounds", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BaseAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Location") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.ToTable("Nodes", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("MaxConfigs") + .HasColumnType("integer"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ActivatedBy") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActivated") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b => + { + b.OwnsOne("PnvPanel.Domain.Nodes.NodeCredentials", "Credentials", b1 => + { + b1.Property("NodeId") + .HasColumnType("uuid"); + + b1.Property("ProtectedPassword") + .IsRequired() + .HasColumnType("text") + .HasColumnName("CredentialsProtectedPassword"); + + b1.Property("Username") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("CredentialsUsername"); + + b1.HasKey("NodeId"); + + b1.ToTable("Nodes"); + + b1.WithOwner() + .HasForeignKey("NodeId"); + }); + + b.Navigation("Credentials") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701181642_AddNodesAndInbounds.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701181642_AddNodesAndInbounds.cs new file mode 100644 index 0000000..c72eb3d --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701181642_AddNodesAndInbounds.cs @@ -0,0 +1,72 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddNodesAndInbounds : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Inbounds", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + NodeId = table.Column(type: "uuid", nullable: false), + RemoteInboundId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Protocol = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + Remark = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Port = table.Column(type: "integer", nullable: false), + IsPublished = table.Column(type: "boolean", nullable: false), + DisplayName = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + MaxClients = table.Column(type: "integer", nullable: true), + AllowedRoleIds = table.Column(type: "uuid[]", nullable: false), + LastSyncAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Inbounds", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Nodes", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + BaseAddress = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + CredentialsUsername = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + CredentialsProtectedPassword = table.Column(type: "text", nullable: false), + Location = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + Status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + IsEnabled = table.Column(type: "boolean", nullable: false), + LastSyncAt = table.Column(type: "timestamp with time zone", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Nodes", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Inbounds_NodeId_RemoteInboundId", + table: "Inbounds", + columns: new[] { "NodeId", "RemoteInboundId" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Inbounds"); + + migrationBuilder.DropTable( + name: "Nodes"); + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701185508_AddConfigsAndApps.Designer.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701185508_AddConfigsAndApps.Designer.cs new file mode 100644 index 0000000..8eb6b54 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701185508_AddConfigsAndApps.Designer.cs @@ -0,0 +1,601 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PnvPanel.Infrastructure.Persistence; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260701185508_AddConfigsAndApps")] + partial class AddConfigsAndApps + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Activation.ActivationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Comment") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedBy") + .HasColumnType("uuid"); + + b.Property("RejectionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("ActivationRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Apps.ClientApp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("DownloadUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IconUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OperatingSystem") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ClientApps", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Configs.VpnConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientEmail") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClientExternalId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceLimit") + .HasColumnType("integer"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InboundId") + .HasColumnType("uuid"); + + b.Property("Label") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UsedDownBytes") + .HasColumnType("bigint"); + + b.Property("UsedUpBytes") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("InboundId"); + + b.HasIndex("SubscriptionToken") + .IsUnique(); + + b.HasIndex("UserId", "Status"); + + b.ToTable("VpnConfigs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Inbounds.Inbound", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.PrimitiveCollection("AllowedRoleIds") + .IsRequired() + .HasColumnType("uuid[]"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxClients") + .HasColumnType("integer"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Remark") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RemoteInboundId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "RemoteInboundId") + .IsUnique(); + + b.ToTable("Inbounds", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BaseAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Location") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.ToTable("Nodes", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("MaxConfigs") + .HasColumnType("integer"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ActivatedBy") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActivated") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b => + { + b.OwnsOne("PnvPanel.Domain.Nodes.NodeCredentials", "Credentials", b1 => + { + b1.Property("NodeId") + .HasColumnType("uuid"); + + b1.Property("ProtectedPassword") + .IsRequired() + .HasColumnType("text") + .HasColumnName("CredentialsProtectedPassword"); + + b1.Property("Username") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("CredentialsUsername"); + + b1.HasKey("NodeId"); + + b1.ToTable("Nodes"); + + b1.WithOwner() + .HasForeignKey("NodeId"); + }); + + b.Navigation("Credentials") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701185508_AddConfigsAndApps.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701185508_AddConfigsAndApps.cs new file mode 100644 index 0000000..bc7b92e --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701185508_AddConfigsAndApps.cs @@ -0,0 +1,95 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddConfigsAndApps : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SubscriptionToken", + table: "AspNetUsers", + type: "text", + nullable: false, + defaultValue: ""); + + migrationBuilder.CreateTable( + name: "ClientApps", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + DownloadUrl = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + OperatingSystem = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + Description = table.Column(type: "character varying(300)", maxLength: 300, nullable: true), + IconUrl = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + SortOrder = table.Column(type: "integer", nullable: false), + IsEnabled = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ClientApps", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "VpnConfigs", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + InboundId = table.Column(type: "uuid", nullable: false), + Label = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + ClientEmail = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + ClientExternalId = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Protocol = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + DeviceLimit = table.Column(type: "integer", nullable: false), + UsedUpBytes = table.Column(type: "bigint", nullable: false), + UsedDownBytes = table.Column(type: "bigint", nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: true), + Status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + SubscriptionToken = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + LastSyncAt = table.Column(type: "timestamp with time zone", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_VpnConfigs", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_VpnConfigs_InboundId", + table: "VpnConfigs", + column: "InboundId"); + + migrationBuilder.CreateIndex( + name: "IX_VpnConfigs_SubscriptionToken", + table: "VpnConfigs", + column: "SubscriptionToken", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_VpnConfigs_UserId_Status", + table: "VpnConfigs", + columns: new[] { "UserId", "Status" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ClientApps"); + + migrationBuilder.DropTable( + name: "VpnConfigs"); + + migrationBuilder.DropColumn( + name: "SubscriptionToken", + table: "AspNetUsers"); + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..6bb2827 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,598 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PnvPanel.Infrastructure.Persistence; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Activation.ActivationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Comment") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedBy") + .HasColumnType("uuid"); + + b.Property("RejectionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("ActivationRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Apps.ClientApp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("DownloadUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IconUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OperatingSystem") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ClientApps", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Configs.VpnConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientEmail") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClientExternalId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceLimit") + .HasColumnType("integer"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InboundId") + .HasColumnType("uuid"); + + b.Property("Label") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UsedDownBytes") + .HasColumnType("bigint"); + + b.Property("UsedUpBytes") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("InboundId"); + + b.HasIndex("SubscriptionToken") + .IsUnique(); + + b.HasIndex("UserId", "Status"); + + b.ToTable("VpnConfigs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Inbounds.Inbound", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.PrimitiveCollection("AllowedRoleIds") + .IsRequired() + .HasColumnType("uuid[]"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxClients") + .HasColumnType("integer"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Remark") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RemoteInboundId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "RemoteInboundId") + .IsUnique(); + + b.ToTable("Inbounds", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BaseAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Location") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.ToTable("Nodes", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("MaxConfigs") + .HasColumnType("integer"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ActivatedBy") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActivated") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b => + { + b.OwnsOne("PnvPanel.Domain.Nodes.NodeCredentials", "Credentials", b1 => + { + b1.Property("NodeId") + .HasColumnType("uuid"); + + b1.Property("ProtectedPassword") + .IsRequired() + .HasColumnType("text") + .HasColumnName("CredentialsProtectedPassword"); + + b1.Property("Username") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("CredentialsUsername"); + + b1.HasKey("NodeId"); + + b1.ToTable("Nodes"); + + b1.WithOwner() + .HasForeignKey("NodeId"); + }); + + b.Navigation("Credentials") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/PnvPanel.Infrastructure.csproj b/backend/src/PnvPanel.Infrastructure/PnvPanel.Infrastructure.csproj new file mode 100644 index 0000000..03c5adb --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/PnvPanel.Infrastructure.csproj @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/backend/src/PnvPanel.Infrastructure/Security/DataProtectionSecretProtector.cs b/backend/src/PnvPanel.Infrastructure/Security/DataProtectionSecretProtector.cs new file mode 100644 index 0000000..1d15be1 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Security/DataProtectionSecretProtector.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.DataProtection; +using PnvPanel.Application.Common.Interfaces; + +namespace PnvPanel.Infrastructure.Security; + +internal sealed class DataProtectionSecretProtector : ISecretProtector +{ + private const string Purpose = "PnvPanel.NodeCredentials.v1"; + + private readonly IDataProtector _protector; + + public DataProtectionSecretProtector(IDataProtectionProvider provider) + { + _protector = provider.CreateProtector(Purpose); + } + + public string Protect(string plaintext) => _protector.Protect(plaintext); + + public string Unprotect(string protectedValue) => _protector.Unprotect(protectedValue); +} diff --git a/backend/src/PnvPanel.Infrastructure/Xui/XuiPanelGateway.cs b/backend/src/PnvPanel.Infrastructure/Xui/XuiPanelGateway.cs new file mode 100644 index 0000000..808247c --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Xui/XuiPanelGateway.cs @@ -0,0 +1,197 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Inbounds; +using PnvPanel.Domain.Nodes; +using ThreeXui; +using ThreeXui.ConnectionStrings; +using ThreeXui.Http; + +namespace PnvPanel.Infrastructure.Xui; + +/// +/// ThreeXui.Net настроен на один BaseAddress, а нод у нас много — держим клиента per-node +/// (кэш по NodeId), создавая его из расшифрованных NodeCredentials. Singleton-время жизни +/// (см. регистрацию в DI): кэш должен переживать отдельные HTTP-запросы, чтобы переиспользовать +/// cookie-сессию клиента. +/// +internal sealed class XuiPanelGateway( + IXuiHttpClientFactory httpClientFactory, + IXuiConnectionStringBuilderResolver connectionStringResolver, + ISecretProtector secretProtector, + ILoggerFactory loggerFactory) + : IXuiPanelGateway, IDisposable +{ + private readonly ConcurrentDictionary> _clients = new(); + + public Result ValidateBaseAddress(Uri baseAddress) + { + return XuiBaseUrlValidator.IsAllowed(baseAddress.ToString(), out var reason) + ? Result.Success() + : Result.Failure(Error.Validation("Nodes.BaseAddressNotAllowed", reason ?? "Адрес панели не разрешён.")); + } + + public async Task ProbeAsync(Node node, CancellationToken cancellationToken) + { + try + { + var client = GetClient(node); + var health = await client.CheckHealthAsync(cancellationToken); + return new NodeProbeResult(health.Ok, health.Ok ? null : health.ErrorMessage); + } + catch (Exception ex) + { + return new NodeProbeResult(false, ex.Message); + } + } + + public async Task>> ListInboundsAsync(Node node, CancellationToken cancellationToken) + { + try + { + var client = GetClient(node); + var remoteInbounds = await client.ListInboundsAsync(cancellationToken); + + var mapped = remoteInbounds + .Select(i => (Summary: i, Protocol: TryParseProtocol(i.Protocol))) + .Where(x => x.Protocol is not null) + .Select(x => new RemoteInboundInfo(x.Summary.ExternalId, x.Protocol!.Value, x.Summary.Remark, x.Summary.Port)) + .ToList(); + + return Result.Success>(mapped); + } + catch (Exception ex) + { + return Result.Failure>( + Error.Failure("Xui.Unreachable", $"Нода недоступна: {ex.Message}")); + } + } + + public async Task> AddClientAsync( + Node node, string inboundRemoteId, VpnProtocol protocol, string clientEmail, string clientName, + int deviceLimit, CancellationToken cancellationToken) + { + try + { + var client = GetClient(node); + var request = new AddClientRequest(clientName, clientEmail, ToRemoteProtocol(protocol), deviceLimit, null); + var result = await client.AddClientAsync(inboundRemoteId, request, cancellationToken); + return Result.Success(result.ExternalClientId); + } + catch (Exception ex) + { + return Result.Failure(Error.Failure("Xui.AddClientFailed", $"Не удалось создать клиента: {ex.Message}")); + } + } + + public async Task RemoveClientAsync( + Node node, string inboundRemoteId, string clientExternalId, VpnProtocol protocol, CancellationToken cancellationToken) + { + try + { + var client = GetClient(node); + await client.RemoveClientAsync(inboundRemoteId, clientExternalId, ToRemoteProtocol(protocol), cancellationToken); + return Result.Success(); + } + catch (Exception ex) + { + return Result.Failure(Error.Failure("Xui.RemoveClientFailed", $"Не удалось удалить клиента: {ex.Message}")); + } + } + + public async Task UpdateClientAsync( + Node node, string inboundRemoteId, string clientExternalId, VpnProtocol protocol, + string name, int deviceLimit, bool enable, CancellationToken cancellationToken) + { + try + { + var client = GetClient(node); + var request = new UpdateClientRequest(deviceLimit, null, enable, name); + await client.UpdateClientAsync(inboundRemoteId, clientExternalId, ToRemoteProtocol(protocol), request, cancellationToken); + return Result.Success(); + } + catch (Exception ex) + { + return Result.Failure(Error.Failure("Xui.UpdateClientFailed", $"Не удалось изменить клиента: {ex.Message}")); + } + } + + public async Task> BuildConnectionStringAsync( + Node node, Inbound inbound, string clientExternalId, string clientName, string publicHost, + CancellationToken cancellationToken) + { + try + { + var client = GetClient(node); + var remoteInbound = await client.GetInboundAsync(inbound.RemoteInboundId, cancellationToken); + if (remoteInbound is null) + { + return Result.Failure( + Error.Failure("Xui.ConnectionStringFailed", "Inbound не найден на панели.")); + } + + var builder = connectionStringResolver.Resolve(ToRemoteProtocol(inbound.Protocol)); + if (builder is null) + { + return Result.Failure( + Error.Failure("Xui.ConnectionStringFailed", $"Протокол {inbound.Protocol} не поддерживается.")); + } + + var request = new XuiConnectionStringRequest( + clientExternalId, clientName, inbound.Port, publicHost, node.BaseAddress.ToString(), remoteInbound); + + return Result.Success(builder.Build(request)); + } + catch (Exception ex) + { + return Result.Failure(Error.Failure("Xui.ConnectionStringFailed", $"Не удалось построить ссылку: {ex.Message}")); + } + } + + public void InvalidateClient(Guid nodeId) + { + if (_clients.TryRemove(nodeId, out var lazy) && lazy.IsValueCreated) + (lazy.Value as IDisposable)?.Dispose(); + } + + private IXuiClient GetClient(Node node) + => _clients.GetOrAdd(node.Id, _ => new Lazy(() => CreateClient(node))).Value; + + private IXuiClient CreateClient(Node node) + { + var httpClient = httpClientFactory.Create(node.BaseAddress, allowInsecureTls: false, timeout: TimeSpan.FromSeconds(15)); + var password = secretProtector.Unprotect(node.Credentials.ProtectedPassword); + var logger = loggerFactory.CreateLogger(); + return new XuiClient(httpClient, node.Credentials.Username, password, logger); + } + + private static VpnProtocol? TryParseProtocol(string raw) => raw.ToLowerInvariant() switch + { + "vless" => VpnProtocol.Vless, + "vmess" => VpnProtocol.Vmess, + "trojan" => VpnProtocol.Trojan, + "shadowsocks" => VpnProtocol.Shadowsocks, + _ => null, + }; + + private static string ToRemoteProtocol(VpnProtocol protocol) => protocol switch + { + VpnProtocol.Vless => "vless", + VpnProtocol.Vmess => "vmess", + VpnProtocol.Trojan => "trojan", + VpnProtocol.Shadowsocks => "shadowsocks", + _ => throw new ArgumentOutOfRangeException(nameof(protocol)), + }; + + public void Dispose() + { + foreach (var lazy in _clients.Values) + { + if (lazy.IsValueCreated) + (lazy.Value as IDisposable)?.Dispose(); + } + + _clients.Clear(); + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8c5ad78 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +services: + db: + image: postgres:17-alpine + environment: + POSTGRES_DB: ${POSTGRES_DB:-pnvpanel} + POSTGRES_USER: ${POSTGRES_USER:-pnvpanel} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-pnvpanel} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-pnvpanel} -d ${POSTGRES_DB:-pnvpanel}'] + interval: 5s + timeout: 5s + retries: 10 + restart: unless-stopped + + app: + build: + context: . + dockerfile: Dockerfile + depends_on: + db: + condition: service_healthy + environment: + ASPNETCORE_ENVIRONMENT: Production + ASPNETCORE_HTTP_PORTS: '8080' + ConnectionStrings__Default: 'Host=db;Port=5432;Database=${POSTGRES_DB:-pnvpanel};Username=${POSTGRES_USER:-pnvpanel};Password=${POSTGRES_PASSWORD:-pnvpanel}' + ports: + - '8080:8080' + restart: unless-stopped + +volumes: + pgdata: diff --git a/docs/architecture.md b/docs/architecture.md index d8b1f96..2e48796 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -170,8 +170,11 @@ POST /api/configs сразу активирована и с ролью `admin`. - **Telegram id админов** (`AdminSeed__TelegramUserIds`) — авторизуют админ-действия в боте и адресуют уведомления (например, запросы на активацию). +- **Каталог приложений** (`ClientApp`): если таблица пуста — сидируется из + [`seed/client-apps.json`](../seed/client-apps.json) (стартовый набор клиентов по ОС). Дальше — правки через админ-CRUD. -Сидинг не перезаписывает существующие данные; смена пароля админа после первого старта — через приложение. +Сидинг не перезаписывает существующие данные. Принудительной смены сид-пароля при первом входе +**нет** — задавайте сильный `AdminSeed__Password` сразу; сменить пароль можно в приложении. ## RBAC — динамические роли и активация @@ -228,11 +231,18 @@ PostgreSQL: 2. `dotnet sdk` — `dotnet publish` Api; статика фронта копируется в `wwwroot`. 3. `dotnet aspnet` runtime — финальный образ запускает Api. - **docker-compose**: сервис `app` (этот образ) + сервис `db` (PostgreSQL). Всё приложение — в `app`. -- Миграции применяются на старте (dev) / отдельным шагом (prod). +- **TLS — внешний**: HTTPS терминирует внешний прокси/шлюз (nginx/Traefik/cloud LB администратора), + вне нашего compose; `app` внутри отдаёт HTTP. Приложение доверяет `X-Forwarded-Proto/For` через + `ForwardedHeaders`-middleware — иначе Secure-cookie и определение схемы за прокси работать не будут. + Отдельный nginx/Caddy в compose **не** вводим. +- **Миграции**: применяются **автоматически на старте** приложения (в MVP; при масштабировании на + несколько инстансов — вынести в отдельный шаг/джобу). - Конфигурация через `appsettings.{Env}.json` + переменные окружения / secrets (строка подключения, JWT-ключ, ключ шифрования секретов, `Telegram:BotToken`, `PublicSiteUrl`). ``` + [ внешний прокси/шлюз: TLS termination ] ← HTTPS, вне нашего compose + │ HTTP + X-Forwarded-* ┌────────────────── docker-compose ──────────────────┐ │ app (единый образ) db (postgres) │ │ ├─ REST /api └─ том с данными │ diff --git a/docs/backend-conventions.md b/docs/backend-conventions.md index 8f132a3..efec9cf 100644 --- a/docs/backend-conventions.md +++ b/docs/backend-conventions.md @@ -24,6 +24,7 @@ backend/ Configs/ # CreateVpnConfig, EditVpnConfig, RotateVpnConfig, RevokeVpnConfig, GetMyConfigs, GetConfigLink, GetSubscription, ... Nodes/ # RegisterNode, SyncNode, ListNodes, ... Inbounds/ # PublishInbound, ListInbounds, ... + Apps/ # (admin) CRUD каталога ClientApp; GetApps (по ОС) для юзера Admin/ # ListUsers, BlockUser/UnblockUser, ChangeUserRole, GetStats, Audit, ... PnvPanel.Infrastructure/ Persistence/ diff --git a/docs/domain-model.md b/docs/domain-model.md index 7179cd0..d2cd2b3 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -49,12 +49,12 @@ ClientApp (каталог приложений-клиен | ----------------- | ------------- | ---------------------------------------------------------- | | `Id` | `Guid` | PK (внутренний) | | `NodeId` | `Guid` | FK → Node | -| `RemoteInboundId` | `int` | Id inbound в 3x-ui | +| `RemoteInboundId` | `string` | Id inbound в 3x-ui (ThreeXui.Net отдаёт его как string, не число) | | `Protocol` | `VpnProtocol` | `Vless` / `Vmess` / `Trojan` / `Shadowsocks` | | `Remark` | `string` | Метка из 3x-ui | | `Port` | `int` | | | `IsPublished` | `bool` | Доступен ли для самообслуживания пользователями | -| `AllowedRoles` | `AppRole[]` (M:N) | Роли, которым разрешено создавать конфиги в этом инбаунде | +| `AllowedRoleIds` | `Guid[]` | Id ролей, которым разрешено создавать конфиги (native PostgreSQL `uuid[]`; не навигация на `AppRole` — тот в Infrastructure/Identity, Domain на него не ссылается) | | `DisplayName` | `string?` | Витринное имя для пользователя, напр. «Германия (Trojan)» | | `MaxClients` | `int?` | Лимит клиентов (null = без лимита) | | `LastSyncAt` | `DateTimeOffset?` | | @@ -76,7 +76,7 @@ ClientApp (каталог приложений-клиен | `InboundId` | `Guid` | FK → Inbound | | `Label` | `string?` | Пользовательская метка («Мой телефон»); редактируется юзером | | `ClientEmail` | `string` | Уникальный ключ клиента в 3x-ui; схема `pnv_{userIdShort}_{rand}` (уникален в рамках панели, виден владелец) | -| `ClientUuid` | `Guid` | UUID клиента (VLESS/VMess) | +| `ClientExternalId` | `string` | Идентификатор клиента, который вернула панель (UUID для VLESS/VMess, пароль для Trojan/Shadowsocks — ThreeXui.Net отдаёт его как string) | | `Protocol` | `VpnProtocol` | Денормализовано с inbound | | `DeviceLimit` | `int` | Лимит одновременных устройств/IP (0 = без лимита); задаёт юзер → `limitIp` в 3x-ui | | `TrafficLimit` | `TrafficLimit` (VO) | Лимит в байтах (0 = безлимит) | @@ -146,6 +146,7 @@ ClientApp (каталог приложений-клиен | `IsEnabled` | `bool` | Показывать пользователям | Управляется админом (CRUD). Пользователю отдаётся только `IsEnabled`, сгруппировано по `OperatingSystem`. +Стартовый набор сидируется из [`seed/client-apps.json`](../seed/client-apps.json), если таблица пуста. ### AuditLog — журнал действий Аудит значимых действий (прежде всего админских) для расследований и прозрачности. @@ -218,6 +219,7 @@ UI **настойчиво напоминает** привязать его (ед | `Status` | `ActivationStatus` | `Pending` / `Approved` / `Rejected` | | `DecidedBy` | `Guid?` | Админ, принявший решение | | `DecidedAt` | `DateTimeOffset?` | | +| `RejectionReason` | `string?` | Комментарий админа при отклонении (опционально) | | `CreatedAt` | `DateTimeOffset` | | Инварианты: одновременно не более одного `Pending`-запроса на пользователя; `Approved` → diff --git a/docs/frontend.md b/docs/frontend.md index bdeb145..ddd821d 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -33,12 +33,15 @@ SPA на **React 19 + Vite + TypeScript**. Общается с бэком по R frontend/ src/ app/ # провайдеры (Query, Router, Auth, Theme), корневой layout - routes/ # маршруты TanStack Router (login, dashboard, configs, admin/*) + routes/ # маршруты TanStack Router (login, dashboard, configs, instructions, admin/*) features/ auth/ # формы, хуки useLogin/useRegister, стор авторизации - configs/ # список/создание/детали конфигов, QR, ссылка-подписка + configs/ # список/создание/редактирование/детали конфигов, QR, подписка + instructions/ # страница инструкций + каталог приложений по ОС nodes/ # (admin) управление нодами - admin/ # пользователи, статистика + apps/ # (admin) CRUD каталога приложений + admin/ # пользователи, роли, аудит, статистика + theme/ # провайдер темы (light/dark/system) + переключатель shared/ api/ # http-клиент (fetch + JWT/refresh), сгенерированные типы, query-хуки realtime/ # инициализация SignalR, подписки → инвалидация Query-кэша @@ -60,12 +63,15 @@ frontend/ быстрые действия (копировать ссылку, показать QR, перевыпустить, отозвать) + карточка «Общая подписка» (агрегированная ссылка/QR со всеми конфигами). Для неактивированного — экран «запросить активацию». - **Создание конфига**: выбор локации/inbound (по `DisplayName`) + метка + лимит устройств → - мгновенная выдача ссылки + QR + краткие инструкции по подключению (iOS/Android/Windows). + мгновенная выдача ссылки + QR + ссылка на страницу инструкций. +- **Страница инструкций** (`/instructions`): общие шаги «как импортировать ссылку/QR» + каталог + приложений (`GET /api/apps`), **сгруппированный по ОС**; клик по приложению открывает ссылку на + скачивание. Данные ведёт админ (каталог `ClientApp`). - **Редактирование конфига**: изменить метку и лимит устройств. - **Настройки аккаунта**: смена пароля, привязка/отвязка Telegram, **удаление аккаунта** (с подтверждением). - **Админка**: таблицы (TanStack Table) с пагинацией/фильтрами для нод, пользователей, конфигов, ролей, - журнала аудита; очередь запросов активации; графики трафика (Recharts). Блокировка пользователя — с - подтверждением (гасит VPN). + каталога приложений, журнала аудита; очередь запросов активации; графики трафика (Recharts). + Блокировка пользователя — с подтверждением (гасит VPN). Управление приложениями (название, ссылка, ОС, вкл/выкл). - **Состояния**: скелетоны при загрузке, аккуратные пустые состояния и toasts на ошибки/успех. ## Работа с API diff --git a/docs/roadmap.md b/docs/roadmap.md index 4aa40bd..2817a26 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -6,11 +6,13 @@ - Solution + 4 проекта (Domain/Application/Infrastructure/Api), ссылки по Clean Architecture. - `Directory.Build.props`, `.editorconfig`, nullable + анализаторы, `dotnet format` в CI. - EF Core + Npgsql, первая миграция. -- Scaffolding фронта: Vite + React + TS + Tailwind + shadcn/ui + TanStack Query/Router; dev-прокси `/api`,`/hubs` на бэк. +- Scaffolding фронта: Vite + React + TS + Tailwind + shadcn/ui + TanStack Query/Router; **тема light/dark/system** (провайдер + переключатель); i18n (RU/EN); dev-прокси `/api`,`/hubs` на бэк. - **Единый контейнер**: multi-stage Dockerfile (node → dotnet publish → aspnet), Api раздаёт SPA из - `wwwroot` (fallback на `index.html`); docker-compose `app` + `db` (PostgreSQL). + `wwwroot` (fallback на `index.html`); docker-compose `app` + `db` (PostgreSQL); `ForwardedHeaders` + (TLS — внешним прокси); авто-применение миграций на старте. - Health-check `/health`, Serilog, OpenAPI + Scalar. -- **Готово, когда**: единый образ поднимается в docker-compose рядом с postgres, отдаёт заглушку SPA и `/health`, есть базовая миграция. +- **CI (GitHub Actions)**: `dotnet build/test` + `pnpm build/lint/typecheck` (без деплоя). +- **Готово, когда**: единый образ поднимается в docker-compose рядом с postgres, отдаёт заглушку SPA и `/health`, есть базовая миграция, CI зелёный. ## M1 — Аутентификация и сидинг - ASP.NET Core Identity (`AppUser`/`AppRole` c `MaxConfigs`); `DbInitializer`: системные роли @@ -44,7 +46,8 @@ - Подписка: агрегированная `/sub/{userToken}` (все конфиги) + по конфигу `/sub/{configToken}`; заголовки `Subscription-Userinfo` / `profile-update-interval`. - Самоудаление аккаунта (`DELETE /api/auth/me`): отзыв всех конфигов + удаление данных. -- Фронт: дашборд (метки, лимит устройств), создание/редактирование, инструкции подключения, копирование, QR, отзыв, перевыпуск, настройки аккаунта. +- Каталог приложений `ClientApp` (домен + `GET /api/apps` по ОС; сид из `seed/client-apps.json`) + **страница инструкций** на фронте. +- Фронт: дашборд (метки, лимит устройств), создание/редактирование, страница инструкций, копирование, QR, отзыв, перевыпуск, настройки аккаунта. - **Готово, когда**: активированный юзер создаёт рабочий конфиг в доступном инбаунде в пределах квоты; работает агрегированная подписка. ## M5 — Синхронизация трафика и realtime @@ -57,6 +60,7 @@ ## M6 — Админ-статистика, управление пользователями, аудит - ListUsers, BlockUser (→ отключение конфигов в 3x-ui) / UnblockUser, ChangeUserRole, ResetUserPassword (без привязки TG), GetUserConfigs, force-revoke, GetStats. - `AuditLog`: запись значимых действий (Web/Telegram/System) + эндпоинт `/api/admin/audit`. +- Каталог приложений: админ-CRUD `ClientApp` (`/api/admin/apps`) — название, ссылка, ОС, порядок, вкл/выкл. - Фронт: таблицы пользователей/конфигов/ролей, журнал аудита, графики трафика (Recharts), сводки. - **Готово, когда**: админ видит статистику и журнал, управляет пользователями/ролями/конфигами; блокировка гасит VPN. @@ -75,7 +79,7 @@ ## M8 — Закалка (hardening) - Полный набор тестов (Domain/Application/Integration с Testcontainers). - Rate-limiting, аудит-лог действий, единообразные ProblemDetails, ретеншн `TrafficSample`. -- Прод-конфиг docker-compose (secrets, миграции отдельным шагом, опц. reverse-proxy для TLS). +- Прод-конфиг docker-compose (secrets, том для key-ring Data Protection, healthchecks); TLS — внешним прокси. - **Готово, когда**: зелёный CI, покрытие ключевых сценариев, готовность к деплою. ## Backlog (после MVP) diff --git a/docs/tech-stack.md b/docs/tech-stack.md index 773721e..7b8d953 100644 --- a/docs/tech-stack.md +++ b/docs/tech-stack.md @@ -125,8 +125,12 @@ MVP (не нужен публичный webhook, проще в одиночно + fallback на `index.html`), фронт и бек — один origin. - **docker-compose**: `app` (единый образ) + `db` (PostgreSQL) с томом. - Почему не отдельный nginx: единый origin упрощает CORS/куки/деплой и укладывается в требование - «фронт+бек в одном контейнере». Nginx/reverse-proxy — опция для прод (TLS-терминация) поверх, но не обязателен. -- **CI**: сборка/тесты бэка (`dotnet test`), линт/сборка фронта (`pnpm build`), сборка единого образа. + «фронт+бек в одном контейнере». +- **TLS — внешний** (решение): HTTPS терминирует внешний прокси/шлюз (nginx/Traefik/cloud LB) вне + compose; `app` отдаёт HTTP и доверяет `X-Forwarded-*` через `ForwardedHeaders`. Свой nginx/Caddy не вводим. +- **Миграции** — авто на старте приложения (MVP). +- **CI** — GitHub Actions, **только сборка/тесты**: `dotnet build`/`test`, `pnpm build`/`lint`/`typecheck`. + Публикация образа и деплой — вручную/позже (в MVP не автоматизируем). - **Пакетный менеджер фронта**: pnpm (быстрый, экономный по диску). ## Принятые решения (по открытым вопросам) @@ -166,6 +170,8 @@ MVP (не нужен публичный webhook, проще в одиночно | Самоудаление аккаунта | Разрешено: отзыв всех конфигов + удаление данных, аудит анонимизируется | | Версионирование API | Без версий в MVP (`/api` без `v1`) | | Подписка (заголовки) | `Subscription-Userinfo` (used/total/expire) + `profile-update-interval` | +| Тема сайта | Светлая + тёмная (+ системная); Tailwind `dark`, выбор в localStorage | +| Инструкции/приложения | Отдельная страница инструкций + каталог `ClientApp` (админ CRUD, юзер — по ОС); стартовый сид из `seed/client-apps.json` | | Реконсиляция с 3x-ui | На синхронизации сверяем проекцию с панелью, помечаем дрейф, не «воскрешаем» молча | Также заложены: CSRF-защита refresh-cookie + Identity lockout; проверка квоты в транзакции; схема diff --git a/docs/vision.md b/docs/vision.md index 68bb27d..a73bbeb 100644 --- a/docs/vision.md +++ b/docs/vision.md @@ -25,7 +25,7 @@ PnvPanel **не заменяет** Xray/3x-ui — он оркестрирует | ----------- | -------------------------------------------------------------------------------------------- | | **Guest** | Регистрация, вход, публичный эндпоинт подписки (`/sub/{token}`). | | **User** | После **активации** — CRUD своих конфигов (в рамках квоты роли и доступных инбаундов), просмотр трафика/срока, ссылка/QR, отзыв. | -| **Admin** | Всё выше без лимитов + ноды, публикация inbounds с выбором ролей, роли/квоты, активация пользователей, стата. | +| **Admin** | Всё выше без лимитов + ноды, публикация inbounds с выбором ролей, роли/квоты, активация пользователей, каталог приложений, стата. | | *(кастомные)* | Админ создаёт роли (напр. `vip`) со своей квотой конфигов и назначает их пользователям. | ### RBAC — динамические роли с квотой @@ -107,6 +107,8 @@ PnvPanel **не заменяет** Xray/3x-ui — он оркестрирует - Синхронизация трафика (фоновая) + realtime-обновления по SignalR. - Базовая статистика для админа. - Telegram-бот: ссылка на сайт, просмотр конфигов, привязка Telegram и passwordless-вход. +- Светлая/тёмная тема сайта. +- Страница инструкций по подключению + каталог приложений по ОС (админ ведёт, юзер видит сгруппировано). - Единый Docker-образ (фронт+бек) + PostgreSQL в docker-compose. **За рамками MVP (backlog):** diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/frontend/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d6af7e3 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,32 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the Oxlint configuration + +If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`: + +```json +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "options": { + "typeAware": true + }, + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} +``` + +See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories. diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..0fca6f0 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + frontend + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..b9777dd --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,31 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "typecheck": "tsc -b", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.101.2", + "i18next": "^26.3.4", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-i18next": "^17.0.8" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.2", + "@types/node": "^24.13.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "oxlint": "^1.71.0", + "tailwindcss": "^4.3.2", + "typescript": "~6.0.2", + "vite": "^8.1.1" + } +} diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 0000000..43bf01b --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,1147 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@tanstack/react-query': + specifier: ^5.101.2 + version: 5.101.2(react@19.2.7) + i18next: + specifier: ^26.3.4 + version: 26.3.4(typescript@6.0.3) + react: + specifier: ^19.2.7 + version: 19.2.7 + react-dom: + specifier: ^19.2.7 + version: 19.2.7(react@19.2.7) + react-i18next: + specifier: ^17.0.8 + version: 17.0.8(i18next@26.3.4(typescript@6.0.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3) + devDependencies: + '@tailwindcss/vite': + specifier: ^4.3.2 + version: 4.3.2(vite@8.1.1(@types/node@24.13.2)(jiti@2.7.0)) + '@types/node': + specifier: ^24.13.2 + version: 24.13.2 + '@types/react': + specifier: ^19.2.17 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: ^6.0.3 + version: 6.0.3(vite@8.1.1(@types/node@24.13.2)(jiti@2.7.0)) + oxlint: + specifier: ^1.71.0 + version: 1.72.0 + tailwindcss: + specifier: ^4.3.2 + version: 4.3.2 + typescript: + specifier: ~6.0.2 + version: 6.0.3 + vite: + specifier: ^8.1.1 + version: 8.1.1(@types/node@24.13.2)(jiti@2.7.0) + +packages: + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.137.0': + resolution: {integrity: sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==} + + '@oxlint/binding-android-arm-eabi@1.72.0': + resolution: {integrity: sha512-zhCmvn+1Mj3UchAc/90i99S0t7jJUsHmFVSPg4UWrjO8b8eaSGwscgO6QAUtvHBstkjQwBttQNswEnAF1mIQdA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.72.0': + resolution: {integrity: sha512-mtH+aY/ozv1eZoCUC2owjFAtyNBKHpJHygKeEu9zXXnQGW1Q2/qOpvx+I+Lf23+TvTz66F4iiXUbl2cGvoLPCQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.72.0': + resolution: {integrity: sha512-EvnajNPDtfknB3ZieeOOyDTwJn9QXDiwfnF4ZDQqART6RG6hjY4WigQcZdGoK2dkB3e1vrmEzN9aYbQCUkh/gQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.72.0': + resolution: {integrity: sha512-ZkCdEa/G80A7vEHfeCDz/+L3m33DE73v32mDKhgOIgz8Uwf0DFcK7+uu6qC+7LEhmz5fpOe1osWKyjSNMydFIQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.72.0': + resolution: {integrity: sha512-NroXv2vh+sxVY1uya/rM5pjhx1hm8BzlYpx9q67QP0Xhw5MH2bf5GJylpvLEC+781p1Xli/317EoV9AlGwViag==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.72.0': + resolution: {integrity: sha512-0NDywYgfj279Ou/BcQuCYSj7NJwBfmWn5qc5uGO/Ny7fUWmXyIpvawqX/8acQlWG6IXelJsJhj+JAy6sjsKj0A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.72.0': + resolution: {integrity: sha512-4vpXB06h65Ezsy4hRyrGjGrfa1SkVPii09yaajiYhmVpgsFiLD+KNxIx/BNAY+XiO+i1yqp9HHdwqM8VTqa5XQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.72.0': + resolution: {integrity: sha512-immaN4g2ZGFiOkKrvRX9LvzZdd2GkQM5wR+UyzYyUuyhUTXGQ4HKUJH18xp4G8OfhCVaVAJfKZxwE1r8+4hhaQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-arm64-musl@1.72.0': + resolution: {integrity: sha512-JGHS9Mnr7iWyyLDxgCv1MhzVpAckgptg00F2gnxt/GD7lQ2SW1BRcxHqhSTaSdDpjWRrBkBxMMh4+Hn3aVtExg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-ppc64-gnu@1.72.0': + resolution: {integrity: sha512-AOYgBZqxNshrg83P9v0RYv+m8s10Cqkj4/PxXFDhcS3k7FqsIG5+CxErshZCIN7G8iy4Y+VGfAsuEdar8AcbBg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-gnu@1.72.0': + resolution: {integrity: sha512-QMybPS5ij3/vrKG67mqzHwW++91sYxK/PPUVi6SBtNCEzW4niS52fVBdXbQ6nou0wWbUPEpx8Sl/ZjtgE3clXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-musl@1.72.0': + resolution: {integrity: sha512-gOc3W7JV0PXRpIL7stUlLe3Wa9Gp0Kdlup87IT3gHDvPKck2xNgMIl/Gs2lldYY2lyXZDC4rWi3hmoLUobkgbQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-s390x-gnu@1.72.0': + resolution: {integrity: sha512-rpGxph+FjjHcYI5q6uxB3Az+tnfmEnDbSA8+PK9ZE/VzyUAkvBOMeuY7ZQMhu5mpZH7YQDsTdW6Cx4kV/msc6w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-gnu@1.72.0': + resolution: {integrity: sha512-WND+uhf/Ko13SLqQMWQUgsZuLvYYEvL0ZKgg0tgGYfLqxG7l8Ju123fHDMJyYSDl5E3bUbpFUuii/OvMreFQzw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-musl@1.72.0': + resolution: {integrity: sha512-SrpbrUL70nG9vh6zP4/oKHWgLuHquwsr7MW9XOn0olBVgh10Uqr8qscKhQoBGEn6olK/IUpn5GSKcdQ5AjUhGA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxlint/binding-openharmony-arm64@1.72.0': + resolution: {integrity: sha512-qkrsEn6NmgFKr7U/QnezQMb+q/vzAy0Dd9Y95gQGQTyjzDLN+HRZMuM5u70iyH4nBLCfKBzhjMsYCehKay2jyg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.72.0': + resolution: {integrity: sha512-LWR6ZlFZph+KPjXv8opgZsXRDCdrdQe8VL8Cg9zxCoBS73h6znzZpydVgmdnwj8mB9AuSM5jxEgDJDpQkjboeg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.72.0': + resolution: {integrity: sha512-yt6HEh7IsHvtjRWtmeZRX134eaXKHq5Gnqlf1xBJdJl1JtdoRUEJw3nAxpZoUDS860cX/foKbztO441anVBtVQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.72.0': + resolution: {integrity: sha512-b2eKFD2hX7tIwmo/cyH6TDq8vzWRZ2qNHrzoGntUTmq0h3zQh/uX3eTSHCwI8OB/ADQfJCRelLItK8BsxuucDA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/binding-android-arm64@1.1.3': + resolution: {integrity: sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.1.3': + resolution: {integrity: sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.3': + resolution: {integrity: sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.1.3': + resolution: {integrity: sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.1.3': + resolution: {integrity: sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.1.3': + resolution: {integrity: sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.1.3': + resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.1.3': + resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.1.3': + resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.1.3': + resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.1.3': + resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.1.3': + resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.1.3': + resolution: {integrity: sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.1.3': + resolution: {integrity: sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.1.3': + resolution: {integrity: sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@tailwindcss/node@4.3.2': + resolution: {integrity: sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==} + + '@tailwindcss/oxide-android-arm64@4.3.2': + resolution: {integrity: sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.2': + resolution: {integrity: sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.2': + resolution: {integrity: sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.2': + resolution: {integrity: sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2': + resolution: {integrity: sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.2': + resolution: {integrity: sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.2': + resolution: {integrity: sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.2': + resolution: {integrity: sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.2': + resolution: {integrity: sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.2': + resolution: {integrity: sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.2': + resolution: {integrity: sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.2': + resolution: {integrity: sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.2': + resolution: {integrity: sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.2': + resolution: {integrity: sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@tanstack/query-core@5.101.2': + resolution: {integrity: sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==} + + '@tanstack/react-query@5.101.2': + resolution: {integrity: sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==} + peerDependencies: + react: ^18 || ^19 + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/node@24.13.2': + resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + + '@vitejs/plugin-react@6.0.3': + resolution: {integrity: sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + enhanced-resolve@5.21.6: + resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} + engines: {node: '>=10.13.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + + i18next@26.3.4: + resolution: {integrity: sha512-pa7m0d7pBDqGHZxljT+WPFeyFgQ7P7SciPPo1tTqYuO0z4sqADYhwnBESmmGp/wEof1inwdls/k8ZgTg8rxFHA==} + peerDependencies: + typescript: ^5 || ^6 + peerDependenciesMeta: + typescript: + optional: true + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + oxlint@1.72.0: + resolution: {integrity: sha512-1rhdZIP/EvoI91ABIwNU5Q8+bWf8mjrS5UzIOZld4d4bXxJvtlUhlQvaoTogIGin/qdErMOrwaIJvCSIAKTLhA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=0.22.1' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.16: + resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} + engines: {node: ^10 || ^12 || >=14} + + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react-i18next@17.0.8: + resolution: {integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==} + peerDependencies: + i18next: '>= 26.2.0' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 || ^6 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + + rolldown@1.1.3: + resolution: {integrity: sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tailwindcss@4.3.2: + resolution: {integrity: sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + vite@8.1.1: + resolution: {integrity: sha512-X/05/cT+VITy2AeDc1der6smvGWWREtL4hPbPTaVbjSBuuWkmNOjR6HP3NzqcQA2nF6VHGUPaFRJyft/2AE9Kg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + +snapshots: + + '@babel/runtime@7.29.7': {} + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oxc-project/types@0.137.0': {} + + '@oxlint/binding-android-arm-eabi@1.72.0': + optional: true + + '@oxlint/binding-android-arm64@1.72.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.72.0': + optional: true + + '@oxlint/binding-darwin-x64@1.72.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.72.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.72.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.72.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.72.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.72.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.72.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.72.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.72.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.72.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.72.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.72.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.72.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.72.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.72.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.72.0': + optional: true + + '@rolldown/binding-android-arm64@1.1.3': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.3': + optional: true + + '@rolldown/binding-darwin-x64@1.1.3': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.3': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.3': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.3': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.3': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.3': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.3': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.3': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.3': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.3': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.3': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.1.3': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.1.3': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@tailwindcss/node@4.3.2': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.21.6 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.2 + + '@tailwindcss/oxide-android-arm64@4.3.2': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.2': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.2': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.2': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.2': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.2': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.2': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.2': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.2': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.2': + optional: true + + '@tailwindcss/oxide@4.3.2': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.2 + '@tailwindcss/oxide-darwin-arm64': 4.3.2 + '@tailwindcss/oxide-darwin-x64': 4.3.2 + '@tailwindcss/oxide-freebsd-x64': 4.3.2 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.2 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.2 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.2 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.2 + '@tailwindcss/oxide-linux-x64-musl': 4.3.2 + '@tailwindcss/oxide-wasm32-wasi': 4.3.2 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.2 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.2 + + '@tailwindcss/vite@4.3.2(vite@8.1.1(@types/node@24.13.2)(jiti@2.7.0))': + dependencies: + '@tailwindcss/node': 4.3.2 + '@tailwindcss/oxide': 4.3.2 + tailwindcss: 4.3.2 + vite: 8.1.1(@types/node@24.13.2)(jiti@2.7.0) + + '@tanstack/query-core@5.101.2': {} + + '@tanstack/react-query@5.101.2(react@19.2.7)': + dependencies: + '@tanstack/query-core': 5.101.2 + react: 19.2.7 + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/node@24.13.2': + dependencies: + undici-types: 7.18.2 + + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + + '@vitejs/plugin-react@6.0.3(vite@8.1.1(@types/node@24.13.2)(jiti@2.7.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.1.1(@types/node@24.13.2)(jiti@2.7.0) + + csstype@3.2.3: {} + + detect-libc@2.1.2: {} + + enhanced-resolve@5.21.6: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fsevents@2.3.3: + optional: true + + graceful-fs@4.2.11: {} + + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + + i18next@26.3.4(typescript@6.0.3): + optionalDependencies: + typescript: 6.0.3 + + jiti@2.7.0: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + nanoid@3.3.15: {} + + oxlint@1.72.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.72.0 + '@oxlint/binding-android-arm64': 1.72.0 + '@oxlint/binding-darwin-arm64': 1.72.0 + '@oxlint/binding-darwin-x64': 1.72.0 + '@oxlint/binding-freebsd-x64': 1.72.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.72.0 + '@oxlint/binding-linux-arm-musleabihf': 1.72.0 + '@oxlint/binding-linux-arm64-gnu': 1.72.0 + '@oxlint/binding-linux-arm64-musl': 1.72.0 + '@oxlint/binding-linux-ppc64-gnu': 1.72.0 + '@oxlint/binding-linux-riscv64-gnu': 1.72.0 + '@oxlint/binding-linux-riscv64-musl': 1.72.0 + '@oxlint/binding-linux-s390x-gnu': 1.72.0 + '@oxlint/binding-linux-x64-gnu': 1.72.0 + '@oxlint/binding-linux-x64-musl': 1.72.0 + '@oxlint/binding-openharmony-arm64': 1.72.0 + '@oxlint/binding-win32-arm64-msvc': 1.72.0 + '@oxlint/binding-win32-ia32-msvc': 1.72.0 + '@oxlint/binding-win32-x64-msvc': 1.72.0 + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.16: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react-i18next@17.0.8(i18next@26.3.4(typescript@6.0.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3): + dependencies: + '@babel/runtime': 7.29.7 + html-parse-stringify: 3.0.1 + i18next: 26.3.4(typescript@6.0.3) + react: 19.2.7 + use-sync-external-store: 1.6.0(react@19.2.7) + optionalDependencies: + react-dom: 19.2.7(react@19.2.7) + typescript: 6.0.3 + + react@19.2.7: {} + + rolldown@1.1.3: + dependencies: + '@oxc-project/types': 0.137.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.3 + '@rolldown/binding-darwin-arm64': 1.1.3 + '@rolldown/binding-darwin-x64': 1.1.3 + '@rolldown/binding-freebsd-x64': 1.1.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.3 + '@rolldown/binding-linux-arm64-gnu': 1.1.3 + '@rolldown/binding-linux-arm64-musl': 1.1.3 + '@rolldown/binding-linux-ppc64-gnu': 1.1.3 + '@rolldown/binding-linux-s390x-gnu': 1.1.3 + '@rolldown/binding-linux-x64-gnu': 1.1.3 + '@rolldown/binding-linux-x64-musl': 1.1.3 + '@rolldown/binding-openharmony-arm64': 1.1.3 + '@rolldown/binding-wasm32-wasi': 1.1.3 + '@rolldown/binding-win32-arm64-msvc': 1.1.3 + '@rolldown/binding-win32-x64-msvc': 1.1.3 + + scheduler@0.27.0: {} + + source-map-js@1.2.1: {} + + tailwindcss@4.3.2: {} + + tapable@2.3.3: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tslib@2.8.1: + optional: true + + typescript@6.0.3: {} + + undici-types@7.18.2: {} + + use-sync-external-store@1.6.0(react@19.2.7): + dependencies: + react: 19.2.7 + + vite@8.1.1(@types/node@24.13.2)(jiti@2.7.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.16 + rolldown: 1.1.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.2 + fsevents: 2.3.3 + jiti: 2.7.0 + + void-elements@3.1.0: {} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..8c8867c --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,68 @@ +import { useTranslation } from 'react-i18next' +import { useTheme, type Theme } from './lib/theme' +import { setLanguage } from './lib/i18n' + +function App() { + const { t, i18n } = useTranslation() + const { theme, setTheme } = useTheme() + + const themes: Theme[] = ['light', 'dark', 'system'] + const langs = ['ru', 'en'] + + return ( +
+
+ {t('appName')} +
+ + +
+
+ +
+

{t('appName')}

+

{t('tagline')}

+

{t('scaffoldNote')}

+ +
+
+ ) +} + +export default App diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000000000000000000000000000000000000..02251f4b956c55af2d76fd0788124d7eee2b45eb GIT binary patch literal 13057 zcmV+cGycqpP)V|)f$;Qooc7=_G zlYe)HToTQIc!$)^+J1M1y0*T%w!p~7%ux`!eRhO?c80XDxKQ*R^lUUMnA>6NT^?feoZ8xxvP32D&s-9ow zqjcM}eesrC)NeDmsf)*P7wJ|K!&xP%Zy4iI8lF)Tv2!reW)tCzg_1=PmOwd1SQfxa z8;58t!=z~Ba7CYlNWVG>he8aRPY|+-JmozNhn!#9i#77Aa_Edt$ijyCWL#=~I>~2X zZNrQ8I0=D+NWD4pq=7~(i zhfThMNw|G>g^y9pGzxX7ZSApl@tIxFcs{p#MX{Ax&XZT+cR#U+OWc@S)pkIuI}dzu zH?^Q=<(y&Vq-oxSLfc0Zmq81bjZWf}RnssBaD6}2g-XJHLcN_|*IOu>m|x$nbm(?E zyNy!Zp=RroS;?Vg*kmoJYBi!n5{_^@rA!)=t#a^;N$8GL!*DsQb}`yvEuX!G@||An znOfUZAevPrkV_qjl|<~3QRZzG&h@C9Y5z zqpNH4xqbF_InIPh)kX}Vn^5kyed|mOuq+2>M;v~KO37a#yrEn3XDqtOl=rc6_KZ!; zreo)DFVB4|>1Zd(bvMI%8uM;3!)YMYu&cG?(PE!B~y@3yKBMt|R zAf=I16tFwPsl)!jDqvYkLHaAQ+f@W1m6F5aZvwhm4JL z{_l)@b;)mDSzle2gyFP5-r1x-5X{G}ot%VyWP@vEW80!Q=f%RTfpg>B*TA^pyWYUQ z<=xPtz}WcZ!;rFl4m1D&FFHv?K~#9!?A%+fn=lXt;9!Fc#kQ;zk~gZFsH z8e5iu@c_pzX&qb8&Dum*oXwB+fm6l6gFfC|o*wgEiy6tw~&co z9Vd_4)P%wP-KwQW7|lN-znGK#?N+j24U=$982myIBM+vsiKsc*@4-rwJxuAaHKna6 zT3wi!C~a4ZKH03qU}_1bKyx0&$CaK7_%Z+Kl$)fF5^op zZApQF2TvDav!s|krTjw-8US6ep z%!VmX4luub+fseQz_D9ATJQ?iQQwD}TZz{-yo#l12a%+7bT@E(X-hyaVS-5vuXc#^ zx^w;L21;NphGVoj*{s3f4dme0y2LC=G1-7THd`#z?;tuC{^9k(dM{Rf2GOxg7Jzho z7nSZHl7?M9kdalX`)YgoKEfiae5+;$(OGeN1eqxrv!ZCVKyH>xiyNqfe8xzY8*7)H zQls8KMp)F4D>ED;idMOU^^WhVF@q>ZSmeB0y~qC~|DB648hr%Sh|*T(4q|w2l?m2+ zvBVw3@7+Mz?^Yc#+se6KM;a<=(W-I>k)$-qL2V*t}VaW`;?P4)WqI%maIDq8!oUcSYAD`}wWjkSyAVsnF65#2zQ zZ>(K*TlS(E#4y$4Zq+e^_&}d)q20hCe3!LfLYP%nQpLJ~gM6a1hJlz3)aS<9C9me| zAcmJ#>tOwBy{HoP0Sm1&_(E+S@6 zgBIFUoei8zJmdpiq8q5=OY7t@`)JWxn_&GvKVr=Zdb_pEL_j|=?f;WK^U9Q0efd#K z9q7SfJTl4pmA$jsZ5oK8@O9#!I3Cv-kL)<8SalSsp#dcpvJ}Nz#G6FC0%9|7Fi#8; zGDJXtj!&GljT3*HE@0EE>G8Se&d)*nkqe}-?`3vPl&UqK?xG z!3XJ4M-x`EuQjhBbu?ik-)rmIt=DF_N?TVMP)8Gjn)TZ2V%H|zENbeix}kOxd@0}Q z>)HuH6Ean!uS#~4g2Ne2WsMGel|h%j9*W_quQheG^JqmKhc*RYzp0wKlGjBq2VzY_ zgOv8WC1+%W=W)k)Yp_`8kfE=uiiwOZTXi8Uj9YGr$f@yJcJ;#&-Nq~sJ7anE(@;QN z=~br%7%7`isKStX|7!1?L(apl^QvPKlrHV4S+6tNVQ*R1iGdC~WMNE1$a+=rpQmcB z>wxiLIBvOnm;u*;9Y!kJdy(T4lk|8>JAm(&wEsFIF1$_*{>2ZNd$V6DS=SfrGxAv0 zzKe377JI`&o9Ljr+VnS*EwehA{f&{cKZF(6*MG5!p5MvrFA3ll{fmRG*L@6^cb;o^ z3Wm8c?Sc6$`>~VEWw(c$Y?nRO;2Q$=ulpqPtM^=1IZx;@xK0PgO7rKQ^WHVLwtgUT z%|JF{^f(VH)wLKQ%dYiu2RmchBdxL0-M?wxxul_z*{h6ZZ`>-k(vizs((vW8Lt6Z6 zY;Dt?@JWyN`O`f;&d1Mb?e%9oyRK1ql?EE5XB2(W)|D1~Rx35$H6@6)$F?)7V|zEO zI}fu0-0}8W5=6sg$fPnZ~7=tTudl?Ecb@pxbo)vni%gP-?hL|%*?62C;x6?@E`VRnJv z?fTb;k4x;TS7Cu-z%J}uy}e-pwpLQ17Q@4DC+FCdAmNKklG$`I_pyw7E{fYmw~{Fj zi?6KcVy=Wrel)EB_DWO|0CKmI|13!gBV?X`Ozp7x>?6jr`>Qz=^4ea35!$*f}) zS$i+x_k+@P2q1RFUH^ZTTk7=n?cjfR>hTq3l3SY~#w+I8SSutXGyhw;Ws~=zMQ%Vc z>$On~47Ut?P*_!TOQ&PFmLAyJieB2X4_Fd_!WxI-AY`q1Lc-oK?+qcOTzlQ?@~x@OT}*9jTVNfl@3rGvZpWI=eKg>T zZb@6YWz)J=IhP7CF|c?G62vMEG%#U}?#86$0jR4sG~i(jRd#jmn`7b(O#?N;3a;1t zhXLssmUwGhp79luw#(*V8WL0|8+E z6=YZ_O@er~$LrD_PYGc(kJgB=;yw#+Z3X6LDUZ(NcwN=B-hjdiHm!JFar%m{(5bEW z@@_VEtG$5;`EJZ|OkJ@l&G9n((w@uNFwmU%bG|s#TbcJJos!{e+bjCjrCq_}LcN!UFgKtgg7siV*7# z!}1whTRRi*-avJPu->C}Z8EiuK$#886+H_#_!btv+rsiBbv2jAJvJ+O0{#}y(%L3H zfjU-kq_-L@2XrL*ae{{qYJkD{@dw%*bkh2P&YS-0!Xt!PRz7KHV0+~j(t9W8lAVWR zt@B*DgURgEz4>WuN>o?_iKcw$?k{||Pg7{Q2o4|VmJ)mg?{VQJA<}zEr^YAAS zgGm5RT4T3p)U;yz-tfBO^kw8?IoG!IVmc+Z3m#}AOQ?5MRa>)OcU!$N^_+yK6ayn? zK>~WK0!#ysuj^oNLakm)Zvu+J)OSubX^kv!c*xgdIvs;kln!rgG4*uZ;w0mQQO4XD zO9P{GNdv!=cQ(CAL{S(%KtuV^zC&Q{%g)PoXnp^gn^>c*`E>$hLYg2HjnbVGtWLa{7zHdG1jT@B{|Dm16 z7K2(jsfG+m*Zxof)iXxu+!H5Mo-0$pkyV3VV4B@Qms46M zuBxGRV@HxU7Wwx-6CB zaU*HO<_qn$5GH>&@?nRy1{z zkik!sLfWQ)r#75)vVwCBU*r_)Q6mp?!j85{#Xqse)ApRdE$V0%I0*~e(_{)5H)`Mk z#rExC>yjhZxuL@|+#v4#<Axw$+VpV zuT;!2Vww$je$DpAW`$FX_Ab|Ip%$;&T$-lW8jS~B$>G}rd>eQG+$h9lQx4Mx0w={m zx9?T6VU`>sR}XClkAhHEShOUe8awiq zmizhL+}5UKs3}6~It7vBTig9dfQ2Q8coo+Miiaw7n~>4ybv2Ptt0^^=VqX(t*Yya9 zr`FxxFX8(v*H=+uJ#JJWIB2A(==HDYx~^zZ2nu?2`}|Wsa*f3h3ixc+U|FDtAG$Y! z*lc_7se5Oso-Cgqe0){{!8H4g$3<8!R<6JOurD;((({c$1(pwb>(#TT!sge@4>r2@ zVL7>U`0`nsWAYErezk4(Z!gMI2?UTo{J3Ajo(u4)KYIRd>BRcG4BoS3G0EXyEp@tw z%P7__?A^a>Q&AKL@ayDO9D*Qkc!NHnO9l}kpp_6hXbMppYL(X1L?njdFT|-h2<_$; zAtDZ!1Rf%|yb!qbWKd}%0b`LzBeyNy43|QO(&h2mxQLUL)|0%agVOW)6TV!&Ip^Ls z`PG2cygM8)IecQx=Fc+nqYRo4hS^^-nM_&-y8?EJXUczP=DIw(GkTJdpEdh<_STs{ z|A)4n1GKdE=Wu!!nYoZHcUQ4S&R;oDOKX2lrkdF(mK>hz<$Pp>igjOcvoRIjlN=W8 zu8Gx5(roqn8$>gEE5vy{GiGeW8Tq{vnf3hS-V=$tZkQuftUVuU8o6k&dn=Yg3)6MOIH>nlK^-2+C6BZITr~1@So?NvG#TwL)|~=1YXGMTLpS<)ziK_CSOabe z=cB#5)yz|@0i9dSo?*CX)}UP=s6)B+F@~Em(u@Q(I9J9i_V{LmMu8BfXYMh~*oPP+ z!3~xTv|(>|=n6ZOtT~C@V!z!w%18*8T2t6}U2S##rC)mekBql&VsBX;$~ByGE$oA9 z`0Wzq8p?R{4)$l*on;!cLa}Dh^Xe?owiQZt9nH1fxxh$pN9K%CtOw?u3>85L7rr!d zXs)l{TZ{xXP&U8exz?9cv~dNNibOmt*K4I$?RxqIBZ0(?Mg-9FS{*9Bc49Qc1`=sIF-rye`aNT1G@4NwXcnyc@+bw_mTsR>5< zF<2;X0QesG_pw|TonqVBhRtfqI>ty(SIu&VOXd0CrLlfp+;WH7HYjhqnu^oAY!9cB z=B6#R?Rfz9BP`dJ=@v_?70s3HxQPk+{6Y+lM85f2NF^00*^OcM0~?JOZfR9ZPYF+# zYSs}(_BUYV8{n@2a1hD^SV41bwmi2uztR;PeBgF1F-`9>`zoNss-@3LaF2sjl~>OaaVmp7PNp+UT`6@}gR%uzqHDVeEZ14{Yt?n%JeQm+t(1_u zSc}oj^{b;+rlS|ME%+LjzSI&xu0Bblxo$MJ-J$kJ?Qu_XUXh}*@*-x@ny|}wVM%Lg z3tNB`yvr*}N?ClGL;H2cglcvErIccU3(eP7>@~4nOIcI~-`P8tSQnx=jI&{9)!1}l z;gQ%_h>ZlPSV@o@Azq1R$C6ja5!^ZGh;YRhhxs58qJWo9@Bceac&yy(pET1hnn`~7@}2L0&dfPKYs$ih7m2}R!25!(hxqA(!UIw; zK4+~Jowy3=RNC6nE=ncU{LH5?*9@W24lacJlvCZXB$CYtE@>c+~H zkV=(5I&gb{xn2!~f&fs2NQgAL6`p|kyt6kpWk}iVlqIp(H;ig`{_U9yxs1jzu^ETM z7~)Rg8C-NueqTYP&U8l{DY=Y47cR zOR@U%$KQV{mkRF|4)z9Y^t3K`@p>duY&QLUFeh6VoV`a`$U@)(z!-N*5Cj<11$EZW&hJLX83TO{lJYP74rlDZQPkm@t<=U^I)x@|UnHHkdQlh?!ltZwl92rE;;^ zZuIappj4dhld1}kttYYV-j|KF1Kus zWBnzttD^00%LFK(wrwNragFub6xiV8QE2rm<`&fcR4SLFcdtLxVuN!Aal-g6dE4%k zARZ}|xeo;K{0yf7@9aua%2j5o)CPcIOc6uLHFJOcgtB5owlcNAwyAHc0QB0Dts?c@ zUemG~j_E&W7R%+x-IO4FJl8e&*2Blmp1S#RA|)geVrxvP)NHdYuxi~g&Etn?QdNK8ZDKZ?QFLU?zh30G|t9G>a_X4zk}Ygw<^$7K!GIn(Io$>(d4ODJQ2XSd%jpK zm7>ptl$a3GyB}5-%p4>Q*p#VL^B{yQMuFCM^#l#+N!Ne z5_PrJWB=@Iy+t)H`g1lX`{bm($KE5I?0c(JEYm#t{F}j!xtsbob0{xu@0TB_*>G7w0ICn zr#VoBktqHZ~XxhiKD*lcG|b;H*|Ny3P^8ceV`sfBRfrhwZ!T+MFZ!F1Bt{q$8d9i6o?~ zODj^POr}&ivSa^R^YFIq7o0giLBKCycH_aU`F6)O6JX%nPTwh~Q`eq6*0iE#Srj2^ z*_hN3%*b83zfafy60@Cp3{J({RlSaEn&E?mrxRNC9GQ7#+f=s! z0KBf-9Ny_v2VbE%aB|Di)5kNJ^t&C`4D(>t7zYUWUFtbxt+Oq=!@O7BU)}>d*R72o zFF)3jQD_lLe4is&xzyJYC1-c{8TX$RU>&>P$%)ufpez0XSAukmh!xcekg`s$c<>-q zI#zn^JU0zzF}V60)o$_gY}PQH>b2M9&8fRZa#OauglPb zeQ@pMm&=!vNgos4CluQjLMV!pfkmxK+35bi^k&=k>9h02?l+u+m0agG;(h2|Jslc-llvtEwn~*w3bx7qnvZACG<8}AGeaDVvcHbKd2>3G^ zSFPULUn-?Pmo^-_`mLZr??uNH`2=I&yajlrF{DtUxMy#Nu}z=3y7qbUA;5`)hibMR zhXL@@uKyV0-2&A@t@!xyrBnMJl&^o@Gx$&5_q6?D=ji5grd-~=?dlg;ur(_V0wjh! zA=JV^C1m+DDkOsgr<%O9ZQFg!0}pD(#PSz4Dr_EyS5$`)VIAv);4n-SFP~YtC7sH= z7&*MfpH;gd*FHbkmD#)hVxb6xjc9~`t?_{=JS+@ip_cTicXxG<=7m9& zPX+Z8IC*GSAXuGCrZDHgR$r%jyk-fctis2Kx4HvZ|B~8uC@o)m^>Hy-O!&TKA?$&n zkP2Xc54w~!=z2?^NafyL*L0V9cbYrugHBBUj`xVyZmGFR&kvk#>1J*Z~i zNTz}?IAdJ$gkqd2!Gw(%LzE!O5s4C7q4%T~e_P{+z=DNDKrG**p=U`d5yg^vp`;Zn zsU=8gd0a9s4s0FPJePWR9eH5=+O^Kks&kC-iblNqTh2&Pw*^(4384f+D8N|fewZu_ zg2ejQ)ov;ztz;NQl7yj;A`(!H!XQu_$sqY9h_IrH*}_%1{L&_YLDvO?%R5Z-t+ClW z_qERbL?HKUZ!nt+!E9S`uoh^5A|DaIHe*_gf1`E_Vq+}{&T@t$EGhMnRjJ4z2w_W8 zp+qjs7as22^&S3wY1?+}^j-I=RcCE>#|39)g(lU7v_8;?=qK(9D8-*pPdiy)P3lIblG`+?%ea| zYoD3dopYt!tKgFicfNmNi(EWE=E4hC6(r|PYtanqJlmt57YOVrr2^tfrG(eG9C##X zu&1t@%L$RIvpj!wUA z8i>Pqot#_+Cnp6L2XPcZy1ar|9MnY+7eNvK1E)@Tr#2KsXq1*>)uUCozT7L##ok?o zhA6ofP4E|b*9tAfG?uf$#}>TIR&1A!yslP8}i7w-EzW(x#9VEvx18k%Tn=-$VV zkOtUr0b2!w3t>h?#8AZl^Az*(6KCGlD;4j~yx};`#2gN1_gv=%7KVzecIRakN{f*4 zeaI>yH;-o4OGhvGTU)(quWI)-q?V*(sVesSMv|wMUQ3hLEt=lBB$KZ9TyHr>)f7o%) zPYeU<3P)*P10*7vE)nA5#{c=6-E-_>r_u4e3i!I2+UksELwDqwMeBZ9FSP$;^Ajro z_@M#_Ss$?ejoB@!wN|kbGKs(0zLo%0QpQXW#t;oC$B0MZYZ&Ej?8~fNhcCVvPo3vo zFn0WWZaPliF^8_}yzb`*f@yg0uWv6HgNI)xa=pO%Ck(C<=-60l#uD3(wXP~c7!NoX z0&^6=N`zcc90F#qt@=Rn@r!3(*1v(Tl{B!m?Mc7yIA+nEHpY{YWr$=)F7rhR1P}(v zt{YhY#;jsW6G>#xhP*B`OCk|Pf+NN;ju1rxa*HAgoGq*rvqw&xe~;t1JA31$s?GBb z*g7&@cbKo4n<`>)!UlIAgR6q&))B0KYU8r66GbFj?8Guw4E%&}Qi_lT003LtoIZei zwD~=XZmeo+yZ2Pq3KYCF-R&11^p= z@H%s+=G`}wrbJ{()Mh71#2SP3Zy3m>l1n?0N-N1Q;z6?oSxr-G(H5m4EO>~&;}VKi zfY}3w+9z>vp#d)hVuu`)vG_aaH%3b=WKMnSu&c31;<3O;bz2iD=w+o4#oBb36 z5ZCF*Gu?zjZIR0S>_%pHY2$k8D^n7Sz_K8tCDeXM+dO<#LSg%h6`~dnVG1N@T7v&e z%wEd1!k{^zfz_1BTW{!$!B%g)J^2b87!9Y>>100X1SgT7s0z$o>^lAA=Gp_cC1(h=*5Tmf8z&LGJJ>$|K^~s`z9*OWz5MFUr?>Bi?_PGBB)#psD5?>n+q{o_ zz7~ez&;t#h8l$jwGPCC&xq2YetXYQT+0F3j(`xmNGf8dj#an|p#I*pvI*kwW4iuB> z+q3_7xB8y;pLzHG-S%+UHQA zvqp;$kmGJY>lLsN4C~&TcvAS1SErTcwcw0r@wngk zShAUA1M9b#g}^pL-zH7Q#z^&j#r9F8BTVfkR&qF<=e35goTu7c|GN)0mokj4m0%~0 zXJ8j4Hc_l;HJ&uU*Iw`8d_EscJ``s0tk9mkKo^&#TYXm-EoAzTQObxa@^u~g2t#T) zJz|rE!I_?i4dCJC=B8(_pZ{YR>|V?0iCcnU;E@$239^x?SYCfNaMHN;CtHIS_zHN9 zTkQc1v@O35okiFtq5_u+5FkY55ap@pi)O?}x0D1c*qB0KpYR}>Ul+B0Vmr}Z@+%mJ|As}sis_=ROPbov@*2thpE&?!V#Qgu$snYvCZ zrkhmkMU+fSf-s8(L37fPr&M*jRs{{THb!aXQu|P9l_-vJhHvLzMGH zE?1U0H_+PmNABp9`|KzkGfrrZ%XvdGo6*<{d5m9~L7 z_^`M;X6xDo=m6LY6RfvJEvsTK1!u8d2HPx|$S}p;sRy!I zWL55Yxu~_B`OP@~(q6&W3#)~I&+MGL%GWR$#udC151^wsswhqlii;rP9jJpiI7o&Z zAb})=HY7?4HA|re3ns`%$)FuvKCFWjhb~?IE)F6dF2K5}poj-NK6Gf;hw$t3=1txY zoxQxZWrQU6K!%|~!m?~Bnw-6Rr!F3BZ{u5!LqnZTDON}Coj9^@&le)V!NYrVwS~B% zEL+>Sr@}qGwGvu|HrOo|gSt__ezN^&%~{*)a=rf7y1HujUcr`zZB<4#l@T#eN)si} z)lZA<{=tKx8E%c9>A(##6}_p+~EZpKsl5a4pj`E*;_-6`ysiv zffA!7=MT1vCz}-m4~tjVey1b2KSR4OEtLd-(_DdUqYZ74LaDkhH?KFh?%WAOP2WbX zp@zT+Dx|5_f%JQiAGvVw!oh+g3e50u!aPfMxdC=E)XB{F5IcEZhePIM- zph6Y`$Oy?JBL<8Ex(SqEhLeQ@XcrdA>a?rx+_~HLA;l14)WmmpH}_w?Pg#HBZs0eS zwypwAW?M-x+3AU-(GGWSJ=ngxUEcEZ5OsX(Qlt!MQ zn^(`S{GHkAv(8@D`EAfSYig%Cxv?z!{=w^F#y)5_d7FuKZH7qlR-#5B0bt806%D0I zT7VdVP_?q*%Rq8UR;JkD4i^RXowt+E%#V2U>TfDqzZSDZ+dR!a#T3I>-z_$q9@k|m zy5~A*m~&JWP@E7a=pc}4kVHTc4h&R;Li7d@f`|hKMLkbb^uhOakNr3&FLjlm~i5NBM< zFaYI{;cpiHCNRdE0dg*>qIm(_t?#$h=(SCw?h3rJV2*ER8{O4^3#=dO)KwklZkoqU zS8i5c%YL*y*4;FY#D=XmkQnYj%LH)?02~gSJH`Qp1XY64g>%c_K$xseI&|e)7vRoL zAqRba$G@%fSGA7X7hQk%_3NVOYVS+$leU_!&6*5uN)8#5ZBz_6ASCA;azYS-Rt@ki zg2NWz(=;t}SC(~Ibl63$5C8FPmhXqb^)5#jaJ~I{Ex3xZ!+2h8$}}h_g@Be>HZ;72 z6#y#>AY3^skuVKF#0WxFBQ()5d5_nWb?c6c>EeMM|Mh+*&wEpPyxHCq{R-Gdr-`hN zF=1sxl&mBoK+#qRLl9#CEN|Fg8>nbmsTg3a1;#M9enQ$RgWk}kp#-5wh=EF&1tl%mJln2V^8o%Qv(*=zEuO7y z=m*8?xpUn-*@h5Cl_3BK3joiGkyaScK+>|MWdMRWm@RT!Q1piAlv5hL@B6>3&GI8) zP!xBc6}ZNIpJLL%2a8Y!+(<=f%WX>_uWVxlga9!D*oYt$l0cxRDMvqfU;Kq_mLK5k z)dvqYcgLa_Lz?3HyeF)@$%$&6lI?r4I>6W#M*<)vq{?&Oqrx``d`mhpVPr> z#q078F6gw_X<=?KR>8%^t%@wbITvNMu!hKiTSkCTJkw>1!e*Y{%31#_yMf=LW7{RJ zYoC^w$6%3cBtVG5)x#{Hg6IVTh9XEcM{gQwXk!R^y95^f-hZ`d{aVa+xW1EO4wDV4 zB?JgD7*?qkvc|$nIykTvNl2x0j3Q!MXoLL^)~}d7jcYf(H8D~c+?$pKL(px>Z3`eb z04RzS6_AgFT6Pn#iZAg$Sl_j8#;6ShF%&(Fag#E2asU@@LaN;=b=Wf7sgPKhfzhBM zC@eFL8^MrnA*9&Khe*Ab@CC9*uyJGXyi(;y2>lQLJZt;ShtJi?3Yf_t`F+$hY!+Q2Ndsx=U+bjTiAy7djLji>7k%k`$9&--f<*BNA3Hy&ZrHH|4 zG5H&9cB?O#zI1_OOf0Ce%mDfQxdtp3vU%(iY6yji3iISS61XLv#z|!zI_sZqza@B+ zyu9st5-h+`H7QUKx9}3w@oU@EO}&cEzG?fu!!bLO->%zkcg;i9^j`S~=WKMnDi1f= P00000NkvXXu0mjft=yBf literal 0 HcmV?d00001 diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..35a416b --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,46 @@ +@import 'tailwindcss'; + +/* Класс-стратегия тёмной темы: .dark на (см. theme.tsx). */ +@custom-variant dark (&:where(.dark, .dark *)); + +:root { + --background: #ffffff; + --foreground: #0a0a0f; + --muted: #f4f4f5; + --muted-foreground: #6b7280; + --border: #e5e7eb; + --primary: #7c3aed; + --primary-foreground: #ffffff; +} + +.dark { + --background: #0b0c10; + --foreground: #e5e7eb; + --muted: #17181f; + --muted-foreground: #9ca3af; + --border: #2a2c37; + --primary: #a78bfa; + --primary-foreground: #0b0c10; +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-border: var(--border); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --font-sans: system-ui, 'Segoe UI', Roboto, sans-serif; +} + +@layer base { + html { + color-scheme: light dark; + } + body { + @apply bg-background text-foreground font-sans antialiased; + margin: 0; + min-height: 100svh; + } +} diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts new file mode 100644 index 0000000..c7a20d9 --- /dev/null +++ b/frontend/src/lib/i18n.ts @@ -0,0 +1,46 @@ +import i18n from 'i18next' +import { initReactI18next } from 'react-i18next' + +const resources = { + ru: { + translation: { + appName: 'PnvPanel', + tagline: 'Self-service портал для VPN-конфигураций', + scaffoldNote: 'Каркас приложения (M0). Далее — аутентификация, ноды, конфиги (см. roadmap).', + theme: 'Тема', + language: 'Язык', + light: 'Светлая', + dark: 'Тёмная', + system: 'Системная', + }, + }, + en: { + translation: { + appName: 'PnvPanel', + tagline: 'Self-service portal for VPN configurations', + scaffoldNote: 'Application scaffold (M0). Next: authentication, nodes, configs (see roadmap).', + theme: 'Theme', + language: 'Language', + light: 'Light', + dark: 'Dark', + system: 'System', + }, + }, +} + +const STORAGE_KEY = 'pnv-lang' +const stored = typeof localStorage !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null + +void i18n.use(initReactI18next).init({ + resources, + lng: stored ?? 'ru', + fallbackLng: 'ru', + interpolation: { escapeValue: false }, +}) + +export function setLanguage(lng: string) { + localStorage.setItem(STORAGE_KEY, lng) + void i18n.changeLanguage(lng) +} + +export default i18n diff --git a/frontend/src/lib/theme.tsx b/frontend/src/lib/theme.tsx new file mode 100644 index 0000000..dedcd87 --- /dev/null +++ b/frontend/src/lib/theme.tsx @@ -0,0 +1,51 @@ +import { createContext, useContext, useEffect, useState, type ReactNode } from 'react' + +export type Theme = 'light' | 'dark' | 'system' + +type ThemeContextValue = { + theme: Theme + setTheme: (theme: Theme) => void +} + +const STORAGE_KEY = 'pnv-theme' +const ThemeContext = createContext(undefined) + +function resolve(theme: Theme): 'light' | 'dark' { + if (theme === 'system') { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' + } + return theme +} + +function applyTheme(theme: Theme) { + const root = document.documentElement + root.classList.toggle('dark', resolve(theme) === 'dark') +} + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [theme, setThemeState] = useState( + () => (localStorage.getItem(STORAGE_KEY) as Theme | null) ?? 'system', + ) + + useEffect(() => { + applyTheme(theme) + if (theme !== 'system') return + const media = window.matchMedia('(prefers-color-scheme: dark)') + const onChange = () => applyTheme('system') + media.addEventListener('change', onChange) + return () => media.removeEventListener('change', onChange) + }, [theme]) + + const setTheme = (next: Theme) => { + localStorage.setItem(STORAGE_KEY, next) + setThemeState(next) + } + + return {children} +} + +export function useTheme(): ThemeContextValue { + const ctx = useContext(ThemeContext) + if (!ctx) throw new Error('useTheme must be used within ThemeProvider') + return ctx +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..1cb057b --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,19 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import './index.css' +import './lib/i18n' +import { ThemeProvider } from './lib/theme' +import App from './App.tsx' + +const queryClient = new QueryClient() + +createRoot(document.getElementById('root')!).render( + + + + + + + , +) diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..6830b6f --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "allowArbitraryExtensions": true, + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..8455dcb --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..0989161 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + +// Бэкенд для dev-прокси (Api слушает http://localhost:8080). См. docs/frontend.md. +const apiTarget = process.env.VITE_API_TARGET ?? 'http://localhost:8080' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react(), tailwindcss()], + server: { + proxy: { + '/api': { target: apiTarget, changeOrigin: true }, + '/hubs': { target: apiTarget, changeOrigin: true, ws: true }, + }, + }, + build: { + outDir: 'dist', + }, +}) diff --git a/seed/client-apps.json b/seed/client-apps.json new file mode 100644 index 0000000..b879327 --- /dev/null +++ b/seed/client-apps.json @@ -0,0 +1,182 @@ +[ + { + "name": "Happ", + "operatingSystem": "iOS", + "downloadUrl": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215", + "description": "App Store (Global)", + "sortOrder": 10, + "isEnabled": true + }, + { + "name": "Karing", + "operatingSystem": "iOS", + "downloadUrl": "https://apps.apple.com/ru/app/karing/id6472431552", + "description": "App Store (iOS & Mac)", + "sortOrder": 20, + "isEnabled": true + }, + { + "name": "AmneziaVPN", + "operatingSystem": "iOS", + "downloadUrl": "https://apps.apple.com/us/app/amneziavpn/id1600529900", + "description": "App Store", + "sortOrder": 30, + "isEnabled": true + }, + { + "name": "OneXray", + "operatingSystem": "iOS", + "downloadUrl": "https://apps.apple.com/us/app/onexray/id6745748773", + "description": "App Store", + "sortOrder": 40, + "isEnabled": true + }, + + { + "name": "v2rayNG", + "operatingSystem": "Android", + "downloadUrl": "https://github.com/2dust/v2rayNG/releases/download/2.0.18/v2rayNG_2.0.18_universal.apk", + "description": "APK (GitHub)", + "sortOrder": 10, + "isEnabled": true + }, + { + "name": "Happ", + "operatingSystem": "Android", + "downloadUrl": "https://play.google.com/store/apps/details?id=com.happproxy", + "description": "Google Play", + "sortOrder": 20, + "isEnabled": true + }, + { + "name": "V2Box", + "operatingSystem": "Android", + "downloadUrl": "https://play.google.com/store/apps/details?id=dev.hexasoftware.v2box", + "description": "Google Play", + "sortOrder": 30, + "isEnabled": true + }, + { + "name": "v2RayTun", + "operatingSystem": "Android", + "downloadUrl": "https://play.google.com/store/apps/details?id=com.v2raytun.android", + "description": "Google Play", + "sortOrder": 40, + "isEnabled": true + }, + { + "name": "AmneziaVPN", + "operatingSystem": "Android", + "downloadUrl": "https://play.google.com/store/apps/details?id=org.amnezia.vpn", + "description": "Google Play", + "sortOrder": 50, + "isEnabled": true + }, + + { + "name": "v2rayN", + "operatingSystem": "Windows", + "downloadUrl": "https://github.com/2dust/v2rayN/releases/download/7.13.2/v2rayN-windows-64-desktop.zip", + "description": "Windows 10/11 (ZIP)", + "sortOrder": 10, + "isEnabled": true + }, + { + "name": "Throne", + "operatingSystem": "Windows", + "downloadUrl": "https://github.com/throneproj/Throne/releases/download/1.1.1/Throne-1.1.1-windows64-installer.exe", + "description": "Windows 10/11 (Installer)", + "sortOrder": 20, + "isEnabled": true + }, + { + "name": "Happ", + "operatingSystem": "Windows", + "downloadUrl": "https://github.com/Happ-proxy/happ-desktop/releases/latest/download/setup-Happ.x64.exe", + "description": "Windows 10/11 (Installer)", + "sortOrder": 30, + "isEnabled": true + }, + { + "name": "NekoBox (NekoRay)", + "operatingSystem": "Windows", + "downloadUrl": "https://github.com/MatsuriDayo/nekoray/releases/download/4.0.1/nekoray-4.0.1-2024-12-12-windows64.zip", + "description": "Windows 10/11 (Portable)", + "sortOrder": 40, + "isEnabled": true + }, + { + "name": "Karing", + "operatingSystem": "Windows", + "downloadUrl": "https://github.com/KaringX/karing/releases/download/v1.2.1.825/karing_1.2.1.825_windows_x64.exe", + "description": "Windows 10/11 (Installer)", + "sortOrder": 50, + "isEnabled": true + }, + + { + "name": "v2rayN", + "operatingSystem": "MacOS", + "downloadUrl": "https://github.com/2dust/v2rayN/releases/download/7.12.5/v2rayN-macos-arm64.dmg", + "description": "macOS ARM (DMG)", + "sortOrder": 10, + "isEnabled": true + }, + { + "name": "Karing", + "operatingSystem": "MacOS", + "downloadUrl": "https://apps.apple.com/ru/app/karing/id6472431552", + "description": "App Store (iOS & Mac)", + "sortOrder": 20, + "isEnabled": true + }, + { + "name": "AmneziaVPN", + "operatingSystem": "MacOS", + "downloadUrl": "https://github.com/amnezia-vpn/amnezia-client/releases/download/4.8.7.2/AmneziaVPN_4.8.7.2_macos.dmg", + "description": "macOS (DMG)", + "sortOrder": 30, + "isEnabled": true + }, + { + "name": "Furious", + "operatingSystem": "MacOS", + "downloadUrl": "https://github.com/LorenEteval/Furious/releases/download/0.5.0/Furious-0.5.0-macOS-12.0-arm64.dmg", + "description": "macOS ARM (DMG)", + "sortOrder": 40, + "isEnabled": true + }, + + { + "name": "NekoBox (NekoRay)", + "operatingSystem": "Linux", + "downloadUrl": "https://github.com/MatsuriDayo/nekoray/releases/download/4.0.1/nekoray-4.0.1-2024-12-12-debian-x64.deb", + "description": "Debian/Ubuntu (DEB)", + "sortOrder": 10, + "isEnabled": true + }, + { + "name": "Throne", + "operatingSystem": "Linux", + "downloadUrl": "https://github.com/throneproj/Throne/releases/download/1.1.1/Throne-1.1.1-debian-x64.deb", + "description": "Debian/Ubuntu (DEB)", + "sortOrder": 20, + "isEnabled": true + }, + { + "name": "OneXray", + "operatingSystem": "Linux", + "downloadUrl": "https://github.com/OneXray/OneXray/releases/latest/download/OneXray-linux-x86_64.deb", + "description": "Debian/Ubuntu (DEB)", + "sortOrder": 30, + "isEnabled": true + }, + { + "name": "v2rayA", + "operatingSystem": "Linux", + "downloadUrl": "https://v2raya.org/en/docs/prologue/installation/debian/", + "description": "Установка (docs)", + "sortOrder": 40, + "isEnabled": true + } +]