Compare commits

...
91 Commits
Author SHA1 Message Date
Leonid Pershin 4b34c37ce3 Enhance user management and node health check features
CI / Backend (build + test) (push) Failing after 2m14s
CI / Frontend (lint + typecheck + build) (push) Successful in 51s
- Updated `ListUsersQueryHandler` to include plan names and config quotas in `UserSummaryDto`, enriching user data retrieval.
- Implemented `WithPlanNamesAsync` method to fetch plan names based on user plan IDs, improving user experience in the admin interface.
- Enhanced `Node` class with a `ConsecutiveProbeFailures` property for better status management during health checks.
- Modified `NodeHealthCheckService` to utilize the new `RecordProbe` method, implementing a hysteresis mechanism for node status changes.
- Updated frontend components to display user config quotas and plan names, improving clarity in user management.
- Enhanced tests for user listing and node status handling to ensure robust functionality and coverage.
- Updated documentation to reflect changes in user and node management features.
2026-08-05 08:34:17 +03:00
Leonid Pershin c2ed3240bd Add media image handling and related endpoints
CI / Backend (build + test) (push) Failing after 1m35s
CI / Frontend (lint + typecheck + build) (push) Successful in 43s
- Introduced `MediaImage` entity to manage images for markdown in instructions and news.
- Updated `IAppDbContext` and `AppDbContext` to include `MediaImages` DbSet.
- Implemented `DeleteMediaImageFilesAsync` method in `FactoryResetCommandHandler` to remove media images during factory reset.
- Added new API endpoints for uploading and retrieving media images, enhancing markdown support.
- Updated frontend components to utilize the new `MarkdownEditor` for image uploads in instructions and news.
- Enhanced documentation to reflect the new media handling features and API specifications.
2026-07-30 04:05:01 +03:00
Leonid Pershin cc7e2a7f8f Enhance activation request validation and documentation
CI / Backend (build + test) (push) Failing after 1m37s
CI / Frontend (lint + typecheck + build) (push) Successful in 44s
- Updated the `RequestActivationCommandValidator` to require a non-empty comment, ensuring that users provide necessary identification information.
- Modified integration tests to validate the new requirement for a comment, including a test for handling empty comments.
- Updated API documentation to reflect that the comment is now mandatory and clarified its purpose.
- Enhanced frontend components to enforce comment requirements and provide user guidance on the comment's importance.
2026-07-30 03:23:03 +03:00
Leonid Pershin a8358b930d Refactor QR code implementation in ConfigCard, SubscriptionCard, TelegramLinkCard, and TelegramLoginButton components
CI / Backend (build + test) (push) Failing after 2m36s
CI / Frontend (lint + typecheck + build) (push) Successful in 56s
- Replaced `QRCodeSVG` with a new `QrCode` component from the shared UI library across multiple files, enhancing consistency in QR code rendering.
- Adjusted sizes of QR codes in `ConfigCard` and `SubscriptionCard` for improved visual presentation.
2026-07-28 19:08:27 +03:00
Leonid Pershin 9925968e22 Update LiteCqrs integration and documentation
CI / Backend (build + test) (push) Failing after 1m23s
CI / Frontend (lint + typecheck + build) (push) Successful in 36s
- Replaced references to local project connections with the NuGet package for LiteCqrs in multiple documentation files, ensuring clarity on dependency management.
- Updated architecture and backend conventions documentation to reflect the current state of the LiteCqrs library as a NuGet package, enhancing consistency across the project.
- Improved descriptions of CQRS implementation and command/query handling in the tech stack documentation, providing clearer guidance for developers.
2026-07-24 04:18:03 +03:00
Leonid Pershin b05b76f32f Refactor messaging system to utilize LiteCqrs library
CI / Backend (build + test) (push) Failing after 1m28s
CI / Frontend (lint + typecheck + build) (push) Successful in 47s
- Replaced instances of the previous messaging system with LiteCqrs across various application components, enhancing the CQRS implementation.
- Updated dependency injection to register LiteCqrs services and behaviors, streamlining command and query handling.
- Adjusted multiple command and query handlers to align with the new messaging framework, ensuring consistent functionality and improved maintainability.
- Added LiteCqrs package reference in the project file for better dependency management.
2026-07-24 04:16:38 +03:00
Leonid Pershin 4e1b63645f Enhance billing status handling in PlanContent component
CI / Backend (build + test) (push) Failing after 1m23s
CI / Frontend (lint + typecheck + build) (push) Successful in 44s
- Integrated billing status query to determine if payment is required for users with active plans.
- Added conditional rendering for a payment notice card, guiding users to the billing page if payment is needed.
- Updated i18n resource files with new translations for payment-related messages, improving user clarity on billing status.
2026-07-24 02:43:49 +03:00
Leonid Pershin 694683455b Add page hints to admin and user plan interfaces
CI / Backend (build + test) (push) Failing after 1m23s
CI / Frontend (lint + typecheck + build) (push) Successful in 47s
- Introduced page hints in the PlanContent, AdminPlansPage, and AdminRolesPage components to provide users with contextual information.
- Updated i18n resource files to include new translations for page hints related to plans and roles, enhancing user experience and clarity.
- Improved existing text for downgrade notices and discount tier hints for better understanding of billing implications.
2026-07-23 23:03:45 +03:00
Leonid Pershin fad03c2834 Enhance user plan management and update related endpoints
CI / Backend (build + test) (push) Failing after 1m23s
CI / Frontend (lint + typecheck + build) (push) Successful in 34s
- Added new configuration options for user plans in `.env.example`, including `Plans__MaxCustomConfigCount` and `Plans__MinCustomConfigCount`.
- Introduced `MapPlanEndpoints` in `Program.cs` to handle plan-related API routes.
- Implemented `SetUserPlan` endpoint in `RoleEndpoints` to allow admins to assign plans to users.
- Removed deprecated role request approval endpoints from `AdminSupportEndpoints`.
- Updated `ITelegramNotifier` and related classes to reflect changes in role request handling and payment notifications.
- Refactored role management commands to remove `MaxConfigs` and focus on `MaxIpLimit` and billing settings.
- Enhanced billing request handling to accommodate plan changes instead of role changes.
- Updated various interfaces and command handlers to support new plan management features.
2026-07-23 22:52:20 +03:00
Leonid Pershin 2c5b730500 Update ThreeXui.Net package version to 1.0.4 in Directory.Packages.props
CI / Backend (build + test) (push) Failing after 58s
CI / Frontend (lint + typecheck + build) (push) Successful in 33s
2026-07-23 20:32:34 +03:00
Leonid Pershin fb97093bd6 Implement NotifyOnStatusChange feature for nodes and enhance Telegram notifications
CI / Backend (build + test) (push) Successful in 1m24s
CI / Frontend (lint + typecheck + build) (push) Successful in 34s
- Added `NotifyOnStatusChange` property to the `Node` class and related DTOs to allow individual node configuration for status change notifications.
- Updated `NodeHealthCheckService` to send Telegram notifications to admins when a node's status changes, based on the new property.
- Enhanced the `ITelegramNotifier` interface with a method for notifying admins about node status changes.
- Modified the frontend to include a checkbox for `NotifyOnStatusChange` in the node editing dialog, allowing admins to easily configure this setting.
- Updated API documentation to reflect the new `notifyOnStatusChange` parameter in the node update endpoint.
- Added tests to ensure the correct behavior of the new feature and its integration with existing functionality.
2026-07-23 11:04:38 +03:00
Leonid Pershin 2f6bb26e97 Normalize base address in Node class to ensure consistent URI formatting
CI / Backend (build + test) (push) Successful in 1m25s
CI / Frontend (lint + typecheck + build) (push) Successful in 33s
- Implemented `NormalizeBaseAddress` method to add a trailing slash to the `BaseAddress` when necessary, preventing issues with relative path merging in 3x-ui deployments.
- Updated `Register` and `UpdateAddress` methods to utilize the normalization logic, ensuring that both methods handle base address input consistently.
- Added unit tests to verify the normalization behavior for various input scenarios, enhancing the reliability of node address management.
- Updated domain model documentation to reflect the new normalization behavior for `BaseAddress`.
2026-07-23 10:55:17 +03:00
Leonid Pershin c4bc6ff04b Update ThreeXui.Net package version and enhance IXuiPanelGateway implementation
CI / Backend (build + test) (push) Successful in 1m21s
CI / Frontend (lint + typecheck + build) (push) Successful in 33s
- Updated the version of `ThreeXui.Net` to 1.0.3 in `Directory.Packages.props` for improved functionality.
- Modified the `IXuiPanelGateway` interface documentation to reflect changes in client traffic retrieval, specifying the use of `GetInboundClientTrafficAsync` method for better data synchronization.
- Refactored `XuiPanelGateway` to utilize the new method, ensuring accurate client traffic data retrieval and addressing issues with previous versions not loading client statistics correctly.
2026-07-23 01:30:54 +03:00
Leonid Pershin 99451a425a Update API documentation for node management to include base address requirement in PUT requests
CI / Backend (build + test) (push) Successful in 1m27s
CI / Frontend (lint + typecheck + build) (push) Successful in 33s
- Modified the API design documentation to clarify that the `baseAddress` field is now mandatory in the `PUT /api/admin/nodes/{id}` endpoint.
- Updated descriptions to reflect the validation process for the base address and its impact on client gateway caching.
- Ensured consistency in documentation regarding optional fields and their behavior during updates.
2026-07-23 01:24:39 +03:00
Leonid Pershin ee6b8a35b8 Enhance node update functionality to include base address
CI / Backend (build + test) (push) Successful in 1m25s
CI / Frontend (lint + typecheck + build) (push) Successful in 33s
- Added `BaseAddress` property to `UpdateNodeCommand` and `UpdateNodeBody` for improved node management.
- Implemented validation for the base address in `UpdateNodeCommandHandler`, ensuring it is a valid absolute URI.
- Updated `Node` class to support address updates, including logic to invalidate cached clients on address changes.
- Enhanced frontend components to handle base address input in the node editing dialog and API requests.
- Updated validation rules to enforce base address requirements in `UpdateNodeCommandValidator`.
2026-07-23 01:05:02 +03:00
Leonid Pershin 6b449dbfb5 Update package versions and improve null safety in tests
CI / Backend (build + test) (push) Successful in 1m24s
CI / Frontend (lint + typecheck + build) (push) Successful in 32s
- Updated various package versions in `Directory.Packages.props` to the latest compatible releases, enhancing overall stability and security.
- Modified test assertions in `GrantBillingGiftCommandHandlerTests`, `RejectPaymentRequestCommandHandlerTests`, `BlockUserCommandHandlerTests`, and `DeleteUserCommandHandlerTests` to use null-safe checks, ensuring robustness against potential null reference exceptions.
- Updated PostgreSqlContainer initialization in `PnvPanelWebApplicationFactory` for improved clarity and maintainability.
2026-07-23 00:48:57 +03:00
Leonid Pershin fb320fbb31 Update package versions and enhance IXuiPanelGateway interface
CI / Backend (build + test) (push) Failing after 55s
CI / Frontend (lint + typecheck + build) (push) Successful in 34s
- Updated the version of `ThreeXui.Net` to 1.0.2 in `Directory.Packages.props`.
- Enhanced the `IXuiPanelGateway` interface to include detailed documentation on the new `ForcedFingerprint` and `ForcedPacketEncoding` parameters for the `BuildConnectionStringAsync` method, clarifying their roles in client application interactions.
- Refactored `XuiPanelGateway` to implement the new parameters, ensuring compatibility with client requirements for TLS fingerprinting and packet encoding based on transport type.
- Updated architecture documentation to reflect changes in connection string handling and the implications for client applications.
2026-07-23 00:24:02 +03:00
Leonid Pershin 29291b5dec Refactor billing configuration management to centralize expiration handling
CI / Backend (build + test) (push) Failing after 1m8s
CI / Frontend (lint + typecheck + build) (push) Successful in 34s
- Updated `BillingConfigResumer` to manage billing expiration entirely on the application side, using `enable` as the sole control mechanism for client access.
- Changed all relevant methods to pass `DateTimeOffset.UnixEpoch` for `expiresAt`, ensuring the panel does not enforce expiration independently of our application logic.
- Modified `IXuiPanelGateway` interface to reflect the new expiration handling approach, clarifying the role of `expiresAt` in client management.
- Adjusted command handlers for creating and rotating VPN configurations to set `expiresAt` to `null`, preventing unintended expiration enforcement by the panel.
- Enhanced documentation to explain the new billing expiration management strategy and its implications for client configurations.
2026-07-22 23:43:45 +03:00
Leonid Pershin 33ad98cf62 Enhance admin endpoints and queries for improved filtering and management
CI / Backend (build + test) (push) Successful in 1m22s
CI / Frontend (lint + typecheck + build) (push) Successful in 34s
- Updated `ListPaymentRequestsQuery` to include `Kind` and `Search` parameters for better filtering of payment requests.
- Enhanced `ListAuditLogsQuery` to support additional filters: `Source`, `TargetType`, and `Action`, improving audit log retrieval.
- Modified `ListUsersQuery` to accept new filters: `RoleId`, `IsActivated`, `IsBlocked`, and `BillingExpired`, allowing for more granular user management.
- Introduced `DeleteInbound` endpoint to allow deletion of inbounds that are not currently available, enhancing inbound management capabilities.
- Updated frontend API calls to reflect new query parameters and support for additional filtering options in the admin interface.
- Revised API documentation to include new parameters and endpoint functionalities for better clarity and usage guidance.
2026-07-20 10:35:48 +03:00
Leonid Pershin e19860ba46 Implement billing status notification and enhance user management integration
CI / Backend (build + test) (push) Successful in 1m30s
CI / Frontend (lint + typecheck + build) (push) Successful in 33s
- Added `NotifyBillingStatusChangedAsync` method to `IRealtimeNotifier` for notifying clients about changes in billing status.
- Updated `BillingConfigResumer` to call the new notification method after modifying billing configurations, ensuring users receive real-time updates.
- Enhanced `ListUsersQueryHandler` to include a `BillingPendingReview` property in `UserSummaryDto`, indicating if a user has a pending payment request awaiting confirmation.
- Refactored various command handlers to utilize `AdvisoryLock` for managing concurrent requests, preventing race conditions in billing operations.
- Updated tests to cover new notification behaviors and ensure proper functionality in billing status management.
2026-07-19 23:22:57 +03:00
Leonid Pershin b32756d5bc Enhance billing request handling to support immediate config suspension and protection
CI / Backend (build + test) (push) Successful in 1m21s
CI / Frontend (lint + typecheck + build) (push) Successful in 33s
- Updated the `RejectPaymentRequestCommandHandler` to immediately suspend user configs if a payment request is rejected and no other pending subscription requests exist.
- Introduced `SuspendIfStillUnpaidAsync` method to handle the logic for suspending configs based on the user's billing status.
- Enhanced the `MarkPaymentSentCommandHandler` to protect configs during the payment confirmation process, ensuring users remain active while awaiting admin approval.
- Refactored `BillingConfigResumer` to include methods for protecting and suspending configs, improving the overall billing management flow.
- Updated tests to cover new behaviors and ensure proper functionality in various scenarios related to payment requests and config management.
2026-07-19 19:25:33 +03:00
Leonid Pershin 979eddf72e Refactor client update handling to support nullable parameters for name and expiration
CI / Backend (build + test) (push) Successful in 1m18s
CI / Frontend (lint + typecheck + build) (push) Successful in 59s
- Updated the `UpdateClientAsync` method in `IXuiPanelGateway` to accept nullable parameters for `name` and `expiresAt`, allowing for more flexible client management without unintended modifications.
- Adjusted the `BlockUserCommandHandler`, `UnblockUserCommandHandler`, and other related command handlers to utilize the new nullable parameters, ensuring that client names remain unchanged during block/unblock operations and that expiration dates are managed correctly.
- Enhanced the billing and configuration handling to reflect the new logic for managing client states based on expiration rather than enabling/disabling, improving reliability in client status management.
- Updated tests to cover the new behavior and ensure proper functionality across the application.
2026-07-19 18:58:36 +03:00
Leonid Pershin 5ff5224935 Refactor payment request handling to support role change top-ups
CI / Backend (build + test) (push) Successful in 1m19s
CI / Frontend (lint + typecheck + build) (push) Successful in 33s
- Updated the `PaymentRequest` model to include a new `Kind` property, distinguishing between `Subscription` and `RoleChangeTopUp` requests.
- Modified the `TelegramNotifier` to accommodate the new request type, ensuring accurate notifications for role change top-ups.
- Enhanced the `ConfirmPaymentRequestCommandHandler` to handle role change top-ups without extending the billing period, reflecting the new payment logic.
- Updated various application components and tests to support the new payment request structure and ensure proper functionality.
- Revised API documentation to clarify the behavior of role change top-ups and their impact on billing.
2026-07-19 16:45:17 +03:00
Leonid Pershin 0dcaf1203f Implement discount tiers for pricing settings and enhance related functionalities
CI / Backend (build + test) (push) Successful in 1m20s
CI / Frontend (lint + typecheck + build) (push) Successful in 32s
- Introduced a new `DiscountTierDto` to represent volume discount tiers, allowing roles with a config quota at or above specified thresholds to receive discounts on pricing.
- Updated the `PricingSettingsDto` to include a list of discount tiers, enhancing the pricing model to support more flexible pricing strategies.
- Modified the `GetPricingSettingsQueryHandler` and `UpdatePricingSettingsCommandHandler` to handle discount tiers, ensuring they are correctly retrieved and updated in the database.
- Enhanced validation in `UpdatePricingSettingsCommandValidator` to enforce uniqueness and progressive discount tiers, preventing invalid configurations.
- Updated frontend components to support the new discount tier functionality, including forms for adding and managing discount tiers in the admin interface.
- Revised API documentation to reflect the new discount tier features and their usage in pricing settings.
2026-07-19 15:51:01 +03:00
Leonid Pershin 6a2d2d2318 Enhance PaidUntilBadge and UserManageDialog to display billing date
CI / Backend (build + test) (push) Successful in 1m18s
CI / Frontend (lint + typecheck + build) (push) Successful in 31s
- Updated the PaidUntilBadge component to accept a new `showDate` prop, allowing it to display the billing date alongside the remaining days.
- Modified UserManageDialog and admin user management components to utilize the updated PaidUntilBadge, improving context for billing information.
- Added internationalization support for the new date format in both English and Russian.
2026-07-19 05:57:13 +03:00
Leonid Pershin 1c13695556 Enhance PaidUntilBadge component to support pending payment review status
CI / Backend (build + test) (push) Successful in 1m20s
CI / Frontend (lint + typecheck + build) (push) Successful in 33s
- Updated the PaidUntilBadge component to accept a new `pendingReview` prop, indicating if a payment is awaiting admin confirmation.
- Modified the rendering logic to display a warning badge when a payment is under review, providing users with a neutral status instead of a negative one.
- Updated relevant components in billing and dashboard routes to pass the new `pendingReview` prop based on the active payment request status.
- Added internationalization support for new labels related to the pending review status in both English and Russian.
2026-07-19 05:35:28 +03:00
Leonid Pershin 24cee9bb78 Implement extension request and gift functionalities in billing system
CI / Backend (build + test) (push) Successful in 1m27s
CI / Frontend (lint + typecheck + build) (push) Successful in 33s
- Added new endpoints for creating and managing extension requests, allowing users to request billing period extensions.
- Implemented admin approval processes for extension requests via Telegram, including inline buttons for approval and rejection.
- Introduced a gifting feature for admins to grant additional billing days directly to users without a request.
- Updated the support ticket model to accommodate extension requests and their associated properties.
- Enhanced the Telegram notifier to inform admins of new extension requests and notify users of approval or rejection.
- Updated frontend components to support the new extension request and gifting functionalities, including user interfaces for managing these features.
- Revised API documentation to reflect the new endpoints and their usage in the billing context.
2026-07-19 05:30:11 +03:00
Leonid Pershin e088e302e9 Implement billing status feature in Telegram bot
CI / Backend (build + test) (push) Successful in 1m20s
CI / Frontend (lint + typecheck + build) (push) Successful in 32s
- Added a new command `/billing` and a corresponding menu option for users with billing roles to check their payment status.
- Implemented `HandleBillingStatusAsync` method to retrieve and display billing information, including the payment expiration date and remaining time in a user-friendly format.
- Updated the main menu to conditionally show the billing status option based on the user's role.
- Enhanced the `PaidUntilBadge` component to format and display the remaining time until the next payment in both days and hours/minutes.
- Updated documentation to reflect the new billing status feature and its usage in the Telegram bot.
2026-07-19 05:04:54 +03:00
Leonid Pershin 450ad1ea1f Enhance user management and billing integration
CI / Backend (build + test) (push) Successful in 1m18s
CI / Frontend (lint + typecheck + build) (push) Successful in 45s
- Updated the `UserSummaryDto` to include `BillingEnabled` and `BillingPaidUntil` properties, allowing for better tracking of user billing status.
- Refactored the `IdentityService` to populate the new billing fields when retrieving user summaries.
- Modified the dashboard and admin user management components to display billing information, including a link to the billing page and a badge for billing status.
- Added internationalization support for new billing-related labels in both English and Russian.
- Ensured frontend components reflect the updated user data structure, enhancing user experience with billing visibility.
2026-07-19 02:31:52 +03:00
Leonid Pershin b2ae358250 Implement billing functionality and enhance role management
CI / Backend (build + test) (push) Successful in 1m22s
CI / Frontend (lint + typecheck + build) (push) Successful in 33s
- Introduced billing capabilities, allowing users to request payments for subscription periods (3/6/12 months) with admin approval via Telegram.
- Updated role management to include a `BillingEnabled` property, preventing billing for admin roles.
- Enhanced the `CreateRoleCommand` and `UpdateRoleCommand` to accept billing parameters, ensuring proper handling during role creation and updates.
- Added new endpoints for billing management and integrated billing checks into VPN config creation to enforce payment requirements.
- Updated related services, models, and tests to support the new billing features, ensuring comprehensive coverage and functionality.
- Enhanced documentation to reflect the new billing processes and role management changes.
2026-07-19 01:38:16 +03:00
Leonid Pershin b980dc6cef Add IsAvailable property to Inbound and update related logic
CI / Backend (build + test) (push) Successful in 1m19s
CI / Frontend (lint + typecheck + build) (push) Successful in 35s
- Introduced a new boolean property, `IsAvailable`, to the `Inbound` entity to track the availability status of inbounds based on synchronization results.
- Updated the `SyncNodeCommandHandler` to mark inbounds as unavailable if they are not present in the latest synchronization but have existing configurations, preventing their deletion.
- Enhanced the `MarkUnavailable` method to set both `IsAvailable` and `IsPublished` to false, reflecting the new status accurately.
- Modified the frontend components to display the availability status of inbounds, ensuring users are informed of their current state.
- Updated tests to cover the new behavior regarding inbound availability and its impact on revocation processes.
2026-07-19 00:13:30 +03:00
Leonid Pershin c196e0c322 Implement forced TLS fingerprinting for specific VPN protocols
CI / Backend (build + test) (push) Successful in 1m21s
CI / Frontend (lint + typecheck + build) (push) Successful in 33s
- Added functionality to enforce a default TLS fingerprint ("firefox") for vless, trojan, and vmess protocols when not explicitly set in the stream settings.
- Introduced methods to modify the connection string to include the fingerprint for TLS/reality links, ensuring compatibility with existing configurations.
- Updated documentation to reflect the new behavior regarding TLS fingerprint handling in the architecture overview.
2026-07-18 23:02:14 +03:00
Leonid Pershin 3304eed4b3 Refactor pricing endpoints and enhance support for pricing retrieval
CI / Backend (build + test) (push) Successful in 1m21s
CI / Frontend (lint + typecheck + build) (push) Successful in 32s
- Added a new endpoint `/api/support/pricing` to allow users to retrieve pricing information, making it accessible for role request dialogs.
- Introduced the `GetSupportPricing` method to handle pricing queries, ensuring that pricing data is available to non-admin users.
- Updated frontend components to integrate the new pricing retrieval functionality, displaying estimated costs based on user-selected configurations.
- Removed the `PricingSettingsDto` as it is no longer needed, streamlining the pricing data structure.
- Enhanced API documentation to reflect the new endpoint and its usage in the support context.
2026-07-18 21:19:40 +03:00
Leonid Pershin 32221af503 Update pricing model to include half-year pricing and enhance validation
CI / Backend (build + test) (push) Successful in 1m24s
CI / Frontend (lint + typecheck + build) (push) Failing after 15s
- Introduced a new pricing field, `PricePerConfigPerHalfYear`, to the `PricingSettings` model, allowing for more flexible pricing options.
- Updated the `UpdatePricingSettingsCommand` and its validator to include the new half-year pricing, ensuring proper validation against the quarterly and yearly rates.
- Modified the `PricingSettingsDto` and related frontend components to accommodate the new half-year pricing field, including validation logic to prevent pricing discrepancies.
- Enhanced API documentation and frontend forms to reflect the updated pricing structure and validation rules.
2026-07-18 20:17:32 +03:00
Leonid Pershin 6dfd51ae7c Enhance pricing validation and update related components
CI / Backend (build + test) (push) Successful in 1m21s
CI / Frontend (lint + typecheck + build) (push) Successful in 34s
- Added validation logic in `UpdatePricingSettingsCommandValidator` to ensure the annual price does not fall below the equivalent quarterly price, preventing potential pricing discrepancies.
- Updated `PricingSettings` model documentation to clarify that both pricing fields represent monthly rates, with calculations for total costs based on the number of months.
- Modified frontend components to reflect the new validation, including error messages when the annual price is cheaper than the quarterly price.
- Adjusted API documentation to accurately describe the pricing structure and validation rules for the pricing endpoints.
2026-07-18 19:56:21 +03:00
Leonid Pershin ae379f8e0f Implement pricing management functionality and update related components
CI / Backend (build + test) (push) Successful in 1m19s
CI / Frontend (lint + typecheck + build) (push) Successful in 32s
- Added new endpoints for managing global pricing settings, including retrieval and updates for `PricePerConfigPerQuarter` and `PricePerConfigPerYear`.
- Updated `RoleService` and related commands to remove pricing fields from role management, ensuring a clear separation between role configurations and global pricing.
- Enhanced the `FactoryResetCommandHandler` to include seeding of pricing settings during a factory reset.
- Modified frontend components to support new pricing settings, including forms for creating and updating pricing information.
- Updated API documentation to reflect changes in pricing management endpoints and their expected request/response formats.
- Adjusted tests to ensure proper coverage for new pricing functionalities and their integration with existing role management features.
2026-07-18 19:34:29 +03:00
Leonid Pershin 285d8180c8 Enhance role management by adding pricing fields and updating related logic
CI / Backend (build + test) (push) Successful in 1m22s
CI / Frontend (lint + typecheck + build) (push) Successful in 32s
- Updated `CreateRoleCommand` and `UpdateRoleCommand` to include optional pricing fields: `PricePerConfigPerQuarter` and `PricePerConfigPerYear`.
- Modified `RoleEndpoints` to handle the new pricing parameters during role creation and updates.
- Enhanced validation logic in `CreateRoleCommandValidator` and `UpdateRoleCommandValidator` to ensure pricing fields are non-negative when provided.
- Updated `RoleDto` and `SelectableRoleDto` to include pricing information, ensuring proper data handling in API responses.
- Adjusted frontend components to support new pricing fields in role forms and display total costs based on configurations.
- Updated API documentation to reflect changes in role management endpoints and pricing structure.
2026-07-18 19:02:12 +03:00
Leonid Pershin 68781ef183 Update Dockerfile to enhance runtime configuration and health check
CI / Backend (build + test) (push) Successful in 1m19s
CI / Frontend (lint + typecheck + build) (push) Successful in 33s
- Added `gosu` for safe privilege dropping in the entrypoint script.
- Updated the entrypoint to use `docker-entrypoint.sh` for improved container management.
- Ensured necessary directories are created with appropriate permissions during the build process.
- Enhanced health check command to verify service availability more reliably.
2026-07-15 16:02:36 +03:00
Leonid Pershin d30c958dc4 Refactor VPN config creation logic to improve node status handling
CI / Backend (build + test) (push) Successful in 1m19s
CI / Frontend (lint + typecheck + build) (push) Successful in 34s
- Removed the dependency on `Node.Status` for VPN config creation, addressing potential false negatives due to cached health-check data.
- Updated documentation to clarify that `Node.Status` is a diagnostic indicator and not a gate for config creation, ensuring accurate understanding of node availability checks.
- Enhanced comments in the code to explain the rationale behind the changes, improving maintainability and clarity for future developers.
2026-07-15 15:53:47 +03:00
Leonid Pershin f14daa3df1 Add role management validation to prevent removal of the last admin
CI / Backend (build + test) (push) Successful in 1m20s
CI / Frontend (lint + typecheck + build) (push) Successful in 34s
- Introduced a new error, `CannotRemoveLastAdmin`, to handle attempts to downgrade the last admin user in the system.
- Updated `RoleService` to check the number of admin users before allowing a role change that would remove the last admin.
- Enhanced unit tests to verify the new behavior, ensuring that attempts to downgrade the last admin correctly propagate the failure.
- Updated API documentation to reflect the new validation logic and its implications for role management.
2026-07-14 23:53:20 +03:00
Leonid Pershin 940577d8cd Update API documentation for role selection endpoint to clarify response structure
CI / Backend (build + test) (push) Successful in 1m48s
CI / Frontend (lint + typecheck + build) (push) Successful in 38s
- Modified the response description for the `/api/support/roles` GET endpoint to specify that the returned `RoleDto[]` excludes both the current user's role and admin roles, enhancing clarity for developers using the API.
2026-07-14 23:32:08 +03:00
Leonid Pershin b6bffcc302 Enhance role selection functionality in ListSelectableRolesQueryHandler and update frontend role display
CI / Backend (build + test) (push) Successful in 1m22s
CI / Frontend (lint + typecheck + build) (push) Successful in 44s
- Updated ListSelectableRolesQueryHandler to include IIdentityService and ICurrentUser for user authorization and profile retrieval.
- Added logic to filter out the current user's role and admin roles from the selectable roles list.
- Enhanced CreateRoleRequestDialog to display role options with additional information, including max configs and IP limits, using localization support.
- Updated i18n resources to include new role option formatting for both Russian and English.
2026-07-14 23:31:56 +03:00
Leonid Pershin 8f6807a456 Enhance inbound management by removing MaxClients property
CI / Backend (build + test) (push) Successful in 1m23s
CI / Frontend (lint + typecheck + build) (push) Successful in 34s
- Removed the MaxClients property from Inbound-related data models, including InboundDto and PublishInboundCommand.
- Updated related methods and handlers to reflect the removal of MaxClients, ensuring consistent behavior across the application.
- Adjusted API documentation and frontend components to remove references to MaxClients, streamlining the inbound publishing process.
- Enhanced logging in command handlers to handle node unavailability scenarios during user actions.
- Improved database schema and migrations to align with the updated data model.
2026-07-14 23:11:50 +03:00
Leonid Pershin bef3880593 Add instructions management functionality and update related components
CI / Backend (build + test) (push) Successful in 1m17s
CI / Frontend (lint + typecheck + build) (push) Successful in 32s
- Introduced new endpoints for managing instruction intros and tabs, allowing admins to create, update, and delete instructional content.
- Enhanced the FactoryResetCommandHandler to include the seeding of instruction data during a factory reset.
- Updated the database schema to include InstructionIntro and InstructionTab entities, with corresponding migrations.
- Improved frontend routing and components to support the new instructions section, including a dedicated page for displaying instructions and tabs.
- Enhanced API documentation to reflect the new instruction management features and their expected request/response formats.
- Added localization support for the new instructions functionality in both Russian and English.
2026-07-14 22:20:10 +03:00
Leonid Pershin 8c53fcded2 Enhance app management with 'IsRecommended' feature
CI / Backend (build + test) (push) Successful in 1m15s
CI / Frontend (lint + typecheck + build) (push) Successful in 31s
- Added 'IsRecommended' property to app-related data models, allowing apps to be marked as recommended.
- Updated API endpoints for creating and updating apps to include 'IsRecommended' in request bodies.
- Modified database schema to accommodate the new 'IsRecommended' field.
- Enhanced frontend components to display recommended apps with a star icon and updated forms to manage this property.
- Improved sorting logic in app listings to prioritize recommended apps.
- Updated documentation to reflect changes in API and data models.
2026-07-14 19:19:43 +03:00
Leonid Pershin 701c3a1d51 Add factory reset functionality and update identity service
CI / Backend (build + test) (push) Successful in 1m18s
CI / Frontend (lint + typecheck + build) (push) Successful in 32s
- Introduced a new DELETE endpoint `/api/admin/maintenance/factory-reset` for a complete reset of the admin panel, removing all users except the current admin and clearing various data.
- Implemented the `FactoryReset` method in `AdminMaintenanceEndpoints` to handle the reset logic.
- Added a new method `ListAllUserIdsExceptAsync` in `IIdentityService` to retrieve user IDs excluding a specified user, aiding in the factory reset process.
- Updated the frontend to include a confirmation dialog for the factory reset action, enhancing user experience and safety.
- Enhanced localization support for the new factory reset feature in both Russian and English, ensuring clarity for all users.
2026-07-14 18:58:51 +03:00
Leonid Pershin 94ba514b8e Enhance admin maintenance functionality with new endpoints and response types
CI / Backend (build + test) (push) Successful in 1m17s
CI / Frontend (lint + typecheck + build) (push) Successful in 33s
- Added new DELETE endpoints for managing audit logs and disabled apps in the admin maintenance section.
- Updated existing endpoint for closed tickets to use a unified response type, `MaintenanceCleanupResponseDto`.
- Enhanced API documentation to reflect the new operations and their expected request/response formats.
- Improved frontend integration with new functions for deleting old audit logs and disabled apps, including user confirmation prompts.
- Added localization support for new maintenance actions in both Russian and English.
2026-07-14 18:37:22 +03:00
Leonid Pershin 8dfeb05912 Add admin maintenance endpoints and file deletion functionality
CI / Backend (build + test) (push) Successful in 1m21s
CI / Frontend (lint + typecheck + build) (push) Successful in 37s
- Introduced a new `/api/admin/maintenance` route for administrative maintenance tasks, requiring admin authorization.
- Implemented the `DeleteAsync` method in `IFileStorage` to allow for the deletion of files associated with closed support tickets.
- Updated API documentation to include details about the new maintenance operations and their effects on closed tickets.
- Enhanced frontend routing to include the new maintenance section in the admin panel, improving navigation for administrators.
- Added localization support for maintenance-related actions in both Russian and English.
2026-07-14 12:04:10 +03:00
Leonid Pershin 9a6540a266 Implement message preview feature for support tickets
CI / Backend (build + test) (push) Successful in 1m15s
CI / Frontend (lint + typecheck + build) (push) Successful in 30s
- Added a new property `MessagePreview` to the `TicketSummaryDto` to display the truncated first message of a ticket.
- Updated the `TicketMapping` class to retrieve the first message for each ticket and truncate it for preview purposes.
- Modified the `SupportTicketList` and `AdminSupportPage` components to conditionally render the message preview in the ticket list, enhancing user experience by providing context at a glance.
2026-07-14 07:47:57 +03:00
Leonid Pershin d26723dce0 Enhance Telegram notification system with optional link support
CI / Backend (build + test) (push) Successful in 1m16s
CI / Frontend (lint + typecheck + build) (push) Successful in 30s
- Updated NotifyUserAsync method in TelegramNotifier to accept an optional linkPath parameter for dynamic URL generation.
- Modified various command handlers to utilize the new linkPath feature, providing users with relevant links in their notifications.
- Adjusted ITelegramNotifier interface documentation to reflect the changes in method signature and functionality.
- Enhanced unit tests to verify the correct invocation of the updated NotifyUserAsync method.
2026-07-14 07:36:18 +03:00
Leonid Pershin df137ca5a7 Refactor project files for improved readability and structure
CI / Backend (build + test) (push) Successful in 1m18s
CI / Frontend (lint + typecheck + build) (push) Successful in 31s
- Cleaned up whitespace in Directory.Build.props and Directory.Packages.props for consistency.
- Reformatted project file references in PnvPanel.Api.csproj for better clarity.
- Enhanced code readability in various endpoint files by adjusting line breaks and indentation.
- Standardized method signatures and improved formatting in ResultExtensions and multiple endpoint classes for better maintainability.
2026-07-14 07:24:13 +03:00
Leonid Pershin 9d5424bb9c Add notification for ticket reopening to Telegram admins
CI / Backend (build + test) (push) Successful in 1m13s
CI / Frontend (lint + typecheck + build) (push) Successful in 31s
- Implemented NotifyAdminsTicketReopenedAsync method in TelegramNotifier to notify admins when a ticket is reopened, including a link to the ticket on the public site.
- Updated ITelegramNotifier interface to include the new notification method.
- Modified ReopenTicketCommandHandler to invoke the new notification method after a ticket is reopened.
- Enhanced unit tests for ReopenTicketCommandHandler to verify the notification functionality.
2026-07-14 07:22:21 +03:00
Leonid Pershin a833d9aa5b Add Telegram notification for news publication to users
CI / Backend (build + test) (push) Successful in 3m0s
CI / Frontend (lint + typecheck + build) (push) Successful in 31s
- Implemented NotifyUsersNewsPublishedAsync method in TelegramNotifier to send notifications to activated Telegram users when a news post is published.
- Updated CreatePostCommandHandler to invoke the new Telegram notification method after creating a news post.
- Enhanced IIdentityService to retrieve activated linked Telegram user IDs for notifications.
- Updated ITelegramNotifier interface to include the new notification method documentation.
2026-07-14 07:19:19 +03:00
Leonid Pershin 6bd34441fb Update TelegramNotifier URL structure and enhance admin support routing
CI / Backend (build + test) (push) Successful in 1m14s
CI / Frontend (lint + typecheck + build) (push) Successful in 30s
- Modified TelegramNotifier to change the support ticket URL format, using a query parameter for ticket identification.
- Enhanced the admin support route to validate search parameters, allowing for ticket-specific navigation from Telegram notifications.
- Updated AdminSupportPage to utilize navigation for ticket selection, improving user experience when accessing ticket details.
2026-07-14 07:14:38 +03:00
Leonid Pershin eb806a263f Enhance Telegram bot integration and notification system
CI / Backend (build + test) (push) Successful in 1m17s
CI / Frontend (lint + typecheck + build) (push) Successful in 30s
- Updated PnvBotUpdateHandler to improve message editing for login and activation requests, providing clearer feedback and removing lingering buttons.
- Modified TelegramNotifier to include an optional button linking to the public site in user notifications.
- Enhanced CloseTicketCommandHandler and ResolveTicketCommandHandler to notify users via Telegram when their support tickets are closed or resolved, improving user engagement.
- Updated ITelegramNotifier interface documentation to reflect the new functionality of including a site link in user notifications.
2026-07-14 07:06:32 +03:00
Leonid Pershin b5630b2685 Implement support ticket system with role request and bug report functionalities
CI / Backend (build + test) (push) Successful in 1m18s
CI / Frontend (lint + typecheck + build) (push) Successful in 31s
- Introduced a new support ticket system allowing users to submit bug reports and role requests.
- Implemented endpoints for creating, updating, and managing support tickets, including file attachments.
- Enhanced Telegram bot integration to handle role requests directly within the bot, enabling admins to approve or reject requests without accessing the website.
- Updated database schema to include support ticket entities and their relationships.
- Improved API documentation to reflect new support ticket endpoints and their usage.
- Added necessary localization for support ticket features in both Russian and English.
2026-07-14 06:49:05 +03:00
Leonid Pershin 14b64a3140 Implement activation checks across various commands and queries
CI / Backend (build + test) (push) Successful in 1m26s
CI / Frontend (lint + typecheck + build) (push) Successful in 30s
- Introduced `IRequiresActivation` interface to enforce activation requirements for multiple commands and queries, ensuring that only activated users can create, edit, or access configurations, news, and applications.
- Updated the `RequireActivationBehavior` to handle activation checks uniformly, returning appropriate errors for unauthenticated or inactive users.
- Enhanced error handling by adding `NotActivated` error to provide clear feedback for users attempting to access restricted features.
- Updated documentation to reflect the new activation requirements and their implications on user access and functionality.
2026-07-13 18:51:03 +03:00
Leonid Pershin 7f9a441050 Implement activation requirement for protected routes
CI / Backend (build + test) (push) Successful in 1m18s
CI / Frontend (lint + typecheck + build) (push) Successful in 32s
- Added a new `useRequireActivated` hook to enforce user activation before accessing certain routes, redirecting unauthenticated users to the login page and inactive users to the dashboard.
- Updated the `InstructionsPage` and `NewsPage` components to utilize the new activation check, enhancing user flow and security.
- Conditional rendering of navigation links in the `RootLayout` based on user activation status, improving user experience by hiding inaccessible features.
2026-07-13 18:36:14 +03:00
Leonid Pershin 39a8b30b03 Add admin configs endpoint and related UI components
CI / Backend (build + test) (push) Successful in 1m17s
CI / Frontend (lint + typecheck + build) (push) Successful in 32s
- Introduced a new endpoint to list all admin configs, enhancing the admin interface for better management.
- Updated API documentation to include the new `/configs` endpoint with pagination and search capabilities.
- Added routing and UI elements for the configs section in the admin panel, improving navigation and accessibility.
- Enhanced localization for the configs feature in both Russian and English, ensuring a user-friendly experience.
2026-07-13 16:08:50 +03:00
Leonid Pershin 7fce5ef181 Enhance VpnConfigDto to include ClientEmail and update related components
CI / Backend (build + test) (push) Successful in 1m17s
CI / Frontend (lint + typecheck + build) (push) Successful in 32s
- Added ClientEmail property to VpnConfigDto for better client identification.
- Updated ConfigCard to display the ClientEmail in the UI for improved user information.
- Modified API schema and types to accommodate the new ClientEmail field.
- Enhanced localization for displaying the ClientEmail in both Russian and English.
2026-07-13 15:59:09 +03:00
Leonid Pershin 24d9ea1099 Implement role and user management enhancements
CI / Backend (build + test) (push) Successful in 1m14s
CI / Frontend (lint + typecheck + build) (push) Successful in 30s
- Added MaxIpLimit to roles, allowing for the configuration of simultaneous IP limits for users.
- Updated role creation and update commands to include MaxIpLimit, ensuring proper handling in the application logic.
- Enhanced user management by introducing a DELETE endpoint for user accounts, with appropriate checks to prevent self-deletion.
- Updated documentation to reflect changes in role and user management, clarifying the new IP limit functionality and user deletion process.
- Adjusted related tests to cover new functionality and ensure robust validation of role and user management features.
2026-07-13 07:18:13 +03:00
Leonid Pershin 48e8d06a41 Enhance Telegram bot message handling for login and activation callbacks
CI / Backend (build + test) (push) Successful in 1m20s
CI / Frontend (lint + typecheck + build) (push) Successful in 32s
- Updated the PnvBotUpdateHandler to edit the original message for login and activation requests instead of sending a new message, improving user experience by removing lingering buttons.
- Added null checks for callback messages to ensure robust handling of user interactions.
- Enhanced status messages to provide clearer feedback on user actions during the login and activation processes.
2026-07-13 04:38:06 +03:00
Leonid Pershin b6637a1c03 Add news feature with CRUD operations and real-time notifications
CI / Backend (build + test) (push) Successful in 1m18s
CI / Frontend (lint + typecheck + build) (push) Successful in 32s
- Implemented news management functionality, allowing admins to create, read, update, and delete news posts.
- Introduced a new SignalR event for broadcasting news updates to all connected clients.
- Updated API documentation to include new endpoints for news management.
- Enhanced frontend with a dedicated news page and admin interface for managing news posts.
- Added necessary localization for news-related terms in both Russian and English.
2026-07-03 15:28:33 +03:00
Leonid Pershin bea2b5fcf7 Refactor VPN configuration handling to remove device limit management
CI / Backend (build + test) (push) Successful in 1m24s
CI / Frontend (lint + typecheck + build) (push) Successful in 30s
- Updated the VPN configuration commands and handlers to eliminate the device limit parameter, simplifying the configuration process.
- Adjusted related API documentation to reflect the removal of device limit management, clarifying that this setting is now handled directly in the 3x-ui by node administrators.
- Enhanced the overall codebase by removing unnecessary device limit references across various components, ensuring a cleaner and more maintainable code structure.
2026-07-02 23:21:26 +03:00
Leonid Pershin 05d49a8cbd Update border colors in index.css for improved UI consistency
CI / Backend (build + test) (push) Successful in 1m18s
CI / Frontend (lint + typecheck + build) (push) Successful in 33s
- Changed the border color variables in index.css to enhance the visual design, ensuring a more cohesive look across the application.
2026-07-02 22:28:09 +03:00
Leonid Pershin a0cb1b03be Refactor password validation regex for consistency across application
CI / Backend (build + test) (push) Successful in 1m13s
CI / Frontend (lint + typecheck + build) (push) Successful in 31s
- Updated password validation regex in ChangePasswordCommandValidator, RegisterCommandValidator, RegisterForm, and ChangePasswordForm to use Unicode property escapes for uppercase letters, ensuring consistent validation rules across both backend and frontend components.
2026-07-02 22:22:31 +03:00
Leonid Pershin 10e50eb8c7 Update password validation rules and dependencies
CI / Backend (build + test) (push) Successful in 1m14s
CI / Frontend (lint + typecheck + build) (push) Successful in 35s
- Updated the version of the ThreeXui.Net package from 1.0.0 to 1.0.1.
- Enhanced password validation in ChangePasswordCommandValidator and RegisterCommandValidator to require at least one uppercase letter and one digit, with a custom error message in Russian.
- Updated the password validation in RegisterForm and ChangePasswordForm to match the new requirements, ensuring consistency across the application.
2026-07-02 22:08:49 +03:00
Leonid Pershin ad94c6ef22 Update documentation and clarify MVP status
CI / Backend (build + test) (push) Successful in 1m33s
CI / Frontend (lint + typecheck + build) (push) Successful in 29s
- Revised the CLAUDE.md and README.md files to reflect the current MVP status, emphasizing completed features and intentionally omitted elements such as traffic limits and billing.
- Enhanced clarity in the documentation regarding the architecture, tech stack, and user roles.
- Removed the outdated roadmap section and streamlined references to tech stack decisions.
- Updated API design documentation to clarify the absence of versioning in the MVP and the handling of configuration details.
2026-07-02 21:11:59 +03:00
Leonid Pershin 012d08e737 Refactor SubscriptionCard button for improved clarity
CI / Backend (build + test) (push) Successful in 1m13s
CI / Frontend (lint + typecheck + build) (push) Successful in 31s
- Removed unnecessary class from the copy button in the SubscriptionCard component, streamlining the code and enhancing readability.
2026-07-02 20:16:40 +03:00
Leonid Pershin 66d6584835 Update ConfigCard layout for improved responsiveness
CI / Backend (build + test) (push) Successful in 1m14s
CI / Frontend (lint + typecheck + build) (push) Successful in 29s
- Modified the layout of the ConfigCard component by changing the class from `flex gap-2` to `flex flex-wrap gap-2`, enhancing the responsiveness and visual organization of the button elements.
2026-07-02 20:13:59 +03:00
Leonid Pershin cfc5433ff3 Refactor ConfigCard and SubscriptionCard for improved layout consistency
CI / Backend (build + test) (push) Successful in 1m18s
CI / Frontend (lint + typecheck + build) (push) Successful in 36s
- Updated code elements in ConfigCard and SubscriptionCard to use `min-w-0` for better handling of long text and preventing overflow.
- Enhanced the layout of both components to ensure a more uniform appearance and improved user experience when displaying connection strings and subscription URLs.
2026-07-02 19:58:30 +03:00
Leonid Pershin 5b398f9c59 Enhance Telegram bot functionality and configuration options
CI / Backend (build + test) (push) Successful in 1m16s
CI / Frontend (lint + typecheck + build) (push) Successful in 35s
- Added new inline button features to the `/configs` command, allowing users to view their configurations in a single message with active links and a back button.
- Implemented a menu for unlinking Telegram accounts, providing a clearer user experience when managing account connections.
- Updated the `.env.example` file to include a new `Telegram__PublicSiteUrl` setting, enabling a button for accessing the panel's website directly from the bot.
- Enhanced documentation to reflect the new features and configuration options available in the Telegram bot.
2026-07-02 19:39:14 +03:00
Leonid Pershin cf3d8fcad8 Implement username change functionality and enhance Telegram bot registration flow
CI / Backend (build + test) (push) Successful in 1m22s
CI / Frontend (lint + typecheck + build) (push) Successful in 32s
- Added a new endpoint for changing usernames, allowing users to update their login credentials via the API.
- Integrated username change functionality into the settings page, providing a user-friendly interface for this action.
- Enhanced the Telegram bot to support user registration directly through the bot, including username generation and password delivery.
- Updated documentation to reflect the new username change endpoint and registration flow through the Telegram bot.
2026-07-02 18:57:36 +03:00
Leonid Pershin 1452e5c4af Implement Telegram bot configuration updates and user messaging enhancements
CI / Backend (build + test) (push) Successful in 1m23s
CI / Frontend (lint + typecheck + build) (push) Successful in 30s
- Added inline button functionality to the `/configs` command, allowing users to request connection strings for their configurations without displaying them in chat history.
- Introduced a constant message for unlinked Telegram accounts to improve user understanding of the linking process.
- Updated the handling of configuration messages to include inline buttons for better user interaction and experience.
2026-07-02 18:29:32 +03:00
Leonid Pershin 85becacea5 Enhance root layout and admin interface for better usability
CI / Backend (build + test) (push) Successful in 1m15s
CI / Frontend (lint + typecheck + build) (push) Successful in 41s
- Updated the root layout to include a mobile menu toggle, improving navigation on smaller screens.
- Refactored navigation links and language/theme selection for better organization and accessibility.
- Added overflow handling for admin navigation and tables to ensure proper display on smaller screens.
- Improved translations for menu toggle accessibility in both Russian and English.
2026-07-02 17:55:33 +03:00
Leonid Pershin ff523d3d5a Update RegisterNodeDialog to use a more descriptive input ID for node name
CI / Backend (build + test) (push) Successful in 1m20s
CI / Frontend (lint + typecheck + build) (push) Successful in 29s
- Changed the input ID from `nodeName` to `registerNodeName` in the RegisterNodeDialog component for better clarity and consistency in the admin interface.
2026-07-02 17:19:37 +03:00
Leonid Pershin 7dcc8889a0 Enhance dialogs and configuration handling in the admin interface
CI / Backend (build + test) (push) Successful in 1m14s
CI / Frontend (lint + typecheck + build) (push) Successful in 31s
- Updated `EditNodeDialog` and `RegisterNodeDialog` to prevent interaction outside the dialog, improving user experience.
- Added data attributes to password fields in both dialogs for better password management.
- Refactored `CreateConfigDialog` to accept inbounds as a prop, improving data handling and user feedback when no inbounds are available.
- Introduced a warning banner in the root layout to inform users about the necessity of linking their Telegram account for notifications and password recovery.
- Updated translations for improved clarity regarding available inbounds and Telegram linking requirements.
2026-07-02 17:06:53 +03:00
Leonid Pershin 0d0fc2c86b Enhance password input fields in EditNodeDialog and RegisterNodeDialog
CI / Backend (build + test) (push) Successful in 3m29s
CI / Frontend (lint + typecheck + build) (push) Successful in 31s
- Added `autoComplete="new-password"` attribute to password input fields in both dialogs to improve security and user experience during password entry.
2026-07-02 16:20:12 +03:00
Leonid Pershin ff13583a62 Update title in index.html to reflect application name as PnvPanel
CI / Backend (build + test) (push) Successful in 1m14s
CI / Frontend (lint + typecheck + build) (push) Successful in 46s
2026-07-02 15:57:46 +03:00
Leonid Pershin 0d05ff52e9 Refactor Telegram bot configuration and deep link handling
CI / Backend (build + test) (push) Successful in 1m15s
CI / Frontend (lint + typecheck + build) (push) Successful in 29s
- Removed the `BotUsername` property from `TelegramOptions` and updated the `.env.example` to reflect this change, as the bot's username is now dynamically retrieved via the Bot API.
- Introduced `ITelegramBotInfo` to cache the bot's username, improving the handling of deep links in `TelegramEndpoints`.
- Updated API documentation to clarify that the deep link is now dependent on the bot's token and its availability through the Bot API, enhancing clarity for developers.
2026-07-02 15:44:40 +03:00
Leonid Pershin f731249e78 Add logging for Telegram bot proxy configuration
CI / Backend (build + test) (push) Successful in 1m21s
CI / Frontend (lint + typecheck + build) (push) Successful in 33s
- Enhanced logging in `Program.cs` to include information about the Telegram bot's proxy settings, improving traceability of network configurations.
- This addition logs the proxy scheme, host, and port, aiding in debugging and monitoring of the bot's connectivity.
2026-07-02 15:29:31 +03:00
Leonid Pershin 5f005b6496 Enhance Telegram bot configuration and error handling
CI / Backend (build + test) (push) Successful in 1m13s
CI / Frontend (lint + typecheck + build) (push) Successful in 29s
- Updated `.env.example` to include a new `Telegram__ProxyUrl` setting for proxy configuration when accessing the Bot API, improving connectivity options.
- Modified `Program.cs` to support proxy settings for the Telegram bot client, allowing for better handling of network restrictions.
- Improved error handling in `TelegramBotHostedService` to ensure the bot can recover from network errors without crashing the application, implementing a retry mechanism with a delay.
- Added `ProxyUrl` property to `TelegramOptions` for better configuration management.
2026-07-02 15:20:03 +03:00
Leonid Pershin 48f620b964 Refactor logging messages for consistency and clarity
CI / Backend (build + test) (push) Successful in 1m14s
CI / Frontend (lint + typecheck + build) (push) Successful in 43s
- Updated various logging messages across the application to replace Russian text with English equivalents, ensuring consistency in error and information logs.
- Enhanced clarity in log messages related to Telegram bot operations, user management commands, and health check services, improving overall traceability and understanding of application behavior.
2026-07-02 15:04:10 +03:00
Leonid Pershin 3cdd3adf5e Enhance password policy and update hints for user registration
CI / Backend (build + test) (push) Successful in 1m23s
CI / Frontend (lint + typecheck + build) (push) Successful in 30s
- Updated password requirements in the dependency injection configuration to enforce the use of at least one digit and one uppercase letter.
- Modified password hint messages in both Russian and English to reflect the new password policy, ensuring clarity for users during registration.
2026-07-02 14:52:46 +03:00
Leonid Pershin c04e0d7261 Enhance configuration and logging for user management commands
CI / Backend (build + test) (push) Successful in 1m18s
CI / Frontend (lint + typecheck + build) (push) Successful in 31s
- Updated `.env.example` to include new settings for trusted proxies and networks for better security with X-Forwarded headers.
- Modified `BlockUserCommandHandler` and `UnblockUserCommandHandler` to include logging for gateway update failures, ensuring better traceability of issues during user blocking/unblocking.
- Adjusted tests for command handlers to incorporate logging functionality, improving test coverage and reliability.
- Updated frontend configuration to dynamically set the server port based on environment variables.
2026-07-02 14:33:32 +03:00
Leonid Pershin cdd67f8e2b Refactor environment configuration and update documentation for MVP status
CI / Backend (build + test) (push) Successful in 1m15s
CI / Frontend (lint + typecheck + build) (push) Successful in 30s
- Removed deprecated Telegram user ID configuration from `.env.example` and added a new setting for admin Telegram user IDs.
- Updated `CLAUDE.md` to reflect the current MVP status, detailing completed features and testing coverage.
- Enhanced `README.md` with quick start instructions for Docker setup and clarified project status.
- Revised API design documentation to include updated error handling and request/response structures.
- Improved frontend documentation to outline the project structure and technologies used.
2026-07-02 14:12:50 +03:00
Leonid Pershin 7e8435ee76 Update Docker configuration to change HTTP port from 8080 to 8085
CI / Backend (build + test) (push) Successful in 3m33s
CI / Frontend (lint + typecheck + build) (push) Successful in 29s
- Modified docker-compose.yml to update the ASPNETCORE_HTTP_PORTS and health check URL to reflect the new port.
- Adjusted service port mapping to ensure consistency with the updated configuration.
2026-07-02 13:05:00 +03:00
Leonid Pershin 8b92204733 Enhance API endpoints with response type annotations
CI / Backend (build + test) (push) Successful in 1m15s
CI / Frontend (lint + typecheck + build) (push) Successful in 30s
- Updated various API endpoints to include response type annotations using .Produces<T>() for better documentation and type safety.
- Enhanced activation, admin, user, config, and other endpoints to specify response types, improving clarity for frontend integration.
- Added new DTOs for structured responses in authentication and Telegram-related endpoints.
- Improved overall API schema generation to reflect these changes, ensuring consistency between backend and frontend types.
2026-07-02 12:56:03 +03:00
Leonid Pershin 8067be3c35 Implement rate limiting and enhance authentication flow
CI / Backend (build + test) (push) Successful in 1m17s
CI / Frontend (lint + typecheck + build) (push) Successful in 35s
- Added rate limiting configuration for authentication endpoints, allowing customizable request limits via environment variables.
- Updated authentication flow to utilize HttpRequest for cookie management, ensuring secure handling of refresh tokens.
- Introduced a new endpoint to retrieve user subscription details.
- Enhanced the handling of Telegram bot token validation to prevent errors with empty tokens.
- Updated the application to serialize enums as strings for better documentation and compatibility with TypeScript.
- Improved test coverage for new features and adjustments in command handlers.
2026-07-02 12:40:23 +03:00
Leonid Pershin ed07221ca5 Enhance Docker setup and user notification features
CI / Backend (build + test) (push) Failing after 1m35s
CI / Frontend (lint + typecheck + build) (push) Successful in 35s
- Updated docker-compose.yml to include environment variables and health checks for the app service.
- Modified Dockerfile to install curl for health checks and adjusted the build process for backend services.
- Improved user notification handling in activation, blocking, and config revocation commands by integrating Telegram notifications.
- Added new test cases to validate the updated command handlers and Telegram notifier functionality.
- Enhanced documentation to reflect the new Telegram bot features and user management improvements.
2026-07-02 01:16:53 +03:00
Leonid Pershin 7b6fe9ad78 Add Telegram bot integration and enhance user management features
- Introduced Telegram.Bot package for bot functionality.
- Updated user management to include Telegram linking and blocking features.
- Enhanced activation request handling with notifications via Telegram.
- Added new database entities for Telegram link tokens and login requests.
- Implemented traffic synchronization for client stats in the XuiPanelGateway.
- Updated application structure to support new test projects and improved dependency injection for Telegram services.
2026-07-02 01:01:03 +03:00
753 changed files with 69404 additions and 2157 deletions
+12
View File
@@ -0,0 +1,12 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "frontend",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["--dir", "frontend", "dev"],
"port": 5173,
"autoPort": true
}
]
}
+40 -16
View File
@@ -21,33 +21,57 @@ Jwt__RefreshTokenDays=30
# Путь к тому с key-ring (должен быть примонтирован и переживать перезапуск контейнера) # Путь к тому с key-ring (должен быть примонтирован и переживать перезапуск контейнера)
DataProtection__KeyRingPath=/app/keys DataProtection__KeyRingPath=/app/keys
# ── Файловое хранилище (вложения тикетов поддержки) ─────────────────────────
# Путь на диске для скриншотов к тикетам — должен быть на постоянном томе (см. docker-compose.yml,
# FileStorage__RootPath там уже задан явно под volume 'ticket_uploads', эту строку трогать не нужно).
# FileStorage__RootPath=/app/uploads
# ── Сид администратора (создаётся при первом старте, если не существует) ─── # ── Сид администратора (создаётся при первом старте, если не существует) ───
# Логин в систему — по username. Email в системе не используется. # Логин в систему — по username. Email в системе не используется.
AdminSeed__Username=admin AdminSeed__Username=admin
AdminSeed__Password=change-me-strong-admin-password AdminSeed__Password=change-me-strong-admin-password
# Telegram id(ы) администраторов (через запятую). Дают права админа в боте
# и получают уведомления о запросах на активацию. Узнать id: @userinfobot.
AdminSeed__TelegramUserIds=123456789
# ── Роли по умолчанию ───────────────────────────────────────────────────── # ── Роли по умолчанию ─────────────────────────────────────────────────────
# Квота конфигов для системной роли "user" (выдаётся при регистрации). # Стартовая квота конфигов нового пользователя (AppUser.ConfigQuota при регистрации; далее меняется
# самостоятельно через страницу тарифа /plan, не привязана к роли).
Roles__DefaultUserMaxConfigs=3 Roles__DefaultUserMaxConfigs=3
# Лимит одновременных IP на клиента (limitIp в 3x-ui) для системной роли "user"; -1 = без лимита.
Roles__DefaultUserMaxIpLimit=2
# ── Тарифы (самообслуживание) ────────────────────────────────────────────
# Верхняя граница ручного ввода количества конфигов при смене тарифа — защита от абьюза.
Plans__MaxCustomConfigCount=50
# Нижняя граница ручного ввода — не даёт занизить квоту ниже разумного минимума.
Plans__MinCustomConfigCount=3
# ── Rate limiting ────────────────────────────────────────────────────────
# Лимит запросов/мин на auth-эндпоинты (login/register/refresh/telegram/subscription). По умолчанию 20.
# RateLimiting__AuthPermitLimit=20
# ── Telegram-бот ────────────────────────────────────────────────────────── # ── Telegram-бот ──────────────────────────────────────────────────────────
# Если BotToken пуст — бот не стартует, панель работает без него. # Если BotToken пуст — бот не стартует, панель работает без него. Транспорт — только long polling
# (webhook не реализован, отдельного режима/URL для него нет). Username бота для диплинков
# (кнопка «Привязать Telegram»/QR) панель узнаёт сама через Bot API (getMe) — задавать его отдельно
# не нужно и негде (раньше был Telegram__BotUsername — убран, чтобы не ломать диплинк опечаткой/пробелом).
Telegram__BotToken= Telegram__BotToken=
Telegram__BotUsername=PnvPanelBot # Telegram id(ы) администраторов (через запятую). Дают права админа в боте (кнопки активации)
Telegram__Mode=LongPolling # и получают уведомления о запросах на активацию. Узнать id: @userinfobot.
# Для Mode=Webhook: # Не связано с сид-админом выше — привязка Telegram к сид-админу делается вручную в UI.
# Telegram__WebhookUrl=https://panel.example.com/tg/webhook Telegram__AdminTelegramUserIds=123456789
# Telegram__WebhookSecret=change-me-webhook-secret # Прокси для запросов к Bot API (обычно socks5://[user:pass@]host:port) — на случай, если Telegram
# заблокирован напрямую с сети сервера. Пусто (по умолчанию) — без прокси, прямое подключение.
# ── Приложение ──────────────────────────────────────────────────────────── # Telegram__ProxyUrl=socks5://127.0.0.1:39372
# Публичный URL сайта (для deep-link'ов бота и ссылок). # Ссылка на сайт панели — кнопка «🌐 Сайт панели» в главном меню бота. Пусто — кнопки не будет.
App__PublicSiteUrl=https://panel.example.com # Telegram__PublicSiteUrl=https://dashboard.example.com
# CORS-источники (для dev; в проде фронт и бек — один origin).
App__CorsOrigins=http://localhost:5173
# ── ASP.NET Core ────────────────────────────────────────────────────────── # ── ASP.NET Core ──────────────────────────────────────────────────────────
ASPNETCORE_ENVIRONMENT=Production ASPNETCORE_ENVIRONMENT=Production
ASPNETCORE_HTTP_PORTS=8080 ASPNETCORE_HTTP_PORTS=8080
# ── Доверенные прокси (X-Forwarded-For/Proto) ──────────────────────────────
# TLS терминируется вне compose внешним прокси/шлюзом. Чтобы клиент не мог подделать свой IP/схему
# напрямую (в обход прокси), по умолчанию доверяется только loopback (дефолт ASP.NET Core). Если
# прокси стоит не на loopback (отдельный контейнер/хост), перечисли его через запятую — конкретные
# IP через KnownProxies и/или сети в формате CIDR через KnownNetworks.
# ForwardedHeaders__KnownProxies=203.0.113.10
# ForwardedHeaders__KnownNetworks=172.18.0.0/16
+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
+65
View File
@@ -0,0 +1,65 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
backend:
name: Backend (build + test)
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Restore
run: dotnet restore PnvPanel.slnx
- name: Build
run: dotnet build PnvPanel.slnx -c Release --no-restore
# Docker доступен на ubuntu-latest — интеграционные тесты (Testcontainers.PostgreSql) реально
# поднимают Postgres и проверяют HTTP-контракт + pg_advisory_xact_lock под нагрузкой.
- name: Test
run: dotnet test PnvPanel.slnx -c Release --no-build --logger "console;verbosity=normal"
frontend:
name: Frontend (lint + typecheck + build)
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- name: Enable corepack
run: corepack enable
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Setup pnpm
run: corepack prepare pnpm@11.9.0 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm lint
- name: Typecheck
run: pnpm typecheck
- name: Build
run: pnpm build
+108 -96
View File
@@ -4,34 +4,38 @@
## Что это ## Что это
**PnvPanel** — self-service портал для VPN-конфигураций. Пользователи сами создают себе конфиги **PnvPanel** — self-service портал для VPN-конфигураций (VLESS/VMess/Trojan/Shadowsocks): пользователи
(VLESS/VMess/Trojan/Shadowsocks), админ управляет серверами и пользователями. Есть **Telegram-бот** сами создают конфиги, админ управляет серверами и пользователями. Есть **Telegram-бот** (ссылка на
(ссылка на сайт, просмотр конфигов, passwordless-вход через привязку Telegram). Бэкенд оркестрирует сайт, просмотр конфигов, passwordless-вход). Бэкенд оркестрирует панели **3x-ui** через
панели **3x-ui** через библиотеку [`ThreeXui.Net`](https://github.com/mrleo1nid/ThreeXui.Net) и [`ThreeXui.Net`](https://github.com/mrleo1nid/ThreeXui.Net), CQRS — через собственную библиотеку
хранит свою проекцию домена в PostgreSQL. Живые обновления — по SignalR. Приложение (фронт + бек + [`LiteCqrs.Net`](https://github.com/mrleo1nid/LiteCqrs.Net) (NuGet-пакет), и хранит проекцию домена
бот) поставляется **единым Docker-образом**; PostgreSQL — отдельным контейнером в compose. в PostgreSQL.
Живые обновления — SignalR. Поставка — **единый Docker-образ** (фронт+бек+бот) + PostgreSQL в compose.
> **Статус: проектирование.** Код ещё не написан. Актуальны только документация и этот файл. > Собрано и покрыто тестами, единый образ и compose-стек проверены живьём. Есть опциональный биллинг
> При старте реализации следуй [`docs/roadmap.md`](docs/roadmap.md) (этапы M0…M6). > (подписка по сроку, per-роль). Осознанно не реализовано: лимиты трафика на конфиг, полное
> самообслуживание в боте — см. [tech-stack.md](docs/tech-stack.md).
## Документация (single source of truth) ## Документация (single source of truth)
Прежде чем менять архитектуру или добавлять фичу — свериться с [`docs/`](docs/README.md): Прежде чем менять архитектуру или добавлять фичу — свериться с [`docs/`](docs/README.md):
[Vision](docs/vision.md) · [Architecture](docs/architecture.md) · [Domain Model](docs/domain-model.md) ·
- [Vision](docs/vision.md) · [Architecture](docs/architecture.md) · [Domain Model](docs/domain-model.md) [Tech Stack](docs/tech-stack.md) · [Backend Conventions](docs/backend-conventions.md) ·
- [Tech Stack (ADR)](docs/tech-stack.md) · [Backend Conventions](docs/backend-conventions.md) [Frontend](docs/frontend.md) · [Telegram Bot](docs/telegram-bot.md) · [API Design](docs/api-design.md)
- [Frontend](docs/frontend.md) · [Telegram Bot](docs/telegram-bot.md) · [API Design](docs/api-design.md) · [Roadmap](docs/roadmap.md)
**Держи доки в синхроне с кодом.** Меняешь контракт/архитектуру — обнови соответствующий док в том же изменении. **Держи доки в синхроне с кодом.** Меняешь контракт/архитектуру — обнови соответствующий док в том же изменении.
## Стек ## Стек
- **Backend**: C# / .NET 10, ASP.NET Core Web API, Clean Architecture, CQRS (**собственный тонкий - **Backend**: C# / .NET 10, ASP.NET Core Web API, Clean Architecture, CQRS (через
диспетчер**, без MediatR), EF Core 10 + Npgsql (PostgreSQL), ASP.NET Core Identity + JWT, SignalR, [`LiteCqrs.Net`](https://github.com/mrleo1nid/LiteCqrs.Net) — собственная лёгкая
FluentValidation, Mapster, **Serilog** (логирование). CQRS-библиотека, альтернатива MediatR с явным разделением Command/Query), EF Core 10 + Npgsql,
- **Frontend**: React 19 + Vite + TypeScript, TanStack Query/Router, shadcn/ui + Tailwind CSS v4, ASP.NET Core Identity + JWT, SignalR, FluentValidation,
Zustand, react-hook-form + zod, @microsoft/signalr, Recharts. Пакетный менеджер — pnpm. Serilog. Маппинг DTO вручную (`FromDomain(...)`, без Mapster). OpenAPI — нативный
- **Telegram**: Telegram.Bot, бот как `BackgroundService` **в процессе Api** (long polling). `Microsoft.AspNetCore.OpenApi` + Scalar UI (без Swashbuckle).
- **Frontend**: React 19 + Vite + TS, TanStack Query/Router, shadcn-стиль поверх Radix + Tailwind v4,
Zustand (только auth), react-hook-form + zod, @microsoft/signalr. pnpm, oxlint.
- **Telegram**: Telegram.Bot как `BackgroundService` **в процессе Api** (long polling).
- **Инфра**: единый Docker-образ (API+бот+статика SPA) + PostgreSQL в docker-compose. - **Инфра**: единый Docker-образ (API+бот+статика SPA) + PostgreSQL в docker-compose.
## Архитектура — жёсткие правила ## Архитектура — жёсткие правила
@@ -39,102 +43,112 @@
Слои и направление зависимостей: **Api → Infrastructure → Application → Domain** (внутрь). Слои и направление зависимостей: **Api → Infrastructure → Application → Domain** (внутрь).
- **Domain** — без внешних зависимостей. Rich model: инварианты в сущностях (приватные сеттеры, - **Domain** — без внешних зависимостей. Rich model: инварианты в сущностях (приватные сеттеры,
фабричные методы, поведенческие методы). Никаких EF/HTTP/Identity здесь. фабричные/поведенческие методы). Никаких EF/HTTP/Identity.
- **Application** — CQRS-хендлеры, DTO, валидаторы, **порты** (интерфейсы). Зависит только от Domain. - **Application** — CQRS-хендлеры, DTO, валидаторы, **порты** (интерфейсы). Зависит только от Domain
Никаких `Npgsql`/`SignalR`/`ThreeXui.Net` только их интерфейсы (`IAppDbContext`, `IXuiPanelGateway`, никаких `Npgsql`/`SignalR`/`ThreeXui.Net`, только их интерфейсы (`IAppDbContext`, `IXuiPanelGateway`,
`IRealtimeNotifier`, `ISecretProtector`, `ICurrentUser`, ...). `IRealtimeNotifier`, `ISecretProtector`, `ICurrentUser`, ...).
- **Infrastructure** — реализации портов: EF Core, Identity/JWT, `XuiPanelGateway`, SignalR-пуш, - **Infrastructure** — реализации портов: EF Core, Identity/JWT, `XuiPanelGateway`, SignalR-пуш,
фоновые сервисы, шифрование секретов. фоновые сервисы, шифрование секретов.
- **Api** — Minimal API эндпоинты (по фичам), SignalR-хабы, middleware, DI composition root. - **Api** — Minimal API эндпоинты (по фичам), SignalR-хабы, middleware, DI composition root.
Обязательно: Обязательно:
- CQRS: команды меняют состояние и идут в транзакции (UnitOfWorkBehavior); запросы только читают - Команды меняют состояние в транзакции (`UnitOfWorkBehavior`); запросы только читают (`AsNoTracking` +
(`AsNoTracking` + проекция в DTO). Диспетчер — **собственный** (`ISender`/`ICommandHandler`/ проекция в DTO). Диспетчер — **собственный** (`ISender`/`ICommandHandler`/`IQueryHandler`, DI-регистрация).
`IQueryHandler`, регистрация хендлеров через DI), без внешних CQRS-библиотек. - Управляемые ошибки — через `Result<T>`, не исключениями (исключения только для исключительного).
- Управляемые ошибки — через `Result<T>`, не исключениями. Исключения — только для исключительного.
- Валидация — FluentValidation через `ValidationBehavior`; хендлер не перепроверяет формат ввода. - Валидация — FluentValidation через `ValidationBehavior`; хендлер не перепроверяет формат ввода.
- Всё I/O асинхронно, `CancellationToken` пробрасывается до EF/HTTP. Никаких `.Result`/`.Wait()`. - Всё I/O асинхронно, `CancellationToken` пробрасывается до EF/HTTP. Никаких `.Result`/`.Wait()`.
- Nullable reference types включены; предупреждения анализаторов не игнорировать. - Nullable reference types включены; предупреждения анализаторов не игнорировать.
## Интеграция с 3x-ui ## Интеграция с 3x-ui
- Только через порт `IXuiPanelGateway`. `ThreeXui.Net` регистрируется на один `BaseAddress`, а нод - Только через порт `IXuiPanelGateway`. Один `BaseAddress` в `ThreeXui.Net`, а нод много → гейтвей
много → гейтвей держит **клиента per-node** (кэш по `NodeId`), создавая его из расшифрованных держит **клиента per-node** (кэш по `NodeId`) из расшифрованных `NodeCredentials`. Детали —
`NodeCredentials`. Детали — в [architecture.md](docs/architecture.md#интеграция-с-3x-ui-threexuinet). [architecture.md](docs/architecture.md#интеграция-с-3x-ui-threexuinet).
- Пароли нод **шифруются at-rest** (`ISecretProtector`), расшифровка только внутри Infrastructure, - Пароли нод **шифруются at-rest** (`ISecretProtector`), расшифровка только в Infrastructure, никогда в логах/ответах.
никогда не в логах/ответах API.
- Недоступность ноды → `Result.Failure`/`NodeStatus.Offline`, не 500 наружу. - Недоступность ноды → `Result.Failure`/`NodeStatus.Offline`, не 500 наружу.
- Операции с 3x-ui идемпотентны; при частичном сбое (клиент создан в панели, но упала БД) — компенсация. - Операции идемпотентны; при частичном сбое (клиент создан в панели, упала БД) — компенсация.
## Роли, активация, сидинг ## Домен: роли, активация, конфиги
- **Роли динамические**: `AppRole : IdentityRole<Guid>` + поле `MaxConfigs` (квота на число конфигов). Полная модель — [domain-model.md](docs/domain-model.md). Ключевые инварианты:
Квота — **на роли, а не на `Plan`**. **У пользователя ровно одна роль**; квота = `MaxConfigs` его
роли (`admin` — без лимита). Системные роли (`admin`/`user`) не удалять/переименовывать. - **Роли динамические** (`AppRole`), квоты на роли (не на `Plan`): `MaxConfigs` (число конфигов),
- **Активация**: новый пользователь `IsActivated = false`, роль `user`. Конфиги может создавать `MaxIpLimit` (лимит одновременных IP клиента в 3x-ui, `limitIp`); -1 = без лимита. У пользователя
только активированный. `ActivationRequest` (с комментарием заявителя) одобряет админ на сайте ровно одна роль; `admin` — без лимитов. Системные роли `admin`/`user` не удалять/переименовывать.
**или** в Telegram — одними и теми же командами (`ApproveActivationCommand`/`RejectActivationCommand`). Понижение роли — грандфазеринг (лишние конфиги не отзываются, новые блокируются до входа в квоту).
- **Инбаунды по ролям**: `Inbound.AllowedRoles` (M:N). При создании конфига доменный инвариант - **`limitIp`** выставляется автоматически по `MaxIpLimit` роли при создании клиента (`Create`/`Rotate`);
проверяет: активирован + под квотой роли + роль входит в `AllowedRoles` инбаунда + нода включена. панель не даёт настраивать его per-конфиг и не трогает уже созданных клиентов при смене роли/квоты.
Проверку квоты делать **в транзакции** (гонки параллельных созданий). - **Активация**: новый пользователь `IsActivated=false`, роль `user`; неактивированному недоступны
- **Понижение роли — грандфазеринг**: смена на меньшую квоту разрешена; лишние конфиги не отзываем, конфиги (создание/просмотр/редактирование/ротация/отзыв/ссылка/подписка), новости и каталог
но новые нельзя до входа в квоту. приложений — единая проверка `RequireActivationBehavior` по маркеру `IRequiresActivation` (не
- **Блокировка** (`AppUser.IsBlocked`): вход запрещён + все конфиги `Disabled` (отключить клиентов разбросанные `if` в хендлерах). На фронте до активации доступны только дашборд (форма запроса
в 3x-ui); разблокировка — обратно. Действие в `AuditLog`. активации) и настройки аккаунта. `ActivationRequest` одобряет админ на сайте или в Telegram —
- **Аудит**: значимые действия (активация, блок, смена роли, отзыв, ноды/инбаунды) писать в `AuditLog` одними командами.
- **Инбаунды по ролям** (`Inbound.AllowedRoles`, M:N): создание конфига проверяет активацию + квоту роли
(в транзакции — гонки параллельных созданий) + `AllowedRoles` + включённость ноды.
- **Поддержка** (`SupportTicket`, доступна только активированным): баг-репорт/предложение (свободная
форма + вложения-картинки, диск-хранилище `IFileStorage`) либо заявка на роль (существующая роль,
кроме `admin`, либо параметры новой). `Open → Resolved → [Reopen]`, `Closed` — финал без возврата.
Одобрение заявки на роль создаёт/назначает роль автоматически; полностью решается и в Telegram
(инлайн-кнопки), баг-репорты — только уведомление-ссылка на сайт.
- **Блокировка** (`AppUser.IsBlocked`): вход запрещён + все конфиги `Disabled` в 3x-ui; в `AuditLog`.
- **Удаление пользователя** — свой аккаунт (`DELETE /api/auth/me`) или админом
(`DELETE /api/admin/users/{id}`, себя удалить нельзя): отзыв всех конфигов в 3x-ui, затем `AppUser`;
админский путь дополнительно пишет `AuditLog` (`UserDeleted`) и шлёт Telegram-DM.
- **Аудит**: значимые действия (активация/блок/роль/отзыв/ноды/инбаунды/удаление) — `AuditLog`
(append-only, источник Web/Telegram/System). (append-only, источник Web/Telegram/System).
- **Подписка**: агрегированная на юзера (`AppUser.SubscriptionToken`, все активные конфиги) + по конфигу. - **Биллинг** (`AppRole.BillingEnabled`, недоступен для `admin`): пользователь оформляет
- **Ротация конфига** (`Rotate()`): новый UUID/ссылка, квоту не тратит. **Бот в MVP — read-only** по конфигам. `PaymentRequest` на 3/6/12 мес (сумма — по `PricingSettings`, заморожена на заявке), админ
- **Конфиг**: пользователь задаёт метку (`Label`) и лимит устройств (`DeviceLimit``limitIp` в 3x-ui, 0=без лимита), может редактировать. подтверждает/отклоняет на сайте или в Telegram (`pay:*`). Пока заявка `AwaitingConfirmation`
- **Самоудаление аккаунта** (`DELETE /api/auth/me`): отзыв всех конфигов + удаление данных, аудит анонимизируется. конфиги не гасятся, даже если срок истёк (не по вине пользователя, что админ не успел). Просрочка
- **API без версионирования** в MVP (`/api` без `v1`). Подписка отдаёт `Subscription-Userinfo`. без заявки → `VpnConfig.Suspend()` (статус `Expired`, отдельно от `Disable()`/блокировки админом) —
- **Тема**: светлая/тёмная/системная (Tailwind `dark`, выбор в localStorage). см. [domain-model.md](docs/domain-model.md#billing--подписка-по-сроку).
- **Инструкции + приложения**: отдельная страница инструкций; каталог `ClientApp` (админ CRUD: - **Ротация конфига** (`Rotate()`) — новый UUID/ссылка, квоту не тратит. **Бот read-only** по конфигам.
название/ссылка/ОС/порядок/вкл), пользователю `GET /api/apps` отдаётся сгруппированным по ОС. - **Подписка**: агрегированная (`AppUser.SubscriptionToken`) + по конфигу. API без версионирования
- **Вход — по `UserName`** (email в системе не используется вовсе; SMTP не нужен). (`/api`, без `v1`); подписка отдаёт `Subscription-Userinfo`.
Восстановление пароля: через привязанный Telegram (self-service), без привязки — сброс админом - **Вход по `UserName`** (email не используется). Восстановление пароля через привязанный Telegram
(`ResetUserPasswordCommand`). Пока Telegram не привязан — UI настойчиво предлагает его привязать. (self-service) либо сбросом админом (`ResetUserPasswordCommand`); без привязки UI настойчиво предлагает привязать.
- **Сидинг из env**: идемпотентный `DbInitializer` на старте создаёт системные роли и учётку админа - **Сидинг**: идемпотентный `DbInitializer` создаёт системные роли + админа из env
(username/пароль/Telegram id) из переменных окружения; каталог приложений `ClientApp` (если пуст) — (`AdminSeed__Username`/`Password`); каталог `ClientApp` — из [`seed/client-apps.json`](seed/client-apps.json).
из [`seed/client-apps.json`](seed/client-apps.json). Единый источник примера env — [`.env.example`](.env.example); Источник примера env — [`.env.example`](.env.example), обновляй при новых настройках. Секреты — только через env.
при добавлении новой настройки обновляй и его. Секреты (пароль админа, JWT-ключ, BotToken) — только через env/secret-store. `Telegram__AdminTelegramUserIds` — отдельно от сидинга, не пишется в БД, читается напрямую из `TelegramOptions`.
- Telegram id админов (`AdminSeed__TelegramUserIds`) авторизуют админ-действия в боте и получают
уведомления о запросах активации.
## Telegram-бот ## Telegram-бот
- Бот — **presentation-адаптер**, не бизнес-слой. Хостится в процессе Api (`TelegramBotHostedService`, Детали флоу — [telegram-bot.md](docs/telegram-bot.md).
long polling). Хендлеры апдейтов вызывают **те же** CQRS-команды/запросы через собственный `ISender`
(`GetMyConfigsQuery`, `LinkTelegramCommand`, `ApproveTelegramLoginCommand`, ...). - Presentation-адаптер, не бизнес-слой: `TelegramBotHostedService` (long polling) вызывает **те же**
- `Telegram.Bot` не проникает в Application/Domain — только в `Api/Telegram/`. CQRS-команды через `ISender`. `Telegram.Bot` не проникает в Application/Domain — только `Api/Telegram/`.
- Passwordless-вход выпускает **те же** JWT/refresh, что и обычный логин. Требует привязки Telegram - Passwordless-вход выпускает те же JWT/refresh, что и обычный логин; требует привязки Telegram (с сайта)
(в MVP — только привязка существующего аккаунта, регистрация из бота — backlog). либо регистрации прямо из бота (`RegisterViaTelegramCommand` — логин `@username`/id, пароль генерируется
- Токены привязки/входа: короткоживущие, одноразовые, высокоэнтропийные. `Telegram:BotToken` — секрет, и приходит в чат один раз). Новый аккаунт — роль `user`, `IsActivated=false`, активация как обычно.
не логировать. Панель должна работать и без бота (если токен не задан — бот просто не стартует). - Токены привязки/входа — короткоживущие одноразовые. `Telegram:BotToken` — секрет, не логировать.
- Детали флоу — [telegram-bot.md](docs/telegram-bot.md). Без токена бот просто не стартует — панель работает и без него.
## Единый контейнер ## Единый контейнер
- Один образ приложения: Api раздаёт REST (`/api`), SignalR (`/hubs`), хостит бота **и** статику SPA - Один образ: Api раздаёт REST (`/api`), SignalR (`/hubs`), хостит бота и статику SPA из `wwwroot`
из `wwwroot` (fallback на `index.html`). Фронт и бек — один origin, база API — относительный `/api`. (fallback на `index.html`). Один origin, база API — относительный `/api`.
- Multi-stage Dockerfile: node (сборка фронта) → dotnet sdk (publish + копирование в `wwwroot`) → aspnet runtime. - Multi-stage Dockerfile: node (фронт) → dotnet sdk (publish + копирование в `wwwroot`) → aspnet runtime.
- docker-compose: `app` (единый образ) + `db` (PostgreSQL). В dev — Vite-прокси `/api`,`/hubs` на бэк. - docker-compose: `app` + `db` (PostgreSQL); в dev — Vite-прокси `/api`,`/hubs` на бэк.
- Не вводи отдельный nginx-контейнер для статики без явной просьбы — это ломает требование единого контейнера. - Не вводи отдельный nginx-контейнер для статики без явной просьбы — ломает требование единого контейнера.
- **TLS — внешний** (прокси/шлюз вне compose); `app` отдаёт HTTP + доверяет `X-Forwarded-*` через - **TLS — внешний**; `app` отдаёт HTTP + доверяет `X-Forwarded-*` (`ForwardedHeaders`). Свой nginx/Caddy не добавляй.
`ForwardedHeaders` (иначе Secure-cookie/схема за прокси сломаются). Свой nginx/Caddy не добавляй. - Миграции применяются авто на старте. CI (GitHub Actions) — только build/test, без деплоя.
- **Миграции** применяются авто на старте (MVP). **CI** (GitHub Actions) — только build/test, без деплоя.
## Соглашения по коду ## Соглашения по коду
Полный список — в [backend-conventions.md](docs/backend-conventions.md). Кратко: Полный список — [backend-conventions.md](docs/backend-conventions.md). Кратко:
- Команды `<Verb><Noun>Command`, запросы `<Get/List><Noun>Query`, + `Handler`/`Validator`. DTO — суффикс `Dto`. - `<Verb><Noun>Command`/`<Get|List><Noun>Query` + `Handler`/`Validator` (валидатор — где есть что
- Application организована **по фичам** (feature folders) внутри слоёв. проверить). DTO — суффикс `Dto`; тела запросов Api — `Body`; тела ответов без Application DTO —
- Один публичный тип на файл, имя файла = имя типа. Async-методы — суффикс `Async` + `CancellationToken`. `ResponseDto`. Application — по фичам (feature folders).
- Секреты не логировать; логи структурные (Serilog) с `UserId`/`NodeId`/`ConfigId`/`CorrelationId`. - Один публичный тип на файл = имя файла (кроме `Body`/`ResponseDto` в файле эндпоинтов).
- Ошибки API — единый `ProblemDetails`. Async-методы — суффикс `Async` + `CancellationToken`.
- Секреты не логировать; логи — Serilog. Явного обогащения `UserId`/`NodeId`/`ConfigId`/`CorrelationId`
пока нет — не полагайся на него при расследовании.
- Ошибки API — единый `application/problem+json`.
## Команды (ожидаемые — появятся по мере создания проектов) ## Команды
Backend (из `backend/`): Backend (из `backend/`):
```bash ```bash
@@ -161,19 +175,17 @@ docker compose up -d # api + postgres (+ web)
``` ```
> Окружение: Windows, основная оболочка — **PowerShell**. Для POSIX-скриптов есть Bash-инструмент. > Окружение: Windows, основная оболочка — **PowerShell**. Для POSIX-скриптов есть Bash-инструмент.
> Пути — с учётом Windows.
## Принятые решения (зафиксированы) ## Ключевые решения
Ключевые развилки закрыты — см. [tech-stack.md](docs/tech-stack.md#принятые-решения-по-открытым-вопросам): См. [tech-stack.md](docs/tech-stack.md#ключевые-решения-по-домену-и-поведению): CQRS — через
CQRS — **собственный диспетчер** (не MediatR); **одна роль** на пользователя; секреты нод — LiteCqrs.Net (собственная библиотека, не MediatR); одна роль на пользователя; секреты нод — ASP.NET Data Protection; биллинг
**ASP.NET Data Protection**; тарифы `Plan`**backlog** (в MVP без лимитов трафика/срока); опционален per-роль (недоступен для `admin`); i18n — RU+EN (react-i18next); Telegram — long polling,
i18n — **RU+EN** (react-i18next); Telegram — **long polling**, только **привязка** (не signup); только привязка (не signup); история трафика — простая таблица + TTL; логирование — Serilog.
история трафика — **простая таблица + TTL**; логирование — **Serilog**.
## Рабочие принципы ## Рабочие принципы
- Не начинай крупную реализацию без сверки с доками и, при неоднозначности, без вопроса пользователю. - Не начинай крупную реализацию без сверки с доками и, при неоднозначности, без вопроса пользователю.
- Соблюдай границы слоёв — это главный инвариант проекта. Нарушение = ошибка ревью. - Соблюдай границы слоёв — главный инвариант проекта. Нарушение = ошибка ревью.
- Обновляй документацию вместе с кодом. Не коммить и не пуши без явной просьбы. - Обновляй документацию вместе с кодом. Не коммить и не пуши без явной просьбы.
- Отвечай пользователю на русском (язык общения в проекте — русский). - Отвечай пользователю на русском.
+14 -4
View File
@@ -12,13 +12,13 @@ RUN pnpm build
# ── Stage 2: publish бэкенда, статика фронта в wwwroot ──────────────────── # ── Stage 2: publish бэкенда, статика фронта в wwwroot ────────────────────
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS backend FROM mcr.microsoft.com/dotnet/sdk:10.0 AS backend
WORKDIR /src WORKDIR /src
# Сначала манифесты для кэша restore # Сначала манифесты для кэша restore (только цепочка Api → ... → Domain, без tests/)
COPY backend/PnvPanel.sln backend/Directory.Build.props backend/Directory.Packages.props ./backend/ COPY 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.Domain/PnvPanel.Domain.csproj ./backend/src/PnvPanel.Domain/
COPY backend/src/PnvPanel.Application/PnvPanel.Application.csproj ./backend/src/PnvPanel.Application/ 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.Infrastructure/PnvPanel.Infrastructure.csproj ./backend/src/PnvPanel.Infrastructure/
COPY backend/src/PnvPanel.Api/PnvPanel.Api.csproj ./backend/src/PnvPanel.Api/ COPY backend/src/PnvPanel.Api/PnvPanel.Api.csproj ./backend/src/PnvPanel.Api/
RUN dotnet restore backend/PnvPanel.sln RUN dotnet restore backend/src/PnvPanel.Api/PnvPanel.Api.csproj
# Исходники бэкенда # Исходники бэкенда
COPY backend/ ./backend/ COPY backend/ ./backend/
# Сид каталога приложений (PnvPanel.Api.csproj ссылается на него через ../../../seed/) # Сид каталога приложений (PnvPanel.Api.csproj ссылается на него через ../../../seed/)
@@ -30,8 +30,18 @@ RUN dotnet publish backend/src/PnvPanel.Api/PnvPanel.Api.csproj -c Release -o /a
# ── Stage 3: runtime ────────────────────────────────────────────────────── # ── Stage 3: runtime ──────────────────────────────────────────────────────
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app WORKDIR /app
# curl — HEALTHCHECK (GET /health). gosu — безопасный drop-privileges из entrypoint: контейнер
# стартует под root, чтобы почистить владельца смонтированных volume'ов, и тут же передаёт
# управление непривилегированному пользователю (см. docker-entrypoint.sh).
RUN apt-get update && apt-get install -y --no-install-recommends curl gosu \
&& rm -rf /var/lib/apt/lists/*
ENV ASPNETCORE_ENVIRONMENT=Production \ ENV ASPNETCORE_ENVIRONMENT=Production \
ASPNETCORE_HTTP_PORTS=8080 ASPNETCORE_HTTP_PORTS=8080
EXPOSE 8080 EXPOSE 8080
COPY --from=backend /app/publish ./ COPY --from=backend /app/publish ./
ENTRYPOINT ["dotnet", "PnvPanel.Api.dll"] COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh && mkdir -p /app/keys /app/uploads
HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=5 \
CMD curl -f http://localhost:8080/health || exit 1
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["dotnet", "PnvPanel.Api.dll"]
+77
View File
@@ -0,0 +1,77 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.5.2.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "backend", "backend", "{1AE8ACA6-933B-BF2A-3671-3E2EAC007D16}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{0F9113EE-888A-26D2-68B0-4A7D0A2A8745}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PnvPanel.Api", "backend\src\PnvPanel.Api\PnvPanel.Api.csproj", "{3B6A930E-4799-6F42-1E94-163F6773FBBC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PnvPanel.Application", "backend\src\PnvPanel.Application\PnvPanel.Application.csproj", "{25F9AF36-7508-0DC8-2469-D065D078DBF3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PnvPanel.Domain", "backend\src\PnvPanel.Domain\PnvPanel.Domain.csproj", "{7511C433-2BCA-BF01-BEF4-DBC6A5DB8CF3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PnvPanel.Infrastructure", "backend\src\PnvPanel.Infrastructure\PnvPanel.Infrastructure.csproj", "{470155DC-9172-CAAF-7AA4-D642ECCFD2D2}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{F57642F3-C37C-D174-720E-6A6AAD5BEE22}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PnvPanel.Application.Tests", "backend\tests\PnvPanel.Application.Tests\PnvPanel.Application.Tests.csproj", "{4A683703-6702-96CD-5AB6-56199C1C1C7E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PnvPanel.Domain.Tests", "backend\tests\PnvPanel.Domain.Tests\PnvPanel.Domain.Tests.csproj", "{F3793E3C-AC9E-D8DA-704A-E67DC4FA790F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PnvPanel.IntegrationTests", "backend\tests\PnvPanel.IntegrationTests\PnvPanel.IntegrationTests.csproj", "{8A44D601-6F27-4D86-63F7-25C42FF67414}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{3B6A930E-4799-6F42-1E94-163F6773FBBC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3B6A930E-4799-6F42-1E94-163F6773FBBC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3B6A930E-4799-6F42-1E94-163F6773FBBC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3B6A930E-4799-6F42-1E94-163F6773FBBC}.Release|Any CPU.Build.0 = Release|Any CPU
{25F9AF36-7508-0DC8-2469-D065D078DBF3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{25F9AF36-7508-0DC8-2469-D065D078DBF3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{25F9AF36-7508-0DC8-2469-D065D078DBF3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{25F9AF36-7508-0DC8-2469-D065D078DBF3}.Release|Any CPU.Build.0 = Release|Any CPU
{7511C433-2BCA-BF01-BEF4-DBC6A5DB8CF3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7511C433-2BCA-BF01-BEF4-DBC6A5DB8CF3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7511C433-2BCA-BF01-BEF4-DBC6A5DB8CF3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7511C433-2BCA-BF01-BEF4-DBC6A5DB8CF3}.Release|Any CPU.Build.0 = Release|Any CPU
{470155DC-9172-CAAF-7AA4-D642ECCFD2D2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{470155DC-9172-CAAF-7AA4-D642ECCFD2D2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{470155DC-9172-CAAF-7AA4-D642ECCFD2D2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{470155DC-9172-CAAF-7AA4-D642ECCFD2D2}.Release|Any CPU.Build.0 = Release|Any CPU
{4A683703-6702-96CD-5AB6-56199C1C1C7E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4A683703-6702-96CD-5AB6-56199C1C1C7E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4A683703-6702-96CD-5AB6-56199C1C1C7E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4A683703-6702-96CD-5AB6-56199C1C1C7E}.Release|Any CPU.Build.0 = Release|Any CPU
{F3793E3C-AC9E-D8DA-704A-E67DC4FA790F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F3793E3C-AC9E-D8DA-704A-E67DC4FA790F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F3793E3C-AC9E-D8DA-704A-E67DC4FA790F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F3793E3C-AC9E-D8DA-704A-E67DC4FA790F}.Release|Any CPU.Build.0 = Release|Any CPU
{8A44D601-6F27-4D86-63F7-25C42FF67414}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8A44D601-6F27-4D86-63F7-25C42FF67414}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8A44D601-6F27-4D86-63F7-25C42FF67414}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8A44D601-6F27-4D86-63F7-25C42FF67414}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{0F9113EE-888A-26D2-68B0-4A7D0A2A8745} = {1AE8ACA6-933B-BF2A-3671-3E2EAC007D16}
{3B6A930E-4799-6F42-1E94-163F6773FBBC} = {0F9113EE-888A-26D2-68B0-4A7D0A2A8745}
{25F9AF36-7508-0DC8-2469-D065D078DBF3} = {0F9113EE-888A-26D2-68B0-4A7D0A2A8745}
{7511C433-2BCA-BF01-BEF4-DBC6A5DB8CF3} = {0F9113EE-888A-26D2-68B0-4A7D0A2A8745}
{470155DC-9172-CAAF-7AA4-D642ECCFD2D2} = {0F9113EE-888A-26D2-68B0-4A7D0A2A8745}
{F57642F3-C37C-D174-720E-6A6AAD5BEE22} = {1AE8ACA6-933B-BF2A-3671-3E2EAC007D16}
{4A683703-6702-96CD-5AB6-56199C1C1C7E} = {F57642F3-C37C-D174-720E-6A6AAD5BEE22}
{F3793E3C-AC9E-D8DA-704A-E67DC4FA790F} = {F57642F3-C37C-D174-720E-6A6AAD5BEE22}
{8A44D601-6F27-4D86-63F7-25C42FF67414} = {F57642F3-C37C-D174-720E-6A6AAD5BEE22}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {B0DAEC4D-A6CA-40D5-94C1-F0ED1F9EBB44}
EndGlobalSection
EndGlobal
+23 -6
View File
@@ -21,6 +21,28 @@
| Frontend | React 19 + Vite + TypeScript, TanStack Query/Router, shadcn/ui + Tailwind | | Frontend | React 19 + Vite + TypeScript, TanStack Query/Router, shadcn/ui + Tailwind |
| Упаковка | Единый Docker-образ (API + бот + статика SPA) + PostgreSQL в docker-compose | | Упаковка | Единый Docker-образ (API + бот + статика SPA) + PostgreSQL в docker-compose |
## Быстрый старт
Единый Docker-образ (API + бот + статика SPA) + PostgreSQL:
```bash
cp .env.example .env # заполнить AdminSeed__Password, Jwt__SigningKey и т.д.
docker compose up -d --build
# → http://localhost:8080 (админ — логин/пароль из .env, AdminSeed__Username/Password)
```
Локальная разработка (без Docker для приложения — только `db`):
```bash
# backend, из backend/
dotnet build && dotnet test
dotnet run --project src/PnvPanel.Api
# frontend, из frontend/
pnpm install
pnpm dev # проксирует /api, /hubs на localhost:8080
```
## Документация ## Документация
Проектная документация лежит в [`docs/`](docs/README.md): Проектная документация лежит в [`docs/`](docs/README.md):
@@ -28,20 +50,15 @@
- [Product Vision & Scope](docs/vision.md) — что мы строим и для кого - [Product Vision & Scope](docs/vision.md) — что мы строим и для кого
- [Architecture](docs/architecture.md) — Clean Architecture, CQRS, интеграция, realtime, безопасность - [Architecture](docs/architecture.md) — Clean Architecture, CQRS, интеграция, realtime, безопасность
- [Domain Model](docs/domain-model.md) — сущности, связи, инварианты - [Domain Model](docs/domain-model.md) — сущности, связи, инварианты
- [Tech Stack (ADR)](docs/tech-stack.md) — решения по стеку и их обоснование - [Tech Stack](docs/tech-stack.md) — используемые технологии
- [Backend Conventions](docs/backend-conventions.md) — структура проекта, паттерны, стиль кода - [Backend Conventions](docs/backend-conventions.md) — структура проекта, паттерны, стиль кода
- [Frontend](docs/frontend.md) — стек фронтенда и структура - [Frontend](docs/frontend.md) — стек фронтенда и структура
- [Telegram Bot](docs/telegram-bot.md) — бот, привязка Telegram и passwordless-вход - [Telegram Bot](docs/telegram-bot.md) — бот, привязка Telegram и passwordless-вход
- [API Design](docs/api-design.md) — REST-эндпоинты и SignalR-контракты - [API Design](docs/api-design.md) — REST-эндпоинты и SignalR-контракты
- [Roadmap](docs/roadmap.md) — этапы разработки
Пример переменных окружения (сид админа, БД, JWT, Telegram) — [`.env.example`](.env.example). Пример переменных окружения (сид админа, БД, JWT, Telegram) — [`.env.example`](.env.example).
Инструкции для AI-ассистента (Claude Code) — в [`CLAUDE.md`](CLAUDE.md). Инструкции для AI-ассистента (Claude Code) — в [`CLAUDE.md`](CLAUDE.md).
## Статус
🚧 Проектирование. Кодовая база ещё не создана — на этом этапе зафиксированы архитектура и план.
## Лицензия ## Лицензия
[MIT](LICENSE) [MIT](LICENSE)
-2
View File
@@ -1,5 +1,4 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
@@ -18,5 +17,4 @@
--> -->
<NoWarn>$(NoWarn);CA1711;CA1716;CA1848;CA1873</NoWarn> <NoWarn>$(NoWarn);CA1711;CA1716;CA1848;CA1873</NoWarn>
</PropertyGroup> </PropertyGroup>
</Project> </Project>
+31 -16
View File
@@ -4,26 +4,41 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageVersion Include="FluentValidation" Version="12.1.1" /> <PackageVersion Include="FluentValidation" Version="12.1.1" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" /> <PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.Extensions" Version="10.0.9" /> <PackageVersion Include="Microsoft.AspNetCore.DataProtection.Extensions" Version="10.0.10" />
<PackageVersion Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.11" /> <PackageVersion Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.11" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" /> <PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.10" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.9" /> <PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.9" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.9" /> <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" /> <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.10" />
<PackageVersion Include="Microsoft.OpenApi" Version="2.9.0" /> <!-- Держим 2.x: Microsoft.AspNetCore.OpenApi 10.0.10 сам зависит от Microsoft.OpenApi >=2.0.0
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.7" /> (nuspec) и его Roslyn source generator (XmlCommentGenerator) скомпилирован под 2.x API —
3.x меняет IOpenApiMediaType.Example на read-only и ломает генерацию (CS0200). -->
<PackageVersion Include="Microsoft.OpenApi" Version="2.11.0" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.16" />
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" /> <PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.9" /> <PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.10" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9"> <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageVersion> </PackageVersion>
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" /> <PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.9" /> <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" /> <PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.10" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.19.1" /> <PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.21.0" />
<PackageVersion Include="ThreeXui.Net" Version="1.0.0" /> <PackageVersion Include="Telegram.Bot" Version="22.10.2" />
<PackageVersion Include="ThreeXui.Net" Version="1.0.4" />
<PackageVersion Include="LiteCqrs.Net" Version="1.0.1" />
<!-- Тестирование (M8) -->
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="NSubstitute" Version="6.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.10" />
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.13.0" />
<!-- InMemory, не Sqlite/Npgsql — модель использует Postgres-специфичные типы (uuid[], jsonb),
не имеющие реляционных аналогов; InMemory игнорирует HasColumnType и не требует их маппинга. -->
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.10" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+5
View File
@@ -5,4 +5,9 @@
<Project Path="src/PnvPanel.Domain/PnvPanel.Domain.csproj" /> <Project Path="src/PnvPanel.Domain/PnvPanel.Domain.csproj" />
<Project Path="src/PnvPanel.Infrastructure/PnvPanel.Infrastructure.csproj" /> <Project Path="src/PnvPanel.Infrastructure/PnvPanel.Infrastructure.csproj" />
</Folder> </Folder>
<Folder Name="/tests/">
<Project Path="tests/PnvPanel.Domain.Tests/PnvPanel.Domain.Tests.csproj" />
<Project Path="tests/PnvPanel.Application.Tests/PnvPanel.Application.Tests.csproj" />
<Project Path="tests/PnvPanel.IntegrationTests/PnvPanel.IntegrationTests.csproj" />
</Folder>
</Solution> </Solution>
@@ -4,11 +4,11 @@ namespace PnvPanel.Api.Common;
public static class ResultExtensions public static class ResultExtensions
{ {
public static IResult ToHttpResult(this Result result) public static IResult ToHttpResult(this Result result) =>
=> result.IsSuccess ? Results.NoContent() : ToProblem(result.Error); result.IsSuccess ? Results.NoContent() : ToProblem(result.Error);
public static IResult ToHttpResult<T>(this Result<T> result) public static IResult ToHttpResult<T>(this Result<T> result) =>
=> result.IsSuccess ? Results.Ok(result.Value) : ToProblem(result.Error); result.IsSuccess ? Results.Ok(result.Value) : ToProblem(result.Error);
private static IResult ToProblem(Error error) private static IResult ToProblem(Error error)
{ {
@@ -1,7 +1,8 @@
using PnvPanel.Api.Common; using PnvPanel.Api.Common;
using PnvPanel.Application.Activation; using PnvPanel.Application.Activation;
using PnvPanel.Application.Admin.Activation; using PnvPanel.Application.Admin.Activation;
using PnvPanel.Application.Common.Messaging; using LiteCqrs;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Activation; using PnvPanel.Domain.Activation;
using PnvPanel.Infrastructure.Identity; using PnvPanel.Infrastructure.Identity;
@@ -12,54 +13,83 @@ public static class ActivationEndpoints
public static IEndpointRouteBuilder MapActivationEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapActivationEndpoints(this IEndpointRouteBuilder app)
{ {
var user = app.MapGroup("/api/activation").WithTags("Activation").RequireAuthorization(); var user = app.MapGroup("/api/activation").WithTags("Activation").RequireAuthorization();
user.MapGet("/status", GetStatus); user.MapGet("/status", GetStatus).Produces<ActivationStatusDto>();
user.MapPost("/request", RequestActivation); user.MapPost("/request", RequestActivation).Produces<ActivationRequestDto>();
var admin = app.MapGroup("/api/admin/activation-requests") var admin = app.MapGroup("/api/admin/activation-requests")
.WithTags("Admin.Activation") .WithTags("Admin.Activation")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", ListRequests); admin.MapGet("", ListRequests).Produces<PagedList<ActivationRequestAdminDto>>();
admin.MapPost("/{id:guid}/approve", Approve); admin.MapPost("/{id:guid}/approve", Approve).Produces(StatusCodes.Status204NoContent);
admin.MapPost("/{id:guid}/reject", Reject); admin.MapPost("/{id:guid}/reject", Reject).Produces(StatusCodes.Status204NoContent);
return app; return app;
} }
private static async Task<IResult> GetStatus(ISender sender, CancellationToken cancellationToken) private static async Task<IResult> GetStatus(
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new GetActivationStatusQuery(), cancellationToken); var result = await sender.Send(new GetActivationStatusQuery(), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> RequestActivation(RequestActivationCommand command, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> RequestActivation(
RequestActivationCommand command,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(command, cancellationToken); var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> ListRequests( private static async Task<IResult> ListRequests(
[AsParameters] ListActivationRequestsRequest request, ISender sender, CancellationToken cancellationToken) [AsParameters] ListActivationRequestsRequest request,
ISender sender,
CancellationToken cancellationToken
)
{ {
var query = new ListActivationRequestsQuery(request.StatusFilter, request.Page, request.PageSize); var query = new ListActivationRequestsQuery(
request.StatusFilter,
request.Page,
request.PageSize
);
var result = await sender.Send(query, cancellationToken); var result = await sender.Send(query, cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> Approve(Guid id, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> Approve(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new ApproveActivationCommand(id), cancellationToken); var result = await sender.Send(new ApproveActivationCommand(id), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> Reject( private static async Task<IResult> Reject(
Guid id, RejectActivationBody body, ISender sender, CancellationToken cancellationToken) Guid id,
RejectActivationBody body,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new RejectActivationCommand(id, body.Reason), cancellationToken); var result = await sender.Send(
new RejectActivationCommand(id, body.Reason),
cancellationToken
);
return result.ToHttpResult(); return result.ToHttpResult();
} }
} }
public sealed record ListActivationRequestsRequest(ActivationStatus? StatusFilter, int Page = 1, int PageSize = 20); public sealed record ListActivationRequestsRequest(
ActivationStatus? StatusFilter,
int Page = 1,
int PageSize = 20
);
public sealed record RejectActivationBody(string? Reason); public sealed record RejectActivationBody(string? Reason);
@@ -0,0 +1,83 @@
using PnvPanel.Api.Common;
using PnvPanel.Application.Admin.Apps;
using LiteCqrs;
using PnvPanel.Domain.Apps;
using PnvPanel.Infrastructure.Identity;
namespace PnvPanel.Api.Endpoints;
public static class AdminAppEndpoints
{
public static IEndpointRouteBuilder MapAdminAppEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/apps")
.WithTags("Admin.Apps")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", ListApps).Produces<IReadOnlyList<AdminAppDto>>();
admin.MapPost("", CreateApp).Produces<AdminAppDto>();
admin.MapPut("/{id:guid}", UpdateApp).Produces<AdminAppDto>();
admin.MapDelete("/{id:guid}", DeleteApp).Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> ListApps(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new ListAdminAppsQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateApp(
CreateAppCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateApp(
Guid id,
UpdateAppBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var command = new UpdateAppCommand(
id,
body.Name,
body.DownloadUrl,
body.OperatingSystem,
body.Description,
body.IconUrl,
body.SortOrder,
body.IsEnabled,
body.IsRecommended
);
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteApp(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteAppCommand(id), cancellationToken);
return result.ToHttpResult();
}
}
public sealed record UpdateAppBody(
string Name,
string DownloadUrl,
OsPlatform OperatingSystem,
string? Description,
string? IconUrl,
int SortOrder,
bool IsEnabled,
bool IsRecommended
);
@@ -0,0 +1,115 @@
using PnvPanel.Api.Common;
using PnvPanel.Application.Admin.Billing;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Billing;
using PnvPanel.Infrastructure.Identity;
namespace PnvPanel.Api.Endpoints;
public static class AdminBillingEndpoints
{
public static IEndpointRouteBuilder MapAdminBillingEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/billing")
.WithTags("Admin.Billing")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("/settings", GetSettings).Produces<BillingSettingsDto>();
admin.MapPut("/settings", UpdateSettings).Produces<BillingSettingsDto>();
admin
.MapGet("/requests", ListRequests)
.Produces<PagedList<AdminPaymentRequestDto>>();
admin
.MapPost("/requests/{id:guid}/confirm", ConfirmRequest)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/requests/{id:guid}/reject", RejectRequest)
.Produces(StatusCodes.Status204NoContent);
admin.MapPost("/gift", GrantGift).Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> GetSettings(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new GetBillingSettingsQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateSettings(
UpdateBillingSettingsCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ListRequests(
[AsParameters] ListPaymentRequestsRequest request,
ISender sender,
CancellationToken cancellationToken
)
{
var query = new ListPaymentRequestsQuery(
request.Status,
request.Kind,
request.Search,
request.Page,
request.PageSize
);
var result = await sender.Send(query, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ConfirmRequest(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ConfirmPaymentRequestCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> RejectRequest(
Guid id,
RejectPaymentRequestBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new RejectPaymentRequestCommand(id, body.Reason),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> GrantGift(
GrantGiftBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new GrantBillingGiftCommand(body.UserId, body.Days),
cancellationToken
);
return result.ToHttpResult();
}
}
public sealed record ListPaymentRequestsRequest(
PaymentRequestStatus? Status,
PaymentRequestKind? Kind,
string? Search,
int Page = 1,
int PageSize = 20
);
public sealed record RejectPaymentRequestBody(string? Reason);
public sealed record GrantGiftBody(Guid UserId, int Days);
@@ -0,0 +1,68 @@
using PnvPanel.Api.Common;
using PnvPanel.Application.Admin.Instructions;
using LiteCqrs;
using PnvPanel.Application.Instructions;
using PnvPanel.Infrastructure.Identity;
namespace PnvPanel.Api.Endpoints;
public static class AdminInstructionEndpoints
{
public static IEndpointRouteBuilder MapAdminInstructionEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/instructions")
.WithTags("Admin.Instructions")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapPut("/intro", UpdateIntro).Produces<InstructionIntroDto>();
admin.MapPost("/tabs", CreateTab).Produces<InstructionTabDto>();
admin.MapPut("/tabs/{id:guid}", UpdateTab).Produces<InstructionTabDto>();
admin.MapDelete("/tabs/{id:guid}", DeleteTab).Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> UpdateIntro(
UpdateInstructionIntroCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateTab(
CreateInstructionTabCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateTab(
Guid id,
UpdateInstructionTabBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var command = new UpdateInstructionTabCommand(id, body.Title, body.Body, body.SortOrder);
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteTab(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteInstructionTabCommand(id), cancellationToken);
return result.ToHttpResult();
}
}
public sealed record UpdateInstructionTabBody(string Title, string Body, int SortOrder);
@@ -0,0 +1,77 @@
using PnvPanel.Api.Common;
using PnvPanel.Application.Admin.Maintenance;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
using PnvPanel.Infrastructure.Identity;
namespace PnvPanel.Api.Endpoints;
public static class AdminMaintenanceEndpoints
{
public static IEndpointRouteBuilder MapAdminMaintenanceEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/maintenance")
.WithTags("Admin.Maintenance")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin
.MapDelete("/tickets/closed", DeleteClosedTickets)
.Produces<MaintenanceCleanupResponseDto>();
admin
.MapDelete("/audit-logs", DeleteOldAuditLogs)
.Produces<MaintenanceCleanupResponseDto>();
admin
.MapDelete("/apps/disabled", DeleteDisabledApps)
.Produces<MaintenanceCleanupResponseDto>();
admin.MapDelete("/factory-reset", FactoryReset).Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> DeleteClosedTickets(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteClosedTicketsCommand(), cancellationToken);
return ToResponse(result);
}
private static async Task<IResult> DeleteOldAuditLogs(
int olderThanDays,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new DeleteOldAuditLogsCommand(olderThanDays),
cancellationToken
);
return ToResponse(result);
}
private static async Task<IResult> DeleteDisabledApps(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteDisabledAppsCommand(), cancellationToken);
return ToResponse(result);
}
private static async Task<IResult> FactoryReset(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new FactoryResetCommand(), cancellationToken);
return result.ToHttpResult();
}
private static IResult ToResponse(Result<int> result) =>
result.IsSuccess
? Results.Ok(new MaintenanceCleanupResponseDto(result.Value))
: result.ToHttpResult();
}
public sealed record MaintenanceCleanupResponseDto(int DeletedCount);
@@ -0,0 +1,70 @@
using PnvPanel.Api.Common;
using PnvPanel.Application.Admin.News;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.News;
using PnvPanel.Infrastructure.Identity;
namespace PnvPanel.Api.Endpoints;
public static class AdminNewsEndpoints
{
public static IEndpointRouteBuilder MapAdminNewsEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/news")
.WithTags("Admin.News")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", ListAdminNews).Produces<PagedList<NewsPostDto>>();
admin.MapPost("", CreatePost).Produces<NewsPostDto>();
admin.MapPut("/{id:guid}", UpdatePost).Produces<NewsPostDto>();
admin.MapDelete("/{id:guid}", DeletePost).Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> ListAdminNews(
int page,
int pageSize,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListAdminNewsQuery(page, pageSize), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreatePost(
CreatePostCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UpdatePost(
Guid id,
UpdatePostBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var command = new UpdatePostCommand(id, body.Title, body.Body);
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DeletePost(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeletePostCommand(id), cancellationToken);
return result.ToHttpResult();
}
}
public sealed record UpdatePostBody(string Title, string Body);
@@ -0,0 +1,69 @@
using PnvPanel.Api.Common;
using PnvPanel.Application.Admin.Plans;
using LiteCqrs;
using PnvPanel.Infrastructure.Identity;
namespace PnvPanel.Api.Endpoints;
public static class AdminPlanEndpoints
{
public static IEndpointRouteBuilder MapAdminPlanEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/plans")
.WithTags("Admin.Plans")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", ListPlans).Produces<IReadOnlyList<AdminPlanDto>>();
admin.MapPost("", CreatePlan).Produces<AdminPlanDto>();
admin.MapPut("/{id:guid}", UpdatePlan).Produces<AdminPlanDto>();
admin.MapDelete("/{id:guid}", DeletePlan).Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> ListPlans(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new ListAdminPlansQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreatePlan(
CreatePlanCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UpdatePlan(
Guid id,
UpdatePlanBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var command = new UpdatePlanCommand(
id,
body.Name,
body.ConfigCount,
body.SortOrder,
body.IsEnabled
);
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DeletePlan(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeletePlanCommand(id), cancellationToken);
return result.ToHttpResult();
}
}
public sealed record UpdatePlanBody(string Name, int ConfigCount, int SortOrder, bool IsEnabled);
@@ -0,0 +1,38 @@
using PnvPanel.Api.Common;
using PnvPanel.Application.Admin.Pricing;
using PnvPanel.Application.Common.Interfaces;
using LiteCqrs;
using PnvPanel.Infrastructure.Identity;
namespace PnvPanel.Api.Endpoints;
public static class AdminPricingEndpoints
{
public static IEndpointRouteBuilder MapAdminPricingEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/pricing")
.WithTags("Admin.Pricing")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", GetPricing).Produces<PricingSettingsDto>();
admin.MapPut("", UpdatePricing).Produces<PricingSettingsDto>();
return app;
}
private static async Task<IResult> GetPricing(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new GetPricingSettingsQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UpdatePricing(
UpdatePricingSettingsCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
}
@@ -0,0 +1,51 @@
using PnvPanel.Api.Common;
using PnvPanel.Application.Admin.Audit;
using PnvPanel.Application.Admin.Stats;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Audit;
using PnvPanel.Infrastructure.Identity;
namespace PnvPanel.Api.Endpoints;
public static class AdminStatsEndpoints
{
public static IEndpointRouteBuilder MapAdminStatsEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin")
.WithTags("Admin.Stats")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("/stats", GetStats).Produces<StatsDto>();
admin.MapGet("/audit", GetAudit).Produces<PagedList<AuditLogDto>>();
return app;
}
private static async Task<IResult> GetStats(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new GetStatsQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetAudit(
int page,
int pageSize,
AuditSource? source,
string? targetType,
string? action,
ISender sender,
CancellationToken cancellationToken
)
{
var query = new ListAuditLogsQuery(
page <= 0 ? 1 : page,
pageSize <= 0 ? 50 : pageSize,
source,
targetType,
action
);
var result = await sender.Send(query, cancellationToken);
return result.ToHttpResult();
}
}
@@ -0,0 +1,124 @@
using Microsoft.AspNetCore.Mvc;
using PnvPanel.Api.Common;
using PnvPanel.Application.Admin.Support;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.Support;
using PnvPanel.Application.Support.AddComment;
using PnvPanel.Infrastructure.Identity;
namespace PnvPanel.Api.Endpoints;
public static class AdminSupportEndpoints
{
public static IEndpointRouteBuilder MapAdminSupportEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/support")
.WithTags("Admin.Support")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("/tickets", ListTickets).Produces<PagedList<TicketSummaryDto>>();
admin.MapGet("/tickets/{id:guid}", GetTicket).Produces<TicketDetailDto>();
admin
.MapPost("/tickets/{id:guid}/comments", AddComment)
.DisableAntiforgery()
.Produces<TicketCommentDto>();
admin
.MapPost("/tickets/{id:guid}/resolve", Resolve)
.Produces(StatusCodes.Status204NoContent);
admin.MapPost("/tickets/{id:guid}/close", Close).Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/tickets/{id:guid}/approve-extension", ApproveExtensionRequest)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/tickets/{id:guid}/reject-extension", RejectExtensionRequest)
.Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> ListTickets(
[AsParameters] ListTicketsRequest request,
ISender sender,
CancellationToken cancellationToken
)
{
var query = new ListAllTicketsQuery(
request.Type,
request.Status,
request.Page,
request.PageSize
);
var result = await sender.Send(query, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetTicket(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetTicketAdminQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> AddComment(
Guid id,
[FromForm] string body,
IFormFileCollection? files,
ISender sender,
CancellationToken cancellationToken
)
{
var command = new AddTicketCommentCommand(id, body, SupportEndpoints.ToUploads(files));
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> Resolve(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ResolveTicketCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> Close(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new CloseTicketCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ApproveExtensionRequest(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ApproveExtensionRequestCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> RejectExtensionRequest(
Guid id,
RejectExtensionRequestBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new RejectExtensionRequestCommand(id, body.Reason),
cancellationToken
);
return result.ToHttpResult();
}
}
public sealed record RejectExtensionRequestBody(string? Reason);
@@ -0,0 +1,157 @@
using PnvPanel.Api.Common;
using PnvPanel.Application.Admin.Configs;
using PnvPanel.Application.Admin.Users;
using PnvPanel.Application.Common.Interfaces;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.Configs;
using PnvPanel.Domain.Configs;
using PnvPanel.Domain.Inbounds;
using PnvPanel.Infrastructure.Identity;
namespace PnvPanel.Api.Endpoints;
public static class AdminUserEndpoints
{
public static IEndpointRouteBuilder MapAdminUserEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin")
.WithTags("Admin.Users")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("/users", ListUsers).Produces<PagedList<UserSummaryDto>>();
admin
.MapPatch("/users/{id:guid}/block", BlockUser)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPatch("/users/{id:guid}/unblock", UnblockUser)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/users/{id:guid}/reset-password", ResetPassword)
.Produces(StatusCodes.Status204NoContent);
admin.MapDelete("/users/{id:guid}", DeleteUser).Produces(StatusCodes.Status204NoContent);
admin
.MapGet("/users/{id:guid}/configs", GetUserConfigs)
.Produces<IReadOnlyList<VpnConfigDto>>();
admin.MapGet("/configs", ListAllConfigs).Produces<PagedList<AdminVpnConfigDto>>();
admin
.MapDelete("/configs/{id:guid}", ForceRevokeConfig)
.Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> ListUsers(
int page,
int pageSize,
string? search,
Guid? roleId,
bool? isActivated,
bool? isBlocked,
bool? billingExpired,
ISender sender,
CancellationToken cancellationToken
)
{
var query = new ListUsersQuery(
page <= 0 ? 1 : page,
pageSize <= 0 ? 20 : pageSize,
search,
roleId,
isActivated,
isBlocked,
billingExpired
);
var result = await sender.Send(query, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> BlockUser(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new BlockUserCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UnblockUser(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new UnblockUserCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ResetPassword(
Guid id,
ResetPasswordBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ResetUserPasswordCommand(id, body.NewPassword),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteUser(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteUserCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetUserConfigs(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetUserConfigsQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ListAllConfigs(
int page,
int pageSize,
string? search,
ConfigStatus? status,
VpnProtocol? protocol,
Guid? nodeId,
ISender sender,
CancellationToken cancellationToken
)
{
var query = new ListAllConfigsQuery(
page <= 0 ? 1 : page,
pageSize <= 0 ? 20 : pageSize,
search,
status,
protocol,
nodeId
);
var result = await sender.Send(query, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ForceRevokeConfig(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ForceRevokeConfigCommand(id), cancellationToken);
return result.ToHttpResult();
}
}
public sealed record ResetPasswordBody(string NewPassword);
@@ -1,6 +1,7 @@
using PnvPanel.Api.Common; using PnvPanel.Api.Common;
using PnvPanel.Application.Apps; using PnvPanel.Application.Apps;
using PnvPanel.Application.Common.Messaging; using LiteCqrs;
using PnvPanel.Domain.Apps;
namespace PnvPanel.Api.Endpoints; namespace PnvPanel.Api.Endpoints;
@@ -8,7 +9,10 @@ public static class AppEndpoints
{ {
public static IEndpointRouteBuilder MapAppEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapAppEndpoints(this IEndpointRouteBuilder app)
{ {
app.MapGet("/api/apps", ListApps).WithTags("Apps").RequireAuthorization(); app.MapGet("/api/apps", ListApps)
.WithTags("Apps")
.RequireAuthorization()
.Produces<IReadOnlyDictionary<OsPlatform, IReadOnlyList<ClientAppDto>>>();
return app; return app;
} }
@@ -1,13 +1,14 @@
using PnvPanel.Api.Common; using PnvPanel.Api.Common;
using PnvPanel.Application.Auth; using PnvPanel.Application.Auth;
using PnvPanel.Application.Auth.ChangePassword; using PnvPanel.Application.Auth.ChangePassword;
using PnvPanel.Application.Auth.ChangeUserName;
using PnvPanel.Application.Auth.DeleteMyAccount; using PnvPanel.Application.Auth.DeleteMyAccount;
using PnvPanel.Application.Auth.Login; using PnvPanel.Application.Auth.Login;
using PnvPanel.Application.Auth.Logout; using PnvPanel.Application.Auth.Logout;
using PnvPanel.Application.Auth.Me; using PnvPanel.Application.Auth.Me;
using PnvPanel.Application.Auth.Refresh; using PnvPanel.Application.Auth.Refresh;
using PnvPanel.Application.Auth.Register; using PnvPanel.Application.Auth.Register;
using PnvPanel.Application.Common.Messaging; using LiteCqrs;
namespace PnvPanel.Api.Endpoints; namespace PnvPanel.Api.Endpoints;
@@ -21,59 +22,125 @@ public static class AuthEndpoints
.WithTags("Auth") .WithTags("Auth")
.RequireRateLimiting(RateLimiting.AuthPolicy); .RequireRateLimiting(RateLimiting.AuthPolicy);
group.MapPost("/register", Register); group.MapPost("/register", Register).Produces<RegisterResult>();
group.MapPost("/login", Login); group.MapPost("/login", Login).Produces<AuthResponseDto>();
group.MapPost("/refresh", Refresh); group.MapPost("/refresh", Refresh).Produces<AuthResponseDto>();
group.MapPost("/logout", Logout).RequireAuthorization(); group
group.MapPost("/change-password", ChangePassword).RequireAuthorization(); .MapPost("/logout", Logout)
group.MapGet("/me", Me).RequireAuthorization(); .RequireAuthorization()
group.MapDelete("/me", DeleteMe).RequireAuthorization(); .Produces(StatusCodes.Status204NoContent);
group
.MapPost("/change-password", ChangePassword)
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent);
group
.MapPost("/change-username", ChangeUserName)
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent);
group.MapGet("/me", Me).RequireAuthorization().Produces<CurrentUserDto>();
group
.MapDelete("/me", DeleteMe)
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent);
return app; return app;
} }
private static async Task<IResult> Register(RegisterCommand command, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> Register(
RegisterCommand command,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(command, cancellationToken); var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> Login(LoginCommand command, ISender sender, HttpResponse response, CancellationToken cancellationToken) private static async Task<IResult> Login(
LoginCommand command,
ISender sender,
HttpRequest request,
HttpResponse response,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(command, cancellationToken); var result = await sender.Send(command, cancellationToken);
if (!result.IsSuccess) if (!result.IsSuccess)
return result.ToHttpResult(); return result.ToHttpResult();
SetRefreshCookie(response, result.Value.RefreshToken, result.Value.RefreshTokenExpiresAt); SetRefreshCookie(
request,
response,
result.Value.RefreshToken,
result.Value.RefreshTokenExpiresAt
);
return Results.Ok(ToLoginResponse(result.Value)); return Results.Ok(ToLoginResponse(result.Value));
} }
private static async Task<IResult> Refresh(HttpRequest request, HttpResponse response, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> Refresh(
HttpRequest request,
HttpResponse response,
ISender sender,
CancellationToken cancellationToken
)
{ {
if (!request.Cookies.TryGetValue(RefreshCookieName, out var rawToken) || string.IsNullOrEmpty(rawToken)) if (
!request.Cookies.TryGetValue(RefreshCookieName, out var rawToken)
|| string.IsNullOrEmpty(rawToken)
)
return Results.Unauthorized(); return Results.Unauthorized();
var result = await sender.Send(new RefreshCommand(rawToken), cancellationToken); var result = await sender.Send(new RefreshCommand(rawToken), cancellationToken);
if (!result.IsSuccess) if (!result.IsSuccess)
{ {
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions()); response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request));
return result.ToHttpResult(); return result.ToHttpResult();
} }
SetRefreshCookie(response, result.Value.RefreshToken, result.Value.RefreshTokenExpiresAt); SetRefreshCookie(
request,
response,
result.Value.RefreshToken,
result.Value.RefreshTokenExpiresAt
);
return Results.Ok(ToLoginResponse(result.Value)); return Results.Ok(ToLoginResponse(result.Value));
} }
private static async Task<IResult> Logout(HttpRequest request, HttpResponse response, ISender sender, CancellationToken cancellationToken) internal static AuthResponseDto ToLoginResponse(AuthResult auth) =>
new(auth.AccessToken, auth.AccessTokenExpiresAt, auth.User);
private static async Task<IResult> Logout(
HttpRequest request,
HttpResponse response,
ISender sender,
CancellationToken cancellationToken
)
{ {
if (request.Cookies.TryGetValue(RefreshCookieName, out var rawToken) && !string.IsNullOrEmpty(rawToken)) if (
request.Cookies.TryGetValue(RefreshCookieName, out var rawToken)
&& !string.IsNullOrEmpty(rawToken)
)
await sender.Send(new LogoutCommand(rawToken), cancellationToken); await sender.Send(new LogoutCommand(rawToken), cancellationToken);
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions()); response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request));
return Results.NoContent(); return Results.NoContent();
} }
private static async Task<IResult> ChangePassword(ChangePasswordCommand command, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> ChangePassword(
ChangePasswordCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ChangeUserName(
ChangeUserNameCommand command,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(command, cancellationToken); var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
@@ -85,32 +152,44 @@ public static class AuthEndpoints
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> DeleteMe(HttpResponse response, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> DeleteMe(
HttpRequest request,
HttpResponse response,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new DeleteMyAccountCommand(), cancellationToken); var result = await sender.Send(new DeleteMyAccountCommand(), cancellationToken);
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions()); response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request));
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static object ToLoginResponse(AuthResult auth) => new private static void SetRefreshCookie(
HttpRequest request,
HttpResponse response,
string rawToken,
DateTimeOffset expiresAt
)
{ {
accessToken = auth.AccessToken, var options = BuildCookieOptions(request);
expiresAt = auth.AccessTokenExpiresAt,
user = auth.User,
};
private static void SetRefreshCookie(HttpResponse response, string rawToken, DateTimeOffset expiresAt)
{
var options = BuildCookieOptions();
options.Expires = expiresAt; options.Expires = expiresAt;
response.Cookies.Append(RefreshCookieName, rawToken, options); response.Cookies.Append(RefreshCookieName, rawToken, options);
} }
private static CookieOptions BuildCookieOptions() => new() // Secure = IsHttps запроса (учитывает ForwardedHeaders за внешним TLS-прокси, см. CLAUDE.md) —
// иначе браузер/HttpClient не пришлёт cookie обратно на plain-http (локальный dev, TestServer).
private static CookieOptions BuildCookieOptions(HttpRequest request) =>
new()
{ {
HttpOnly = true, HttpOnly = true,
Secure = true, Secure = request.IsHttps,
SameSite = SameSiteMode.Strict, SameSite = SameSiteMode.Strict,
Path = "/api/auth", Path = "/api/auth",
}; };
} }
public sealed record AuthResponseDto(
string AccessToken,
DateTimeOffset ExpiresAt,
CurrentUserDto User
);
@@ -0,0 +1,84 @@
using PnvPanel.Api.Common;
using PnvPanel.Application.Billing;
using PnvPanel.Application.Billing.CancelPaymentRequest;
using PnvPanel.Application.Billing.CreatePaymentRequest;
using PnvPanel.Application.Billing.GetMyBillingStatus;
using PnvPanel.Application.Billing.MarkPaymentSent;
using PnvPanel.Application.Billing.SendRequisitesToTelegram;
using LiteCqrs;
using PnvPanel.Domain.Billing;
namespace PnvPanel.Api.Endpoints;
public static class BillingEndpoints
{
public static IEndpointRouteBuilder MapBillingEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/billing").WithTags("Billing").RequireAuthorization();
group.MapGet("/status", GetStatus).Produces<BillingStatusDto>();
group.MapPost("/requests", CreateRequest).Produces<PaymentRequestDto>();
group
.MapPost("/requests/{id:guid}/cancel", CancelRequest)
.Produces(StatusCodes.Status204NoContent);
group
.MapPost("/requests/{id:guid}/mark-paid", MarkPaid)
.Produces(StatusCodes.Status204NoContent);
group
.MapPost("/requests/{id:guid}/send-requisites", SendRequisites)
.Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> GetStatus(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new GetMyBillingStatusQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateRequest(
CreatePaymentRequestBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new CreatePaymentRequestCommand(body.Period),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> CancelRequest(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new CancelPaymentRequestCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> MarkPaid(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new MarkPaymentSentCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> SendRequisites(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new SendRequisitesToTelegramCommand(id), cancellationToken);
return result.ToHttpResult();
}
}
public sealed record CreatePaymentRequestBody(PaymentPeriod Period);
@@ -1,9 +1,11 @@
using PnvPanel.Api.Common; using PnvPanel.Api.Common;
using PnvPanel.Application.Common.Messaging; using LiteCqrs;
using PnvPanel.Application.Configs;
using PnvPanel.Application.Configs.Create; using PnvPanel.Application.Configs.Create;
using PnvPanel.Application.Configs.Edit; using PnvPanel.Application.Configs.Edit;
using PnvPanel.Application.Configs.GetConfigLink; using PnvPanel.Application.Configs.GetConfigLink;
using PnvPanel.Application.Configs.GetMyConfigs; using PnvPanel.Application.Configs.GetMyConfigs;
using PnvPanel.Application.Configs.GetMySubscription;
using PnvPanel.Application.Configs.ListAvailableInbounds; using PnvPanel.Application.Configs.ListAvailableInbounds;
using PnvPanel.Application.Configs.Revoke; using PnvPanel.Application.Configs.Revoke;
using PnvPanel.Application.Configs.Rotate; using PnvPanel.Application.Configs.Rotate;
@@ -16,66 +18,121 @@ public static class ConfigEndpoints
{ {
var group = app.MapGroup("/api").WithTags("Configs").RequireAuthorization(); var group = app.MapGroup("/api").WithTags("Configs").RequireAuthorization();
group.MapGet("/inbounds/available", ListAvailableInbounds); group
group.MapGet("/configs", GetMyConfigs); .MapGet("/inbounds/available", ListAvailableInbounds)
group.MapPost("/configs", CreateConfig); .Produces<IReadOnlyList<AvailableInboundDto>>();
group.MapPatch("/configs/{id:guid}", EditConfig); group.MapGet("/configs", GetMyConfigs).Produces<GetMyConfigsResult>();
group.MapPost("/configs/{id:guid}/rotate", RotateConfig); group.MapPost("/configs", CreateConfig).Produces<VpnConfigDto>();
group.MapDelete("/configs/{id:guid}", RevokeConfig); group.MapPatch("/configs/{id:guid}", EditConfig).Produces<VpnConfigDto>();
group.MapGet("/configs/{id:guid}/link", GetConfigLink); group.MapPost("/configs/{id:guid}/rotate", RotateConfig).Produces<VpnConfigDto>();
group
.MapDelete("/configs/{id:guid}", RevokeConfig)
.Produces(StatusCodes.Status204NoContent);
group.MapGet("/configs/{id:guid}/link", GetConfigLink).Produces<ConfigLinkResponseDto>();
group.MapGet("/subscription", GetMySubscription).Produces<MySubscriptionResponseDto>();
return app; return app;
} }
private static async Task<IResult> ListAvailableInbounds(ISender sender, CancellationToken cancellationToken) private static async Task<IResult> ListAvailableInbounds(
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new ListAvailableInboundsQuery(), cancellationToken); var result = await sender.Send(new ListAvailableInboundsQuery(), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> GetMyConfigs(ISender sender, CancellationToken cancellationToken) private static async Task<IResult> GetMyConfigs(
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new GetMyConfigsQuery(), cancellationToken); var result = await sender.Send(new GetMyConfigsQuery(), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> CreateConfig(CreateConfigBody body, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> CreateConfig(
CreateConfigBody body,
ISender sender,
CancellationToken cancellationToken
)
{ {
var command = new CreateVpnConfigCommand(body.InboundId, body.Label, body.DeviceLimit); var command = new CreateVpnConfigCommand(body.InboundId, body.Label);
var result = await sender.Send(command, cancellationToken); var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> EditConfig(Guid id, EditConfigBody body, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> EditConfig(
Guid id,
EditConfigBody body,
ISender sender,
CancellationToken cancellationToken
)
{ {
var command = new EditVpnConfigCommand(id, body.Label, body.DeviceLimit); var command = new EditVpnConfigCommand(id, body.Label);
var result = await sender.Send(command, cancellationToken); var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> RotateConfig(Guid id, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> RotateConfig(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new RotateVpnConfigCommand(id), cancellationToken); var result = await sender.Send(new RotateVpnConfigCommand(id), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> RevokeConfig(Guid id, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> RevokeConfig(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new RevokeVpnConfigCommand(id), cancellationToken); var result = await sender.Send(new RevokeVpnConfigCommand(id), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> GetConfigLink(Guid id, HttpRequest request, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> GetConfigLink(
Guid id,
HttpRequest request,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new GetConfigLinkQuery(id), cancellationToken); var result = await sender.Send(new GetConfigLinkQuery(id), cancellationToken);
if (!result.IsSuccess) if (!result.IsSuccess)
return result.ToHttpResult(); return result.ToHttpResult();
var subscriptionUrl = $"{request.Scheme}://{request.Host}/sub/{result.Value.SubscriptionToken}"; var subscriptionUrl =
return Results.Ok(new { connectionString = result.Value.ConnectionString, subscriptionUrl }); $"{request.Scheme}://{request.Host}/sub/{result.Value.SubscriptionToken}";
return Results.Ok(
new ConfigLinkResponseDto(result.Value.ConnectionString, subscriptionUrl)
);
}
private static async Task<IResult> GetMySubscription(
HttpRequest request,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetMySubscriptionQuery(), cancellationToken);
if (!result.IsSuccess)
return result.ToHttpResult();
var subscriptionUrl =
$"{request.Scheme}://{request.Host}/sub/{result.Value.SubscriptionToken}";
return Results.Ok(new MySubscriptionResponseDto(subscriptionUrl));
} }
} }
public sealed record CreateConfigBody(Guid InboundId, string? Label, int? DeviceLimit); public sealed record CreateConfigBody(Guid InboundId, string? Label);
public sealed record EditConfigBody(string? Label, int? DeviceLimit); public sealed record EditConfigBody(string? Label);
public sealed record ConfigLinkResponseDto(string ConnectionString, string SubscriptionUrl);
public sealed record MySubscriptionResponseDto(string SubscriptionUrl);
@@ -1,6 +1,6 @@
using PnvPanel.Api.Common; using PnvPanel.Api.Common;
using PnvPanel.Application.Admin.Inbounds; using PnvPanel.Application.Admin.Inbounds;
using PnvPanel.Application.Common.Messaging; using LiteCqrs;
using PnvPanel.Infrastructure.Identity; using PnvPanel.Infrastructure.Identity;
namespace PnvPanel.Api.Endpoints; namespace PnvPanel.Api.Endpoints;
@@ -13,26 +13,53 @@ public static class InboundEndpoints
.WithTags("Admin.Inbounds") .WithTags("Admin.Inbounds")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", ListInbounds); admin.MapGet("", ListInbounds).Produces<IReadOnlyList<InboundDto>>();
admin.MapPut("/{id:guid}/publish", PublishInbound); admin.MapPut("/{id:guid}/publish", PublishInbound).Produces<InboundDto>();
admin.MapDelete("/{id:guid}", DeleteInbound);
return app; return app;
} }
private static async Task<IResult> ListInbounds(Guid? nodeId, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> ListInbounds(
Guid? nodeId,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new ListInboundsQuery(nodeId), cancellationToken); var result = await sender.Send(new ListInboundsQuery(nodeId), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> PublishInbound( private static async Task<IResult> PublishInbound(
Guid id, PublishInboundBody body, ISender sender, CancellationToken cancellationToken) Guid id,
PublishInboundBody body,
ISender sender,
CancellationToken cancellationToken
)
{ {
var command = new PublishInboundCommand( var command = new PublishInboundCommand(
id, body.IsPublished, body.DisplayName, body.AllowedRoleIds ?? [], body.MaxClients); id,
body.IsPublished,
body.DisplayName,
body.AllowedRoleIds ?? []
);
var result = await sender.Send(command, cancellationToken); var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> DeleteInbound(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteInboundCommand(id), cancellationToken);
return result.ToHttpResult();
}
} }
public sealed record PublishInboundBody(bool IsPublished, string? DisplayName, IReadOnlyList<Guid>? AllowedRoleIds, int? MaxClients); public sealed record PublishInboundBody(
bool IsPublished,
string? DisplayName,
IReadOnlyList<Guid>? AllowedRoleIds
);
@@ -0,0 +1,32 @@
using PnvPanel.Api.Common;
using LiteCqrs;
using PnvPanel.Application.Instructions;
namespace PnvPanel.Api.Endpoints;
public static class InstructionEndpoints
{
public static IEndpointRouteBuilder MapInstructionEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/instructions")
.WithTags("Instructions")
.RequireAuthorization();
group.MapGet("/intro", GetIntro).Produces<InstructionIntroDto>();
group.MapGet("/tabs", ListTabs).Produces<IReadOnlyList<InstructionTabDto>>();
return app;
}
private static async Task<IResult> GetIntro(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new GetInstructionIntroQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ListTabs(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new ListInstructionTabsQuery(), cancellationToken);
return result.ToHttpResult();
}
}
@@ -0,0 +1,67 @@
using LiteCqrs;
using PnvPanel.Api.Common;
using PnvPanel.Application.Admin.Media;
using PnvPanel.Application.Media;
using PnvPanel.Application.Media.GetImage;
using PnvPanel.Infrastructure.Identity;
namespace PnvPanel.Api.Endpoints;
public static class MediaEndpoints
{
/// <summary>Год: содержимое картинки по Id неизменно (перезалив даёт новый Id).</summary>
private const string ImageCacheControl = "public, max-age=31536000, immutable";
public static IEndpointRouteBuilder MapMediaEndpoints(this IEndpointRouteBuilder app)
{
// Отдача — анонимная: markdown рендерится обычным <img>, который не шлёт Authorization.
// Защита — непрозрачный Guid в ссылке; в картинках инструкций/новостей нет персональных данных.
var group = app.MapGroup("/api/media").WithTags("Media");
group.MapGet("/images/{id:guid}", GetImage);
var admin = app.MapGroup("/api/admin/media")
.WithTags("Admin.Media")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapPost("/images", UploadImage).DisableAntiforgery().Produces<MediaImageDto>();
return app;
}
private static async Task<IResult> GetImage(
Guid id,
HttpContext httpContext,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetMediaImageQuery(id), cancellationToken);
if (!result.IsSuccess)
return result.ToHttpResult();
httpContext.Response.Headers.CacheControl = ImageCacheControl;
return Results.File(result.Value.Content, result.Value.ContentType);
}
private static async Task<IResult> UploadImage(
IFormFile? file,
ISender sender,
CancellationToken cancellationToken
)
{
if (file is null)
return Results.Problem(
title: MediaErrors.EmptyImage.Message,
statusCode: StatusCodes.Status400BadRequest
);
var upload = new MediaImageUpload(
file.OpenReadStream(),
file.FileName,
file.ContentType,
file.Length
);
var result = await sender.Send(new UploadMediaImageCommand(upload), cancellationToken);
return result.ToHttpResult();
}
}
@@ -0,0 +1,29 @@
using PnvPanel.Api.Common;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.News;
namespace PnvPanel.Api.Endpoints;
public static class NewsEndpoints
{
public static IEndpointRouteBuilder MapNewsEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/news", ListNews)
.WithTags("News")
.RequireAuthorization()
.Produces<PagedList<NewsPostDto>>();
return app;
}
private static async Task<IResult> ListNews(
int page,
int pageSize,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListNewsQuery(page, pageSize), cancellationToken);
return result.ToHttpResult();
}
}
@@ -1,6 +1,6 @@
using PnvPanel.Api.Common; using PnvPanel.Api.Common;
using PnvPanel.Application.Admin.Nodes; using PnvPanel.Application.Admin.Nodes;
using PnvPanel.Application.Common.Messaging; using LiteCqrs;
using PnvPanel.Infrastructure.Identity; using PnvPanel.Infrastructure.Identity;
namespace PnvPanel.Api.Endpoints; namespace PnvPanel.Api.Endpoints;
@@ -13,52 +13,93 @@ public static class NodeEndpoints
.WithTags("Admin.Nodes") .WithTags("Admin.Nodes")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", ListNodes); admin.MapGet("", ListNodes).Produces<IReadOnlyList<NodeDto>>();
admin.MapPost("", RegisterNode); admin.MapPost("", RegisterNode).Produces<NodeDto>();
admin.MapPut("/{id:guid}", UpdateNode); admin.MapPut("/{id:guid}", UpdateNode).Produces<NodeDto>();
admin.MapDelete("/{id:guid}", DeleteNode); admin.MapDelete("/{id:guid}", DeleteNode).Produces(StatusCodes.Status204NoContent);
admin.MapPost("/{id:guid}/sync", SyncNode); admin.MapPost("/{id:guid}/sync", SyncNode).Produces<SyncNodeResultDto>();
admin.MapPost("/{id:guid}/probe", ProbeNode); admin.MapPost("/{id:guid}/probe", ProbeNode).Produces<NodeProbeResultDto>();
return app; return app;
} }
private static async Task<IResult> ListNodes(ISender sender, CancellationToken cancellationToken) private static async Task<IResult> ListNodes(
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new ListNodesQuery(), cancellationToken); var result = await sender.Send(new ListNodesQuery(), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> RegisterNode(RegisterNodeCommand command, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> RegisterNode(
RegisterNodeCommand command,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(command, cancellationToken); var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> UpdateNode(Guid id, UpdateNodeBody body, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> UpdateNode(
Guid id,
UpdateNodeBody body,
ISender sender,
CancellationToken cancellationToken
)
{ {
var command = new UpdateNodeCommand(id, body.Name, body.Location, body.IsEnabled, body.Username, body.Password); var command = new UpdateNodeCommand(
id,
body.Name,
body.BaseAddress,
body.Location,
body.IsEnabled,
body.NotifyOnStatusChange,
body.Username,
body.Password
);
var result = await sender.Send(command, cancellationToken); var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> DeleteNode(Guid id, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> DeleteNode(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new DeleteNodeCommand(id), cancellationToken); var result = await sender.Send(new DeleteNodeCommand(id), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> SyncNode(Guid id, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> SyncNode(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new SyncNodeCommand(id), cancellationToken); var result = await sender.Send(new SyncNodeCommand(id), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> ProbeNode(Guid id, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> ProbeNode(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new ProbeNodeCommand(id), cancellationToken); var result = await sender.Send(new ProbeNodeCommand(id), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
} }
public sealed record UpdateNodeBody(string Name, string? Location, bool IsEnabled, string? Username, string? Password); public sealed record UpdateNodeBody(
string Name,
string BaseAddress,
string? Location,
bool IsEnabled,
bool NotifyOnStatusChange,
string? Username,
string? Password
);
@@ -0,0 +1,55 @@
using PnvPanel.Api.Common;
using LiteCqrs;
using PnvPanel.Application.Plans;
namespace PnvPanel.Api.Endpoints;
public static class PlanEndpoints
{
public static IEndpointRouteBuilder MapPlanEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/plans").WithTags("Plans").RequireAuthorization();
group.MapGet("", ListPlans).Produces<IReadOnlyList<PlanDto>>();
group.MapGet("/status", GetMyPlanStatus).Produces<MyPlanStatusDto>();
group.MapPost("/change", ChangePlan).Produces<ChangePlanResultDto>();
return app;
}
private static async Task<IResult> ListPlans(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new ListPlansQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetMyPlanStatus(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetMyPlanStatusQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ChangePlan(
ChangePlanBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var command = new ChangePlanCommand(
body.PlanId,
body.CustomConfigCount,
body.ConfigIdsToRevoke ?? []
);
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
}
public sealed record ChangePlanBody(
Guid? PlanId,
int? CustomConfigCount,
IReadOnlyList<Guid>? ConfigIdsToRevoke
);
@@ -1,7 +1,8 @@
using PnvPanel.Api.Common; using PnvPanel.Api.Common;
using PnvPanel.Application.Admin.Roles; using PnvPanel.Application.Admin.Roles;
using PnvPanel.Application.Admin.Users; using PnvPanel.Application.Admin.Users;
using PnvPanel.Application.Common.Messaging; using PnvPanel.Application.Common.Interfaces;
using LiteCqrs;
using PnvPanel.Infrastructure.Identity; using PnvPanel.Infrastructure.Identity;
namespace PnvPanel.Api.Endpoints; namespace PnvPanel.Api.Endpoints;
@@ -14,46 +15,94 @@ public static class RoleEndpoints
.WithTags("Admin.Roles") .WithTags("Admin.Roles")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("/roles", ListRoles); admin.MapGet("/roles", ListRoles).Produces<IReadOnlyList<RoleDto>>();
admin.MapPost("/roles", CreateRole); admin.MapPost("/roles", CreateRole).Produces<RoleDto>();
admin.MapPut("/roles/{id:guid}", UpdateRole); admin.MapPut("/roles/{id:guid}", UpdateRole).Produces<RoleDto>();
admin.MapDelete("/roles/{id:guid}", DeleteRole); admin.MapDelete("/roles/{id:guid}", DeleteRole).Produces(StatusCodes.Status204NoContent);
admin.MapPatch("/users/{id:guid}/role", ChangeUserRole); admin
.MapPatch("/users/{id:guid}/role", ChangeUserRole)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPatch("/users/{id:guid}/plan", SetUserPlan)
.Produces(StatusCodes.Status204NoContent);
return app; return app;
} }
private static async Task<IResult> ListRoles(ISender sender, CancellationToken cancellationToken) private static async Task<IResult> ListRoles(
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new ListRolesQuery(), cancellationToken); var result = await sender.Send(new ListRolesQuery(), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> CreateRole(CreateRoleCommand command, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> CreateRole(
CreateRoleCommand command,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(command, cancellationToken); var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> UpdateRole(Guid id, UpdateRoleBody body, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> UpdateRole(
Guid id,
UpdateRoleBody body,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new UpdateRoleCommand(id, body.MaxConfigs), cancellationToken); var result = await sender.Send(
new UpdateRoleCommand(id, body.MaxIpLimit, body.BillingEnabled),
cancellationToken
);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> DeleteRole(Guid id, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> DeleteRole(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new DeleteRoleCommand(id), cancellationToken); var result = await sender.Send(new DeleteRoleCommand(id), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> ChangeUserRole(Guid id, ChangeUserRoleBody body, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> ChangeUserRole(
Guid id,
ChangeUserRoleBody body,
ISender sender,
CancellationToken cancellationToken
)
{ {
var result = await sender.Send(new ChangeUserRoleCommand(id, body.RoleId), cancellationToken); var result = await sender.Send(
new ChangeUserRoleCommand(id, body.RoleId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> SetUserPlan(
Guid id,
SetUserPlanBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new AdminSetUserPlanCommand(id, body.PlanId, body.CustomConfigCount),
cancellationToken
);
return result.ToHttpResult(); return result.ToHttpResult();
} }
} }
public sealed record UpdateRoleBody(int MaxConfigs); public sealed record UpdateRoleBody(int MaxIpLimit, bool BillingEnabled);
public sealed record ChangeUserRoleBody(Guid RoleId); public sealed record ChangeUserRoleBody(Guid RoleId);
public sealed record SetUserPlanBody(Guid? PlanId, int? CustomConfigCount);
@@ -1,6 +1,6 @@
using System.Text; using System.Text;
using PnvPanel.Api.Common; using PnvPanel.Api.Common;
using PnvPanel.Application.Common.Messaging; using LiteCqrs;
using PnvPanel.Application.Subscriptions; using PnvPanel.Application.Subscriptions;
namespace PnvPanel.Api.Endpoints; namespace PnvPanel.Api.Endpoints;
@@ -12,17 +12,26 @@ public static class SubscriptionEndpoints
// Вне /api по дизайну (api-design.md) — публичный эндпоинт для VPN-клиентов. // Вне /api по дизайну (api-design.md) — публичный эндпоинт для VPN-клиентов.
app.MapGet("/sub/{token}", GetSubscription) app.MapGet("/sub/{token}", GetSubscription)
.WithTags("Subscription") .WithTags("Subscription")
.RequireRateLimiting(RateLimiting.AuthPolicy); .RequireRateLimiting(RateLimiting.AuthPolicy)
.Produces<string>(StatusCodes.Status200OK, "text/plain")
.Produces(StatusCodes.Status404NotFound);
return app; return app;
} }
private static async Task<IResult> GetSubscription(string token, HttpResponse response, ISender sender, CancellationToken cancellationToken) private static async Task<IResult> GetSubscription(
string token,
HttpResponse response,
ISender sender,
CancellationToken cancellationToken
)
{ {
// Токен — либо AppUser.SubscriptionToken (агрегированная подписка), либо VpnConfig.SubscriptionToken // Токен — либо AppUser.SubscriptionToken (агрегированная подписка), либо VpnConfig.SubscriptionToken
// (один конфиг). Пробуем пользовательский токен первым. // (один конфиг). Пробуем пользовательский токен первым.
var userResult = await sender.Send(new GetUserSubscriptionQuery(token), cancellationToken); var userResult = await sender.Send(new GetUserSubscriptionQuery(token), cancellationToken);
var result = userResult.IsSuccess ? userResult : await sender.Send(new GetConfigSubscriptionQuery(token), cancellationToken); var result = userResult.IsSuccess
? userResult
: await sender.Send(new GetConfigSubscriptionQuery(token), cancellationToken);
if (!result.IsSuccess) if (!result.IsSuccess)
return Results.NotFound(); return Results.NotFound();
@@ -34,7 +43,8 @@ public static class SubscriptionEndpoints
var expire = result.Value.ExpiresAt is { } exp ? exp.ToUnixTimeSeconds().ToString() : "0"; var expire = result.Value.ExpiresAt is { } exp ? exp.ToUnixTimeSeconds().ToString() : "0";
response.Headers.Append( response.Headers.Append(
"Subscription-Userinfo", "Subscription-Userinfo",
$"upload={result.Value.UsedUpBytes}; download={result.Value.UsedDownBytes}; total={total}; expire={expire}"); $"upload={result.Value.UsedUpBytes}; download={result.Value.UsedDownBytes}; total={total}; expire={expire}"
);
response.Headers.Append("Profile-Update-Interval", "12"); response.Headers.Append("Profile-Update-Interval", "12");
return Results.Text(base64Body, "text/plain; charset=utf-8"); return Results.Text(base64Body, "text/plain; charset=utf-8");
@@ -0,0 +1,162 @@
using Microsoft.AspNetCore.Mvc;
using PnvPanel.Api.Common;
using PnvPanel.Application.Common.Interfaces;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.Support;
using PnvPanel.Application.Support.AddComment;
using PnvPanel.Application.Support.CreateBugReport;
using PnvPanel.Application.Support.CreateExtensionRequest;
using PnvPanel.Application.Support.GetAttachment;
using PnvPanel.Application.Support.GetSupportPricing;
using PnvPanel.Application.Support.GetTicket;
using PnvPanel.Application.Support.ListMyTickets;
using PnvPanel.Application.Support.Reopen;
using PnvPanel.Domain.Support;
namespace PnvPanel.Api.Endpoints;
public static class SupportEndpoints
{
public static IEndpointRouteBuilder MapSupportEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/support").WithTags("Support").RequireAuthorization();
group.MapGet("/pricing", GetSupportPricing).Produces<PricingSettingsDto>();
group
.MapPost("/tickets/bug-reports", CreateBugReport)
.DisableAntiforgery()
.Produces<TicketDetailDto>();
group
.MapPost("/tickets/extension-requests", CreateExtensionRequest)
.Produces<TicketDetailDto>();
group.MapGet("/tickets", ListMyTickets).Produces<PagedList<TicketSummaryDto>>();
group.MapGet("/tickets/{id:guid}", GetTicket).Produces<TicketDetailDto>();
group
.MapPost("/tickets/{id:guid}/comments", AddComment)
.DisableAntiforgery()
.Produces<TicketCommentDto>();
group.MapPost("/tickets/{id:guid}/reopen", Reopen).Produces(StatusCodes.Status204NoContent);
group.MapGet("/attachments/{id:guid}", GetAttachment);
return app;
}
private static async Task<IResult> GetSupportPricing(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetSupportPricingQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateBugReport(
[FromForm] string message,
IFormFileCollection? files,
ISender sender,
CancellationToken cancellationToken
)
{
var command = new CreateBugReportTicketCommand(message, ToUploads(files));
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateExtensionRequest(
CreateExtensionRequestBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var command = new CreateExtensionRequestTicketCommand(body.RequestedDays, body.Justification);
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ListMyTickets(
[AsParameters] ListTicketsRequest request,
ISender sender,
CancellationToken cancellationToken
)
{
var query = new ListMyTicketsQuery(
request.Type,
request.Status,
request.Page,
request.PageSize
);
var result = await sender.Send(query, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetTicket(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetTicketQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> AddComment(
Guid id,
[FromForm] string body,
IFormFileCollection? files,
ISender sender,
CancellationToken cancellationToken
)
{
var command = new AddTicketCommentCommand(id, body, ToUploads(files));
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> Reopen(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ReopenTicketCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetAttachment(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetTicketAttachmentQuery(id), cancellationToken);
if (!result.IsSuccess)
return result.ToHttpResult();
return Results.File(result.Value.Content, result.Value.ContentType, result.Value.FileName);
}
internal static IReadOnlyList<TicketAttachmentUpload> ToUploads(IFormFileCollection? files)
{
if (files is null || files.Count == 0)
return [];
return files
.Select(f => new TicketAttachmentUpload(
f.OpenReadStream(),
f.FileName,
f.ContentType,
f.Length
))
.ToList();
}
}
public sealed record CreateExtensionRequestBody(int RequestedDays, string Justification);
public sealed record ListTicketsRequest(
TicketType? Type,
TicketStatus? Status,
int Page = 1,
int PageSize = 20
);
@@ -0,0 +1,141 @@
using PnvPanel.Api.Common;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using LiteCqrs;
using PnvPanel.Application.Telegram;
using PnvPanel.Domain.Telegram;
namespace PnvPanel.Api.Endpoints;
public static class TelegramEndpoints
{
public static IEndpointRouteBuilder MapTelegramEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/auth/telegram")
.WithTags("Auth.Telegram")
.RequireRateLimiting(RateLimiting.AuthPolicy);
group
.MapPost("/link-token", CreateLinkToken)
.RequireAuthorization()
.Produces<LinkTokenResponseDto>();
group
.MapPost("/unlink", Unlink)
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent);
group
.MapPost("/login-request", CreateLoginRequest)
.Produces<TelegramLoginRequestResponseDto>();
group
.MapGet("/login-request/{id:guid}", GetLoginRequestStatus)
.Produces<TelegramLoginStatusResponseDto>();
return app;
}
private static async Task<IResult> CreateLinkToken(
ISender sender,
ITelegramBotInfo botInfo,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new CreateLinkTokenCommand(), cancellationToken);
if (!result.IsSuccess)
return result.ToHttpResult();
var botUsername = await botInfo.GetUsernameAsync(cancellationToken);
var deepLink = botUsername is null
? null
: $"https://t.me/{botUsername}?start=link_{result.Value.Token}";
return Results.Ok(new LinkTokenResponseDto(deepLink, result.Value.ExpiresAt));
}
private static async Task<IResult> Unlink(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new UnlinkTelegramCommand(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateLoginRequest(
HttpRequest request,
ISender sender,
ITelegramBotInfo botInfo,
CancellationToken cancellationToken
)
{
var context = request.HttpContext.Connection.RemoteIpAddress?.ToString();
var result = await sender.Send(new CreateLoginRequestCommand(context), cancellationToken);
if (!result.IsSuccess)
return result.ToHttpResult();
var botUsername = await botInfo.GetUsernameAsync(cancellationToken);
var deepLink = botUsername is null
? null
: $"https://t.me/{botUsername}?start=login_{result.Value.RequestId}";
return Results.Ok(
new TelegramLoginRequestResponseDto(
result.Value.RequestId,
deepLink,
result.Value.ExpiresAt
)
);
}
private static async Task<IResult> GetLoginRequestStatus(
Guid id,
HttpRequest request,
HttpResponse response,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetLoginRequestStatusQuery(id), cancellationToken);
if (!result.IsSuccess)
return result.ToHttpResult();
if (result.Value.Auth is { } auth)
{
// Secure = IsHttps запроса (учитывает ForwardedHeaders за внешним TLS-прокси) —
// иначе браузер/HttpClient не пришлёт cookie обратно на plain-http (см. AuthEndpoints).
var cookieOptions = new CookieOptions
{
HttpOnly = true,
Secure = request.IsHttps,
SameSite = SameSiteMode.Strict,
Path = "/api/auth",
Expires = auth.RefreshTokenExpiresAt,
};
response.Cookies.Append("pnv_refresh_token", auth.RefreshToken, cookieOptions);
return Results.Ok(
new TelegramLoginStatusResponseDto(
result.Value.Status,
auth.AccessToken,
auth.AccessTokenExpiresAt,
auth.User
)
);
}
return Results.Ok(
new TelegramLoginStatusResponseDto(result.Value.Status, null, null, null)
);
}
}
public sealed record LinkTokenResponseDto(string? DeepLink, DateTimeOffset ExpiresAt);
public sealed record TelegramLoginRequestResponseDto(
Guid RequestId,
string? DeepLink,
DateTimeOffset ExpiresAt
);
public sealed record TelegramLoginStatusResponseDto(
TelegramLoginStatus Status,
string? AccessToken,
DateTimeOffset? ExpiresAt,
CurrentUserDto? User
);
+33
View File
@@ -0,0 +1,33 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using PnvPanel.Infrastructure.Identity;
namespace PnvPanel.Api.Hubs;
/// <summary>
/// Группы: user:{userId} (личные события — трафик/статус конфига), admins (статусы нод,
/// запросы активации). UserIdentifier берётся из claim NameIdentifier — того же, что кладём в JWT.
/// </summary>
[Authorize]
public sealed class PanelHub : Hub
{
public override async Task OnConnectedAsync()
{
if (Context.UserIdentifier is { } userId)
await Groups.AddToGroupAsync(Context.ConnectionId, GroupNames.User(userId));
if (Context.User?.IsInRole(RoleNames.Admin) == true)
await Groups.AddToGroupAsync(Context.ConnectionId, GroupNames.Admins);
await base.OnConnectedAsync();
}
}
public static class GroupNames
{
public const string Admins = "admins";
public static string User(string userId) => $"user:{userId}";
public static string User(Guid userId) => User(userId.ToString());
}
@@ -0,0 +1,161 @@
using Microsoft.AspNetCore.SignalR;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Domain.Configs;
using PnvPanel.Domain.Nodes;
using PnvPanel.Domain.Support;
namespace PnvPanel.Api.Hubs;
internal sealed class SignalRRealtimeNotifier(IHubContext<PanelHub> hubContext) : IRealtimeNotifier
{
public Task NotifyConfigTrafficUpdatedAsync(
Guid userId,
Guid configId,
long usedUpBytes,
long usedDownBytes,
CancellationToken cancellationToken
)
{
return hubContext
.Clients.Group(GroupNames.User(userId))
.SendAsync(
"configTrafficUpdated",
new
{
configId,
usedUpBytes,
usedDownBytes,
},
cancellationToken
);
}
public Task NotifyConfigStatusChangedAsync(
Guid userId,
Guid configId,
ConfigStatus status,
CancellationToken cancellationToken
)
{
return hubContext
.Clients.Group(GroupNames.User(userId))
.SendAsync(
"configStatusChanged",
new { configId, status = status.ToString() },
cancellationToken
);
}
public Task NotifyNodeStatusChangedAsync(
Guid nodeId,
NodeStatus status,
DateTimeOffset? lastSyncAt,
CancellationToken cancellationToken
)
{
return hubContext
.Clients.Group(GroupNames.Admins)
.SendAsync(
"nodeStatusChanged",
new
{
nodeId,
status = status.ToString(),
lastSyncAt,
},
cancellationToken
);
}
public Task NotifyActivationRequestedAsync(
Guid requestId,
Guid userId,
string userName,
string? comment,
DateTimeOffset createdAt,
CancellationToken cancellationToken
)
{
return hubContext
.Clients.Group(GroupNames.Admins)
.SendAsync(
"activationRequested",
new
{
requestId,
userId,
userName,
comment,
createdAt,
},
cancellationToken
);
}
public Task NotifyUserActivatedAsync(Guid userId, CancellationToken cancellationToken)
{
return hubContext
.Clients.Group(GroupNames.User(userId))
.SendAsync("userActivated", new { userId }, cancellationToken);
}
public Task NotifyNewsPublishedAsync(
Guid postId,
string title,
DateTimeOffset createdAt,
CancellationToken cancellationToken
)
{
return hubContext.Clients.All.SendAsync(
"newsPublished",
new
{
id = postId,
title,
createdAt,
},
cancellationToken
);
}
public Task NotifyTicketCreatedAsync(
Guid ticketId,
Guid userId,
string userName,
TicketType type,
CancellationToken cancellationToken
)
{
return hubContext
.Clients.Group(GroupNames.Admins)
.SendAsync(
"ticketCreated",
new
{
ticketId,
userId,
userName,
type = type.ToString(),
},
cancellationToken
);
}
public Task NotifyTicketUpdatedAsync(
Guid ticketId,
Guid userId,
CancellationToken cancellationToken
)
{
return hubContext
.Clients.Group(GroupNames.User(userId))
.SendAsync("ticketUpdated", new { ticketId }, cancellationToken);
}
public Task NotifyBillingStatusChangedAsync(Guid userId, CancellationToken cancellationToken)
{
return hubContext
.Clients.Group(GroupNames.User(userId))
.SendAsync("billingStatusChanged", new { userId }, cancellationToken);
}
}
+6 -3
View File
@@ -1,12 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\PnvPanel.Infrastructure\PnvPanel.Infrastructure.csproj" /> <ProjectReference Include="..\PnvPanel.Infrastructure\PnvPanel.Infrastructure.csproj" />
<ProjectReference Include="..\PnvPanel.Application\PnvPanel.Application.csproj" /> <ProjectReference Include="..\PnvPanel.Application\PnvPanel.Application.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Content Include="..\..\..\seed\client-apps.json" Link="seed\client-apps.json" CopyToOutputDirectory="PreserveNewest" /> <Content
Include="..\..\..\seed\client-apps.json"
Link="seed\client-apps.json"
CopyToOutputDirectory="PreserveNewest"
/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -19,6 +22,7 @@
<PackageReference Include="Microsoft.OpenApi" /> <PackageReference Include="Microsoft.OpenApi" />
<PackageReference Include="Scalar.AspNetCore" /> <PackageReference Include="Scalar.AspNetCore" />
<PackageReference Include="Serilog.AspNetCore" /> <PackageReference Include="Serilog.AspNetCore" />
<PackageReference Include="Telegram.Bot" />
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>
@@ -26,5 +30,4 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
</Project> </Project>
+142 -10
View File
@@ -1,52 +1,162 @@
using System.Net;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options;
using PnvPanel.Api.Common; using PnvPanel.Api.Common;
using PnvPanel.Api.Endpoints; using PnvPanel.Api.Endpoints;
using PnvPanel.Api.Hubs;
using PnvPanel.Api.Telegram;
using PnvPanel.Application; using PnvPanel.Application;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Infrastructure; using PnvPanel.Infrastructure;
using PnvPanel.Infrastructure.Identity; using PnvPanel.Infrastructure.Identity;
using PnvPanel.Infrastructure.Persistence; using PnvPanel.Infrastructure.Persistence;
using PnvPanel.Infrastructure.Telegram;
using Scalar.AspNetCore; using Scalar.AspNetCore;
using Serilog; using Serilog;
using Telegram.Bot;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// Структурное логирование (Serilog), конфигурация из appsettings/env. // Структурное логирование (Serilog), конфигурация из appsettings/env.
builder.Services.AddSerilog((services, configuration) => configuration builder.Services.AddSerilog(
(services, configuration) =>
configuration
.ReadFrom.Configuration(builder.Configuration) .ReadFrom.Configuration(builder.Configuration)
.ReadFrom.Services(services) .ReadFrom.Services(services)
.Enrich.FromLogContext()); .Enrich.FromLogContext()
);
// За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера). // За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера), но ТОЛЬКО от явно
// перечисленных адресов/сетей прокси — иначе клиент может подделать свой IP/схему напрямую, минуя
// прокси (спуфинг rate-limiting по IP, аудит-лога, Secure-cookie). По умолчанию (без конфигурации)
// остаётся дефолт ASP.NET Core — доверие только loopback; для прод-топологии прокси задаётся через
// ForwardedHeaders__KnownProxies / ForwardedHeaders__KnownNetworks (см. .env.example).
builder.Services.Configure<ForwardedHeadersOptions>(options => builder.Services.Configure<ForwardedHeadersOptions>(options =>
{ {
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear(); foreach (
var proxy in builder
.Configuration.GetSection("ForwardedHeaders:KnownProxies")
.Get<string[]>()
?? []
)
options.KnownProxies.Add(IPAddress.Parse(proxy));
foreach (
var network in builder
.Configuration.GetSection("ForwardedHeaders:KnownNetworks")
.Get<string[]>()
?? []
)
{
var parts = network.Split('/');
options.KnownIPNetworks.Add(
new System.Net.IPNetwork(IPAddress.Parse(parts[0]), int.Parse(parts[1]))
);
}
}); });
builder.Services.AddHttpContextAccessor(); builder.Services.AddHttpContextAccessor();
builder.Services.AddApplication(); builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration); builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddSignalR();
// В Api, не в Infrastructure — реализации нужен IHubContext<PanelHub>, а Hub определён здесь же.
builder.Services.AddSingleton<IRealtimeNotifier, SignalRRealtimeNotifier>();
// Telegram-бот: presentation-адаптер, хостится в процессе Api (long polling). Клиент регистрируем
// всегда (даже с пустым токеном) — TelegramBotHostedService сам решает не стартовать без токена,
// а TelegramNotifier — не слать сообщения. TelegramBotClient(...) при этом валидирует формат токена
// и падает на пустой строке, поэтому при пустом BotToken подставляем синтаксически валидную заглушку —
// реальный HTTP-вызов через неё никогда не происходит (все вызывающие места сами проверяют BotToken).
builder.Services.AddSingleton<ITelegramBotClient>(sp =>
{
var options = sp.GetRequiredService<IOptions<TelegramOptions>>().Value;
var token = string.IsNullOrWhiteSpace(options.BotToken)
? "0:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
: options.BotToken;
if (string.IsNullOrWhiteSpace(options.ProxyUrl))
return new TelegramBotClient(token);
// Прокси (обычно socks5://) для запросов к Bot API — на случай, если Telegram недоступен напрямую
// с сети сервера. Пусто (по умолчанию) — без прокси, прямое подключение (см. .env.example).
var proxyUri = new Uri(options.ProxyUrl);
var proxy = new WebProxy(proxyUri);
if (!string.IsNullOrEmpty(proxyUri.UserInfo))
{
var credentials = proxyUri.UserInfo.Split(':', 2);
proxy.Credentials = new NetworkCredential(
credentials[0],
credentials.Length > 1 ? credentials[1] : string.Empty
);
}
sp.GetRequiredService<ILogger<Program>>()
.LogInformation(
"Telegram bot using proxy {Scheme}://{Host}:{Port}",
proxyUri.Scheme,
proxyUri.Host,
proxyUri.Port
);
var handler = new SocketsHttpHandler { Proxy = proxy, UseProxy = true };
return new TelegramBotClient(token, new HttpClient(handler));
});
// Scoped — зависит от IIdentityService (scoped), не Singleton.
builder.Services.AddScoped<ITelegramNotifier, TelegramNotifier>();
// Singleton — кэширует username бота (getMe) на весь процесс, не из ручного env (см. TelegramBotInfo).
builder.Services.AddSingleton<ITelegramBotInfo, TelegramBotInfo>();
builder.Services.AddSingleton<PnvBotUpdateHandler>();
builder.Services.AddHostedService<TelegramBotHostedService>();
builder.Services.AddRateLimiter(options => builder.Services.AddRateLimiter(options =>
{ {
options.AddFixedWindowLimiter(RateLimiting.AuthPolicy, limiterOptions => options.AddFixedWindowLimiter(
RateLimiting.AuthPolicy,
limiterOptions =>
{ {
limiterOptions.PermitLimit = 20; // Настраиваемо через конфиг, чтобы интеграционные тесты (общий TestServer/host на весь
// collection, все запросы — от одного "клиента") могли поднять лимит и не ловить 429.
limiterOptions.PermitLimit = builder.Configuration.GetValue(
"RateLimiting:AuthPermitLimit",
20
);
limiterOptions.Window = TimeSpan.FromMinutes(1); limiterOptions.Window = TimeSpan.FromMinutes(1);
limiterOptions.QueueLimit = 0; limiterOptions.QueueLimit = 0;
}); }
);
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
}); });
// Энумы сериализуются строками ("Vless", "Active", ...), не числами — самодокументируемый JSON,
// корректные строковые литералы при генерации TS-типов из OpenAPI-схемы (см. docs/frontend.md).
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())
);
builder.Services.AddProblemDetails(); builder.Services.AddProblemDetails();
builder.Services.AddOpenApi(); builder.Services.AddOpenApi();
builder.Services.AddHealthChecks() builder.Services.AddHealthChecks().AddDbContextCheck<AppDbContext>();
.AddDbContextCheck<AppDbContext>();
var app = builder.Build(); var app = builder.Build();
// Без персистентного пути key-ring живёт только в памяти контейнера — после пересоздания
// расшифровать уже сохранённые пароли нод будет невозможно. Предупреждаем громко, не молчим.
if (string.IsNullOrWhiteSpace(builder.Configuration["DataProtection:KeyRingPath"]))
{
app.Logger.LogWarning(
"DataProtection:KeyRingPath is not set — node secret encryption keys are not persistent "
+ "and will be lost when the container is recreated. Mount a volume and set the path in production."
);
}
// Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте. // Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте.
await app.Services.ApplyMigrationsAsync(); await app.Services.ApplyMigrationsAsync();
await app.Services.SeedDataAsync(); await app.Services.SeedDataAsync();
@@ -74,6 +184,25 @@ app.MapInboundEndpoints();
app.MapConfigEndpoints(); app.MapConfigEndpoints();
app.MapSubscriptionEndpoints(); app.MapSubscriptionEndpoints();
app.MapAppEndpoints(); app.MapAppEndpoints();
app.MapPlanEndpoints();
app.MapNewsEndpoints();
app.MapInstructionEndpoints();
app.MapMediaEndpoints();
app.MapAdminUserEndpoints();
app.MapAdminStatsEndpoints();
app.MapAdminAppEndpoints();
app.MapAdminPlanEndpoints();
app.MapAdminNewsEndpoints();
app.MapAdminInstructionEndpoints();
app.MapAdminPricingEndpoints();
app.MapBillingEndpoints();
app.MapAdminBillingEndpoints();
app.MapSupportEndpoints();
app.MapAdminSupportEndpoints();
app.MapAdminMaintenanceEndpoints();
app.MapTelegramEndpoints();
app.MapHub<PanelHub>("/hubs/panel");
// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов. // Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов.
app.UseDefaultFiles(); app.UseDefaultFiles();
@@ -81,3 +210,6 @@ app.UseStaticFiles();
app.MapFallbackToFile("index.html"); app.MapFallbackToFile("index.html");
app.Run(); app.Run();
/// <summary>Делает неявный класс Program доступным для WebApplicationFactory&lt;Program&gt; в интеграционных тестах.</summary>
public partial class Program;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,68 @@
using Microsoft.Extensions.Options;
using PnvPanel.Infrastructure.Telegram;
using Telegram.Bot;
using Telegram.Bot.Polling;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
namespace PnvPanel.Api.Telegram;
/// <summary>
/// Бот — presentation-адаптер, хостится в процессе Api (long polling). Если BotToken не задан,
/// не стартует — панель работает без бота. Апдейты обрабатывает PnvBotUpdateHandler, который
/// вызывает те же CQRS-команды, что и веб, через собственный ISender.
/// </summary>
public sealed class TelegramBotHostedService(
ITelegramBotClient botClient,
PnvBotUpdateHandler updateHandler,
IOptions<TelegramOptions> options,
ILogger<TelegramBotHostedService> logger
) : BackgroundService
{
private static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(30);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
{
logger.LogWarning("Telegram__BotToken is not set — bot will not start.");
return;
}
var receiverOptions = new ReceiverOptions
{
AllowedUpdates = [UpdateType.Message, UpdateType.CallbackQuery],
DropPendingUpdates = true,
};
logger.LogInformation("Telegram bot starting (long polling)...");
// ReceiveAsync может упасть с сетевой ошибкой (Telegram API недоступен/заблокирован) —
// это НЕ должно ронять весь хост (BackgroundServiceExceptionBehavior.StopHost по умолчанию
// убивает всё приложение при необработанном исключении в BackgroundService). Панель обязана
// работать без бота, поэтому ловим, логируем и переподключаемся с паузой (см. CLAUDE.md).
while (!stoppingToken.IsCancellationRequested)
{
try
{
await botClient.ReceiveAsync(updateHandler, receiverOptions, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// Штатная остановка вместе с приложением.
}
catch (Exception ex)
{
logger.LogError(ex, "Telegram bot polling failed, retrying in {Delay}", RetryDelay);
try
{
await Task.Delay(RetryDelay, stoppingToken);
}
catch (OperationCanceledException)
{
// Остановка приложения во время паузы перед повтором.
}
}
}
}
}
@@ -0,0 +1,45 @@
using Microsoft.Extensions.Options;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Infrastructure.Telegram;
using Telegram.Bot;
namespace PnvPanel.Api.Telegram;
/// <summary>Кэширует username бота на время жизни процесса (getMe не меняется, повторный запрос не нужен).</summary>
internal sealed class TelegramBotInfo(
ITelegramBotClient botClient,
IOptions<TelegramOptions> options
) : ITelegramBotInfo
{
private readonly SemaphoreSlim _lock = new(1, 1);
private string? _cachedUsername;
public async Task<string?> GetUsernameAsync(CancellationToken cancellationToken)
{
if (_cachedUsername is not null)
return _cachedUsername;
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
return null;
await _lock.WaitAsync(cancellationToken);
try
{
if (_cachedUsername is not null)
return _cachedUsername;
var me = await botClient.GetMe(cancellationToken);
_cachedUsername = me.Username;
return _cachedUsername;
}
catch
{
// Telegram недоступен/бот не отвечает — деплинк просто не покажем вызывающей стороне.
return null;
}
finally
{
_lock.Release();
}
}
}
@@ -0,0 +1,364 @@
using Microsoft.Extensions.Options;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Domain.Billing;
using PnvPanel.Domain.Nodes;
using PnvPanel.Domain.Support;
using PnvPanel.Infrastructure.Telegram;
using Telegram.Bot;
using Telegram.Bot.Types.Enums;
using Telegram.Bot.Types.ReplyMarkups;
namespace PnvPanel.Api.Telegram;
internal sealed class TelegramNotifier(
ITelegramBotClient botClient,
IIdentityService identityService,
IOptions<TelegramOptions> options
) : ITelegramNotifier
{
public async Task NotifyAdminsActivationRequestedAsync(
Guid requestId,
string userName,
string? comment,
CancellationToken cancellationToken
)
{
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
return;
var text =
$"🆕 Запрос на активацию от <b>{Escape(userName)}</b>"
+ (
string.IsNullOrWhiteSpace(comment)
? string.Empty
: $"\nКомментарий: {Escape(comment)}"
);
var keyboard = new InlineKeyboardMarkup(
new[]
{
InlineKeyboardButton.WithCallbackData("✅ Активировать", $"act:approve:{requestId}"),
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"act:reject:{requestId}"),
}
);
foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
{
try
{
await botClient.SendMessage(
adminId,
text,
parseMode: ParseMode.Html,
replyMarkup: keyboard,
cancellationToken: cancellationToken
);
}
catch
{
// Админ мог не запускать бота (нет чата с ботом) — пропускаем, не валим команду.
}
}
}
public async Task NotifyAdminsBugReportCreatedAsync(
Guid ticketId,
string userName,
string message,
CancellationToken cancellationToken
)
{
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
return;
var preview = message.Length > 300 ? message[..300] + "…" : message;
var text =
$"🐞 Новый тикет (баг/предложение) от <b>{Escape(userName)}</b>\n{Escape(preview)}";
// Только ссылка на сайт — переписка и картинки удобнее там, инлайн-действий для баг-тикетов нет.
InlineKeyboardMarkup? keyboard = null;
if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl))
{
var url = $"{options.Value.PublicSiteUrl.TrimEnd('/')}/admin/support?ticket={ticketId}";
keyboard = new InlineKeyboardMarkup(
new[] { InlineKeyboardButton.WithUrl("🌐 Открыть на сайте", url) }
);
}
foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
{
try
{
await botClient.SendMessage(
adminId,
text,
parseMode: ParseMode.Html,
replyMarkup: keyboard,
cancellationToken: cancellationToken
);
}
catch
{
// Админ мог не запускать бота (нет чата с ботом) — пропускаем, не валим команду.
}
}
}
public async Task NotifyAdminsExtensionRequestCreatedAsync(
Guid ticketId,
string userName,
int requestedDays,
string justification,
CancellationToken cancellationToken
)
{
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
return;
var text =
$"🆕 Заявка на продление от <b>{Escape(userName)}</b> — {requestedDays} дн.\nОбоснование: {Escape(justification)}";
var keyboard = new InlineKeyboardMarkup(
new[]
{
InlineKeyboardButton.WithCallbackData("✅ Одобрить", $"erq:approve:{ticketId}"),
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"erq:reject:{ticketId}"),
}
);
foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
{
try
{
await botClient.SendMessage(
adminId,
text,
parseMode: ParseMode.Html,
replyMarkup: keyboard,
cancellationToken: cancellationToken
);
}
catch
{
// Админ мог не запускать бота (нет чата с ботом) — пропускаем, не валим команду.
}
}
}
public async Task NotifyAdminsTicketReopenedAsync(
Guid ticketId,
string userName,
TicketType type,
CancellationToken cancellationToken
)
{
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
return;
var text = $"🔓 Тикет от <b>{Escape(userName)}</b> переоткрыт (баг/предложение)";
InlineKeyboardMarkup? keyboard = null;
if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl))
{
var url = $"{options.Value.PublicSiteUrl.TrimEnd('/')}/admin/support?ticket={ticketId}";
keyboard = new InlineKeyboardMarkup(
new[] { InlineKeyboardButton.WithUrl("🌐 Открыть на сайте", url) }
);
}
foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
{
try
{
await botClient.SendMessage(
adminId,
text,
parseMode: ParseMode.Html,
replyMarkup: keyboard,
cancellationToken: cancellationToken
);
}
catch
{
// Админ мог не запускать бота (нет чата с ботом) — пропускаем, не валим команду.
}
}
}
public async Task NotifyUsersNewsPublishedAsync(
string title,
CancellationToken cancellationToken
)
{
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
return;
var text = $"📰 Новая новость: <b>{Escape(title)}</b>";
InlineKeyboardMarkup? keyboard = null;
if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl))
{
var url = $"{options.Value.PublicSiteUrl.TrimEnd('/')}/news";
keyboard = new InlineKeyboardMarkup(
new[] { InlineKeyboardButton.WithUrl("🌐 Открыть на сайте", url) }
);
}
var telegramUserIds = await identityService.GetActivatedLinkedTelegramUserIdsAsync(
cancellationToken
);
foreach (var telegramUserId in telegramUserIds)
{
try
{
await botClient.SendMessage(
telegramUserId,
text,
parseMode: ParseMode.Html,
replyMarkup: keyboard,
cancellationToken: cancellationToken
);
}
catch
{
// Пользователь мог заблокировать бота — не критично, продолжаем рассылку остальным.
}
}
}
public async Task NotifyAdminsPaymentRequestedAsync(
Guid requestId,
string userName,
PaymentRequestKind kind,
PaymentPeriod? period,
int amount,
CancellationToken cancellationToken
)
{
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
return;
var reason = kind == PaymentRequestKind.PlanChangeTopUp ? "доплате за смену тарифа" : $"оплате за {PeriodLabel(period!.Value)}";
var text = $"💰 <b>{Escape(userName)}</b> заявляет о {reason} — {amount} ₽\nПроверьте поступление и подтвердите.";
var keyboard = new InlineKeyboardMarkup(
new[]
{
InlineKeyboardButton.WithCallbackData("✅ Подтвердить", $"pay:approve:{requestId}"),
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"pay:reject:{requestId}"),
}
);
foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
{
try
{
await botClient.SendMessage(
adminId,
text,
parseMode: ParseMode.Html,
replyMarkup: keyboard,
cancellationToken: cancellationToken
);
}
catch
{
// Админ мог не запускать бота (нет чата с ботом) — пропускаем, не валим команду.
}
}
}
private static string PeriodLabel(PaymentPeriod period) =>
period switch
{
PaymentPeriod.Quarter => "3 месяца",
PaymentPeriod.HalfYear => "полгода",
PaymentPeriod.Year => "год",
_ => period.ToString(),
};
public async Task NotifyUserAsync(
Guid userId,
string message,
string? linkPath,
CancellationToken cancellationToken
)
{
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
return;
var link = await identityService.GetTelegramLinkInfoAsync(userId, cancellationToken);
if (!link.IsLinked || link.TelegramUserId is not { } telegramUserId)
return;
InlineKeyboardMarkup? keyboard = null;
if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl))
{
var url = string.IsNullOrWhiteSpace(linkPath)
? options.Value.PublicSiteUrl
: $"{options.Value.PublicSiteUrl.TrimEnd('/')}{linkPath}";
keyboard = new InlineKeyboardMarkup(
new[] { InlineKeyboardButton.WithUrl("🌐 Открыть на сайте", url) }
);
}
try
{
await botClient.SendMessage(
telegramUserId,
message,
replyMarkup: keyboard,
cancellationToken: cancellationToken
);
}
catch
{
// Пользователь мог заблокировать бота — не критично для основной операции.
}
}
public async Task NotifyAdminsNodeStatusChangedAsync(
Guid nodeId,
string nodeName,
NodeStatus status,
CancellationToken cancellationToken
)
{
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
return;
var emoji = status == NodeStatus.Online ? "✅" : "🔴";
var statusLabel = status == NodeStatus.Online ? "снова в сети" : "недоступна";
var text = $"{emoji} Нода <b>{Escape(nodeName)}</b> {statusLabel}.";
InlineKeyboardMarkup? keyboard = null;
if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl))
{
var url = $"{options.Value.PublicSiteUrl.TrimEnd('/')}/admin/nodes";
keyboard = new InlineKeyboardMarkup(
new[] { InlineKeyboardButton.WithUrl("🌐 Открыть на сайте", url) }
);
}
foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
{
try
{
await botClient.SendMessage(
adminId,
text,
parseMode: ParseMode.Html,
replyMarkup: keyboard,
cancellationToken: cancellationToken
);
}
catch
{
// Админ мог не запускать бота (нет чата с ботом) — пропускаем, не валим команду.
}
}
}
private static string Escape(string text) =>
text.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
}
@@ -4,12 +4,18 @@ namespace PnvPanel.Application.Activation;
public static class ActivationErrors public static class ActivationErrors
{ {
public static readonly Error AlreadyPending = public static readonly Error AlreadyPending = Error.Conflict(
Error.Conflict("Activation.AlreadyPending", "У вас уже есть необработанный запрос на активацию."); "Activation.AlreadyPending",
"У вас уже есть необработанный запрос на активацию."
);
public static readonly Error NotFound = public static readonly Error NotFound = Error.NotFound(
Error.NotFound("Activation.NotFound", "Запрос на активацию не найден."); "Activation.NotFound",
"Запрос на активацию не найден."
);
public static readonly Error AlreadyDecided = public static readonly Error AlreadyDecided = Error.Conflict(
Error.Conflict("Activation.AlreadyDecided", "Запрос на активацию уже обработан."); "Activation.AlreadyDecided",
"Запрос на активацию уже обработан."
);
} }
@@ -1,4 +1,4 @@
using PnvPanel.Application.Common.Messaging; using LiteCqrs;
using PnvPanel.Application.Common.Models; using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Activation; namespace PnvPanel.Application.Activation;
@@ -1,16 +1,22 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth; using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging; using LiteCqrs;
using PnvPanel.Application.Common.Models; using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Activation; using PnvPanel.Domain.Activation;
namespace PnvPanel.Application.Activation; namespace PnvPanel.Application.Activation;
public sealed class GetActivationStatusQueryHandler(IIdentityService identityService, IAppDbContext dbContext, ICurrentUser currentUser) public sealed class GetActivationStatusQueryHandler(
: IQueryHandler<GetActivationStatusQuery, Result<ActivationStatusDto>> IIdentityService identityService,
IAppDbContext dbContext,
ICurrentUser currentUser
) : IQueryHandler<GetActivationStatusQuery, Result<ActivationStatusDto>>
{ {
public async Task<Result<ActivationStatusDto>> Handle(GetActivationStatusQuery query, CancellationToken cancellationToken) public async Task<Result<ActivationStatusDto>> Handle(
GetActivationStatusQuery query,
CancellationToken cancellationToken
)
{ {
if (currentUser.UserId is not { } userId) if (currentUser.UserId is not { } userId)
return Result.Failure<ActivationStatusDto>(AuthErrors.Unauthorized); return Result.Failure<ActivationStatusDto>(AuthErrors.Unauthorized);
@@ -19,8 +25,10 @@ public sealed class GetActivationStatusQueryHandler(IIdentityService identitySer
if (profile is null) if (profile is null)
return Result.Failure<ActivationStatusDto>(AuthErrors.Unauthorized); return Result.Failure<ActivationStatusDto>(AuthErrors.Unauthorized);
var pending = await dbContext.ActivationRequests var pending = await dbContext
.Where(r => r.UserId == userId && r.Status == ActivationStatus.Pending) .ActivationRequests.Where(r =>
r.UserId == userId && r.Status == ActivationStatus.Pending
)
.Select(r => new ActivationRequestDto(r.Id, r.Comment, r.CreatedAt)) .Select(r => new ActivationRequestDto(r.Id, r.Comment, r.CreatedAt))
.FirstOrDefaultAsync(cancellationToken); .FirstOrDefaultAsync(cancellationToken);
@@ -1,6 +1,7 @@
using PnvPanel.Application.Common.Messaging; using LiteCqrs;
using PnvPanel.Application.Common.Models; using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Activation; namespace PnvPanel.Application.Activation;
public sealed record RequestActivationCommand(string? Comment) : ICommand<Result<ActivationRequestDto>>; public sealed record RequestActivationCommand(string? Comment)
: ICommand<Result<ActivationRequestDto>>;
@@ -1,22 +1,31 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth; using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging; using LiteCqrs;
using PnvPanel.Application.Common.Models; using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Activation; using PnvPanel.Domain.Activation;
namespace PnvPanel.Application.Activation; namespace PnvPanel.Application.Activation;
public sealed class RequestActivationCommandHandler(IAppDbContext dbContext, ICurrentUser currentUser) public sealed class RequestActivationCommandHandler(
: ICommandHandler<RequestActivationCommand, Result<ActivationRequestDto>> IAppDbContext dbContext,
IRealtimeNotifier notifier,
ITelegramNotifier telegramNotifier,
ICurrentUser currentUser
) : ICommandHandler<RequestActivationCommand, Result<ActivationRequestDto>>
{ {
public async Task<Result<ActivationRequestDto>> Handle(RequestActivationCommand command, CancellationToken cancellationToken) public async Task<Result<ActivationRequestDto>> Handle(
RequestActivationCommand command,
CancellationToken cancellationToken
)
{ {
if (currentUser.UserId is not { } userId) if (currentUser.UserId is not { } userId)
return Result.Failure<ActivationRequestDto>(AuthErrors.Unauthorized); return Result.Failure<ActivationRequestDto>(AuthErrors.Unauthorized);
var hasPending = await dbContext.ActivationRequests var hasPending = await dbContext.ActivationRequests.AnyAsync(
.AnyAsync(r => r.UserId == userId && r.Status == ActivationStatus.Pending, cancellationToken); r => r.UserId == userId && r.Status == ActivationStatus.Pending,
cancellationToken
);
if (hasPending) if (hasPending)
return Result.Failure<ActivationRequestDto>(ActivationErrors.AlreadyPending); return Result.Failure<ActivationRequestDto>(ActivationErrors.AlreadyPending);
@@ -24,6 +33,25 @@ public sealed class RequestActivationCommandHandler(IAppDbContext dbContext, ICu
var request = ActivationRequest.Create(userId, command.Comment); var request = ActivationRequest.Create(userId, command.Comment);
dbContext.ActivationRequests.Add(request); dbContext.ActivationRequests.Add(request);
return Result.Success(new ActivationRequestDto(request.Id, request.Comment, request.CreatedAt)); var userName = currentUser.UserName ?? userId.ToString();
await notifier.NotifyActivationRequestedAsync(
request.Id,
userId,
userName,
request.Comment,
request.CreatedAt,
cancellationToken
);
await telegramNotifier.NotifyAdminsActivationRequestedAsync(
request.Id,
userName,
request.Comment,
cancellationToken
);
return Result.Success(
new ActivationRequestDto(request.Id, request.Comment, request.CreatedAt)
);
} }
} }
@@ -6,6 +6,7 @@ public sealed class RequestActivationCommandValidator : AbstractValidator<Reques
{ {
public RequestActivationCommandValidator() public RequestActivationCommandValidator()
{ {
RuleFor(x => x.Comment).MaximumLength(500); // Комментарий обязателен: по нему админ понимает, кто заявитель и откуда.
RuleFor(x => x.Comment).NotEmpty().MaximumLength(500);
} }
} }
@@ -8,4 +8,5 @@ public sealed record ActivationRequestAdminDto(
string UserName, string UserName,
string? Comment, string? Comment,
ActivationStatus Status, ActivationStatus Status,
DateTimeOffset CreatedAt); DateTimeOffset CreatedAt
);
@@ -1,4 +1,4 @@
using PnvPanel.Application.Common.Messaging; using LiteCqrs;
using PnvPanel.Application.Common.Models; using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Activation; namespace PnvPanel.Application.Admin.Activation;
@@ -2,22 +2,33 @@ using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Activation; using PnvPanel.Application.Activation;
using PnvPanel.Application.Auth; using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging; using LiteCqrs;
using PnvPanel.Application.Common.Models; using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Activation; using PnvPanel.Domain.Activation;
using PnvPanel.Domain.Audit;
namespace PnvPanel.Application.Admin.Activation; namespace PnvPanel.Application.Admin.Activation;
public sealed class ApproveActivationCommandHandler(IAppDbContext dbContext, IIdentityService identityService, ICurrentUser currentUser) public sealed class ApproveActivationCommandHandler(
: ICommandHandler<ApproveActivationCommand, Result> IAppDbContext dbContext,
IIdentityService identityService,
IRealtimeNotifier notifier,
ITelegramNotifier telegramNotifier,
ICurrentUser currentUser
) : ICommandHandler<ApproveActivationCommand, Result>
{ {
public async Task<Result> Handle(ApproveActivationCommand command, CancellationToken cancellationToken) public async Task<Result> Handle(
ApproveActivationCommand command,
CancellationToken cancellationToken
)
{ {
if (currentUser.UserId is not { } adminId) if (currentUser.UserId is not { } adminId)
return Result.Failure(AuthErrors.Unauthorized); return Result.Failure(AuthErrors.Unauthorized);
var request = await dbContext.ActivationRequests var request = await dbContext.ActivationRequests.FirstOrDefaultAsync(
.FirstOrDefaultAsync(r => r.Id == command.RequestId, cancellationToken); r => r.Id == command.RequestId,
cancellationToken
);
if (request is null) if (request is null)
return Result.Failure(ActivationErrors.NotFound); return Result.Failure(ActivationErrors.NotFound);
@@ -27,6 +38,32 @@ public sealed class ApproveActivationCommandHandler(IAppDbContext dbContext, IId
request.Approve(adminId); request.Approve(adminId);
return await identityService.ActivateUserAsync(request.UserId, adminId, cancellationToken); var activateResult = await identityService.ActivateUserAsync(
request.UserId,
adminId,
cancellationToken
);
if (!activateResult.IsSuccess)
return activateResult;
dbContext.AuditLogs.Add(
AuditLog.Create(
adminId,
"ActivationApproved",
"User",
request.UserId.ToString(),
metadata: null,
AuditSource.Web
)
);
await notifier.NotifyUserActivatedAsync(request.UserId, cancellationToken);
await telegramNotifier.NotifyUserAsync(
request.UserId,
"✅ Ваш аккаунт активирован администратором.",
null,
cancellationToken
);
return Result.Success();
} }
} }
@@ -1,8 +1,11 @@
using PnvPanel.Application.Common.Messaging; using LiteCqrs;
using PnvPanel.Application.Common.Models; using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Activation; using PnvPanel.Domain.Activation;
namespace PnvPanel.Application.Admin.Activation; namespace PnvPanel.Application.Admin.Activation;
public sealed record ListActivationRequestsQuery(ActivationStatus? StatusFilter, int Page, int PageSize) public sealed record ListActivationRequestsQuery(
: IQuery<Result<PagedList<ActivationRequestAdminDto>>>; ActivationStatus? StatusFilter,
int Page,
int PageSize
) : IQuery<Result<PagedList<ActivationRequestAdminDto>>>;
@@ -1,14 +1,19 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging; using LiteCqrs;
using PnvPanel.Application.Common.Models; using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Activation; namespace PnvPanel.Application.Admin.Activation;
public sealed class ListActivationRequestsQueryHandler(IAppDbContext dbContext, IIdentityService identityService) public sealed class ListActivationRequestsQueryHandler(
: IQueryHandler<ListActivationRequestsQuery, Result<PagedList<ActivationRequestAdminDto>>> IAppDbContext dbContext,
IIdentityService identityService
) : IQueryHandler<ListActivationRequestsQuery, Result<PagedList<ActivationRequestAdminDto>>>
{ {
public async Task<Result<PagedList<ActivationRequestAdminDto>>> Handle(ListActivationRequestsQuery query, CancellationToken cancellationToken) public async Task<Result<PagedList<ActivationRequestAdminDto>>> Handle(
ListActivationRequestsQuery query,
CancellationToken cancellationToken
)
{ {
var page = query.Page <= 0 ? 1 : query.Page; var page = query.Page <= 0 ? 1 : query.Page;
var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize; var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize;
@@ -23,13 +28,22 @@ public sealed class ListActivationRequestsQueryHandler(IAppDbContext dbContext,
var userNames = await identityService.GetUserNamesAsync( var userNames = await identityService.GetUserNamesAsync(
page1.Items.Select(r => r.UserId).Distinct().ToList(), page1.Items.Select(r => r.UserId).Distinct().ToList(),
cancellationToken); cancellationToken
);
var items = page1.Items var items = page1
.Select(r => new ActivationRequestAdminDto( .Items.Select(r => new ActivationRequestAdminDto(
r.Id, r.UserId, userNames.GetValueOrDefault(r.UserId, "?"), r.Comment, r.Status, r.CreatedAt)) r.Id,
r.UserId,
userNames.GetValueOrDefault(r.UserId, "?"),
r.Comment,
r.Status,
r.CreatedAt
))
.ToList(); .ToList();
return Result.Success(new PagedList<ActivationRequestAdminDto>(items, page1.Total, page1.Page, page1.PageSize)); return Result.Success(
new PagedList<ActivationRequestAdminDto>(items, page1.Total, page1.Page, page1.PageSize)
);
} }
} }
@@ -1,4 +1,4 @@
using PnvPanel.Application.Common.Messaging; using LiteCqrs;
using PnvPanel.Application.Common.Models; using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Activation; namespace PnvPanel.Application.Admin.Activation;
@@ -2,22 +2,30 @@ using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Activation; using PnvPanel.Application.Activation;
using PnvPanel.Application.Auth; using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging; using LiteCqrs;
using PnvPanel.Application.Common.Models; using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Activation; using PnvPanel.Domain.Activation;
using PnvPanel.Domain.Audit;
namespace PnvPanel.Application.Admin.Activation; namespace PnvPanel.Application.Admin.Activation;
public sealed class RejectActivationCommandHandler(IAppDbContext dbContext, ICurrentUser currentUser) public sealed class RejectActivationCommandHandler(
: ICommandHandler<RejectActivationCommand, Result> IAppDbContext dbContext,
ICurrentUser currentUser
) : ICommandHandler<RejectActivationCommand, Result>
{ {
public async Task<Result> Handle(RejectActivationCommand command, CancellationToken cancellationToken) public async Task<Result> Handle(
RejectActivationCommand command,
CancellationToken cancellationToken
)
{ {
if (currentUser.UserId is not { } adminId) if (currentUser.UserId is not { } adminId)
return Result.Failure(AuthErrors.Unauthorized); return Result.Failure(AuthErrors.Unauthorized);
var request = await dbContext.ActivationRequests var request = await dbContext.ActivationRequests.FirstOrDefaultAsync(
.FirstOrDefaultAsync(r => r.Id == command.RequestId, cancellationToken); r => r.Id == command.RequestId,
cancellationToken
);
if (request is null) if (request is null)
return Result.Failure(ActivationErrors.NotFound); return Result.Failure(ActivationErrors.NotFound);
@@ -26,6 +34,18 @@ public sealed class RejectActivationCommandHandler(IAppDbContext dbContext, ICur
return Result.Failure(ActivationErrors.AlreadyDecided); return Result.Failure(ActivationErrors.AlreadyDecided);
request.Reject(adminId, command.Reason); request.Reject(adminId, command.Reason);
dbContext.AuditLogs.Add(
AuditLog.Create(
adminId,
"ActivationRejected",
"User",
request.UserId.ToString(),
metadata: null,
AuditSource.Web
)
);
return Result.Success(); return Result.Success();
} }
} }
@@ -0,0 +1,29 @@
using PnvPanel.Domain.Apps;
namespace PnvPanel.Application.Admin.Apps;
public sealed record AdminAppDto(
Guid Id,
string Name,
string DownloadUrl,
OsPlatform OperatingSystem,
string? Description,
string? IconUrl,
int SortOrder,
bool IsEnabled,
bool IsRecommended
)
{
public static AdminAppDto FromDomain(ClientApp app) =>
new(
app.Id,
app.Name,
app.DownloadUrl.ToString(),
app.OperatingSystem,
app.Description,
app.IconUrl,
app.SortOrder,
app.IsEnabled,
app.IsRecommended
);
}
@@ -0,0 +1,11 @@
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Apps;
public static class AppErrors
{
public static readonly Error NotFound = Error.NotFound(
"Apps.NotFound",
"Приложение не найдено."
);
}
@@ -0,0 +1,15 @@
using LiteCqrs;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Apps;
namespace PnvPanel.Application.Admin.Apps;
public sealed record CreateAppCommand(
string Name,
string DownloadUrl,
OsPlatform OperatingSystem,
string? Description,
string? IconUrl,
int SortOrder,
bool IsRecommended
) : ICommand<Result<AdminAppDto>>;
@@ -0,0 +1,30 @@
using PnvPanel.Application.Common.Interfaces;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Apps;
namespace PnvPanel.Application.Admin.Apps;
public sealed class CreateAppCommandHandler(IAppDbContext dbContext)
: ICommandHandler<CreateAppCommand, Result<AdminAppDto>>
{
public Task<Result<AdminAppDto>> Handle(
CreateAppCommand command,
CancellationToken cancellationToken
)
{
var app = ClientApp.Create(
command.Name,
new Uri(command.DownloadUrl, UriKind.Absolute),
command.OperatingSystem,
command.Description,
command.IconUrl,
command.SortOrder,
command.IsRecommended
);
dbContext.ClientApps.Add(app);
return Task.FromResult(Result.Success(AdminAppDto.FromDomain(app)));
}
}
@@ -0,0 +1,15 @@
using FluentValidation;
namespace PnvPanel.Application.Admin.Apps;
public sealed class CreateAppCommandValidator : AbstractValidator<CreateAppCommand>
{
public CreateAppCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
RuleFor(x => x.DownloadUrl).NotEmpty().MaximumLength(500);
RuleFor(x => x.OperatingSystem).IsInEnum();
RuleFor(x => x.Description).MaximumLength(300);
RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0);
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Apps;
public sealed record DeleteAppCommand(Guid AppId) : ICommand<Result>;
@@ -0,0 +1,23 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Apps;
public sealed class DeleteAppCommandHandler(IAppDbContext dbContext)
: ICommandHandler<DeleteAppCommand, Result>
{
public async Task<Result> Handle(DeleteAppCommand command, CancellationToken cancellationToken)
{
var app = await dbContext.ClientApps.FirstOrDefaultAsync(
a => a.Id == command.AppId,
cancellationToken
);
if (app is null)
return Result.Failure(AppErrors.NotFound);
dbContext.ClientApps.Remove(app);
return Result.Success();
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Apps;
public sealed record ListAdminAppsQuery : IQuery<Result<IReadOnlyList<AdminAppDto>>>;
@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Apps;
public sealed class ListAdminAppsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListAdminAppsQuery, Result<IReadOnlyList<AdminAppDto>>>
{
public async Task<Result<IReadOnlyList<AdminAppDto>>> Handle(
ListAdminAppsQuery query,
CancellationToken cancellationToken
)
{
var apps = await dbContext
.ClientApps.AsNoTracking()
.OrderBy(a => a.OperatingSystem)
.ThenBy(a => a.SortOrder)
.ToListAsync(cancellationToken);
return Result.Success<IReadOnlyList<AdminAppDto>>(
apps.Select(AdminAppDto.FromDomain).ToList()
);
}
}
@@ -0,0 +1,17 @@
using LiteCqrs;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Apps;
namespace PnvPanel.Application.Admin.Apps;
public sealed record UpdateAppCommand(
Guid AppId,
string Name,
string DownloadUrl,
OsPlatform OperatingSystem,
string? Description,
string? IconUrl,
int SortOrder,
bool IsEnabled,
bool IsRecommended
) : ICommand<Result<AdminAppDto>>;
@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Apps;
public sealed class UpdateAppCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateAppCommand, Result<AdminAppDto>>
{
public async Task<Result<AdminAppDto>> Handle(
UpdateAppCommand command,
CancellationToken cancellationToken
)
{
var app = await dbContext.ClientApps.FirstOrDefaultAsync(
a => a.Id == command.AppId,
cancellationToken
);
if (app is null)
return Result.Failure<AdminAppDto>(AppErrors.NotFound);
app.Update(
command.Name,
new Uri(command.DownloadUrl, UriKind.Absolute),
command.OperatingSystem,
command.Description,
command.IconUrl,
command.SortOrder,
command.IsEnabled,
command.IsRecommended
);
return Result.Success(AdminAppDto.FromDomain(app));
}
}
@@ -0,0 +1,15 @@
using FluentValidation;
namespace PnvPanel.Application.Admin.Apps;
public sealed class UpdateAppCommandValidator : AbstractValidator<UpdateAppCommand>
{
public UpdateAppCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
RuleFor(x => x.DownloadUrl).NotEmpty().MaximumLength(500);
RuleFor(x => x.OperatingSystem).IsInEnum();
RuleFor(x => x.Description).MaximumLength(300);
RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0);
}
}
@@ -0,0 +1,24 @@
using LiteCqrs;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Audit;
namespace PnvPanel.Application.Admin.Audit;
public sealed record ListAuditLogsQuery(
int Page,
int PageSize,
AuditSource? Source,
string? TargetType,
string? Action
) : IQuery<Result<PagedList<AuditLogDto>>>;
public sealed record AuditLogDto(
long Id,
Guid? ActorId,
string Action,
string TargetType,
string TargetId,
string? Metadata,
AuditSource Source,
DateTimeOffset CreatedAt
);
@@ -0,0 +1,44 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Audit;
public sealed class ListAuditLogsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListAuditLogsQuery, Result<PagedList<AuditLogDto>>>
{
public async Task<Result<PagedList<AuditLogDto>>> Handle(
ListAuditLogsQuery query,
CancellationToken cancellationToken
)
{
var page = query.Page <= 0 ? 1 : query.Page;
var pageSize = query.PageSize is <= 0 or > 200 ? 50 : query.PageSize;
var logsQuery = dbContext.AuditLogs.AsNoTracking();
if (query.Source is { } source)
logsQuery = logsQuery.Where(a => a.Source == source);
if (!string.IsNullOrWhiteSpace(query.TargetType))
logsQuery = logsQuery.Where(a => a.TargetType == query.TargetType);
if (!string.IsNullOrWhiteSpace(query.Action))
logsQuery = logsQuery.Where(a => a.Action.Contains(query.Action));
var result = await logsQuery
.OrderByDescending(a => a.CreatedAt)
.Select(a => new AuditLogDto(
a.Id,
a.ActorId,
a.Action,
a.TargetType,
a.TargetId,
a.Metadata,
a.Source,
a.CreatedAt
))
.ToPagedListAsync(page, pageSize, cancellationToken);
return Result.Success(result);
}
}
@@ -0,0 +1,14 @@
using PnvPanel.Domain.Billing;
namespace PnvPanel.Application.Admin.Billing;
public sealed record AdminPaymentRequestDto(
Guid Id,
Guid UserId,
string UserName,
PaymentRequestKind Kind,
PaymentPeriod? Period,
int AmountSnapshot,
PaymentRequestStatus Status,
DateTimeOffset CreatedAt
);
@@ -0,0 +1,13 @@
using PnvPanel.Domain.Billing;
namespace PnvPanel.Application.Admin.Billing;
public sealed record BillingSettingsDto(
string RequisitesText,
int GraceDays,
bool DefaultBillingEnabledForNewRoles
)
{
public static BillingSettingsDto FromDomain(BillingSettings settings) =>
new(settings.RequisitesText, settings.GraceDays, settings.DefaultBillingEnabledForNewRoles);
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Billing;
public sealed record ConfirmPaymentRequestCommand(Guid RequestId) : ICommand<Result>;
@@ -0,0 +1,133 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Billing;
using PnvPanel.Application.Common.Concurrency;
using PnvPanel.Application.Common.Interfaces;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Audit;
using PnvPanel.Domain.Billing;
namespace PnvPanel.Application.Admin.Billing;
/// <summary>
/// Продлевает оплату (max(текущий PaidUntil, сейчас) + период), возвращает в Active конфиги,
/// приостановленные за неуплату (Expired), и синхронизирует ExpiresAt на все конфиги пользователя —
/// зеркало UnblockUserCommandHandler, но по статусу Expired (биллинг), а не Disabled (блокировка).
/// Проверка статуса + продление PaidUntil + Confirm() — под AdvisoryLock (по Id заявки): без неё
/// конфирм с сайта, гонящийся с конфирмом из Telegram по одной и той же заявке, могли бы оба пройти
/// проверку "ещё не решена" и оба продлить PaidUntil — двойное начисление за одну оплату.
/// </summary>
public sealed class ConfirmPaymentRequestCommandHandler(
IAppDbContext dbContext,
IIdentityService identityService,
IXuiPanelGateway gateway,
IRealtimeNotifier notifier,
ITelegramNotifier telegramNotifier,
ICurrentUser currentUser,
ILogger<ConfirmPaymentRequestCommandHandler> logger
) : ICommandHandler<ConfirmPaymentRequestCommand, Result>
{
public async Task<Result> Handle(
ConfirmPaymentRequestCommand command,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } adminId)
return Result.Failure(AuthErrors.Unauthorized);
var claimed = await AdvisoryLock.RunAsync(
dbContext,
command.RequestId,
lockedCancellationToken => ClaimAndConfirmAsync(command.RequestId, adminId, lockedCancellationToken),
cancellationToken
);
if (!claimed.IsSuccess)
return Result.Failure(claimed.Error);
var (request, newPaidUntil) = claimed.Value;
// PlanChangeTopUp — доплата разницы в цене при увеличении тарифа, не покупка времени: подтверждение
// не возвращает Expired-конфиги (это делает обычная Subscription-оплата/продление).
if (request.Kind == PaymentRequestKind.Subscription)
{
await BillingConfigResumer.ResumeConfigsAsync(
dbContext,
gateway,
notifier,
logger,
request.UserId,
newPaidUntil!.Value,
cancellationToken
);
}
dbContext.AuditLogs.Add(
AuditLog.Create(
adminId,
"PaymentConfirmed",
"PaymentRequest",
request.Id.ToString(),
metadata: null,
AuditSource.Web
)
);
var message =
request.Kind == PaymentRequestKind.PlanChangeTopUp
? "✅ Доплата за смену тарифа подтверждена."
: $"✅ Оплата подтверждена. Доступ продлён до {newPaidUntil:dd.MM.yyyy}.";
await telegramNotifier.NotifyUserAsync(request.UserId, message, null, cancellationToken);
return Result.Success();
}
/// <summary>Критическая секция под локом: проверка статуса, продление PaidUntil (для Subscription)
/// и сам Confirm() — всё атомарно вместе, чтобы гонка не могла продлить PaidUntil дважды. Внешний
/// I/O (гейтвей, Telegram) сюда намеренно не входит — см. AdvisoryLock.</summary>
private async Task<Result<(PaymentRequest Request, DateTimeOffset? NewPaidUntil)>> ClaimAndConfirmAsync(
Guid requestId,
Guid adminId,
CancellationToken cancellationToken
)
{
var request = await dbContext.PaymentRequests.FirstOrDefaultAsync(
r => r.Id == requestId,
cancellationToken
);
if (request is null)
return Result.Failure<(PaymentRequest, DateTimeOffset?)>(BillingErrors.RequestNotFound);
if (
request.Status
is not (PaymentRequestStatus.AwaitingPayment or PaymentRequestStatus.AwaitingConfirmation)
)
return Result.Failure<(PaymentRequest, DateTimeOffset?)>(BillingErrors.RequestNotDecidable);
if (request.Kind == PaymentRequestKind.PlanChangeTopUp)
{
request.Confirm(adminId);
return Result.Success<(PaymentRequest, DateTimeOffset?)>((request, null));
}
var profile = await identityService.GetProfileAsync(request.UserId, cancellationToken);
if (profile is null)
return Result.Failure<(PaymentRequest, DateTimeOffset?)>(AuthErrors.Unauthorized);
var now = DateTimeOffset.UtcNow;
var baseline = profile.BillingPaidUntil is { } paidUntil && paidUntil > now ? paidUntil : now;
var newPaidUntil = baseline.AddMonths(request.Period!.Value.ToMonths());
var extendResult = await identityService.ExtendBillingPaidUntilAsync(
request.UserId,
newPaidUntil,
cancellationToken
);
if (!extendResult.IsSuccess)
return Result.Failure<(PaymentRequest, DateTimeOffset?)>(extendResult.Error);
request.Confirm(adminId);
return Result.Success<(PaymentRequest, DateTimeOffset?)>((request, newPaidUntil));
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Billing;
public sealed record GetBillingSettingsQuery : IQuery<Result<BillingSettingsDto>>;
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Billing;
namespace PnvPanel.Application.Admin.Billing;
public sealed class GetBillingSettingsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<GetBillingSettingsQuery, Result<BillingSettingsDto>>
{
public async Task<Result<BillingSettingsDto>> Handle(
GetBillingSettingsQuery query,
CancellationToken cancellationToken
)
{
var settings = await dbContext
.BillingSettings.AsNoTracking()
.FirstOrDefaultAsync(cancellationToken);
// Ещё не сохранялось ни разу — отдаём дефолты, а не ошибку (см. PricingSettings).
return Result.Success(
settings is null
? new BillingSettingsDto(string.Empty, BillingSettings.DefaultGraceDays, false)
: BillingSettingsDto.FromDomain(settings)
);
}
}
@@ -0,0 +1,8 @@
using LiteCqrs;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Billing;
/// <summary>Админ дарит пользователю N дней подписки — продлевает BillingPaidUntil от
/// max(текущий, сейчас), возвращает приостановленные конфиги, шлёт уведомление пользователю.</summary>
public sealed record GrantBillingGiftCommand(Guid UserId, int Days) : ICommand<Result>;
@@ -0,0 +1,76 @@
using Microsoft.Extensions.Logging;
using PnvPanel.Application.Admin.Users;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Billing;
using PnvPanel.Application.Common.Interfaces;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Audit;
namespace PnvPanel.Application.Admin.Billing;
public sealed class GrantBillingGiftCommandHandler(
IAppDbContext dbContext,
IIdentityService identityService,
IXuiPanelGateway gateway,
IRealtimeNotifier notifier,
ITelegramNotifier telegramNotifier,
ICurrentUser currentUser,
ILogger<GrantBillingGiftCommandHandler> logger
) : ICommandHandler<GrantBillingGiftCommand, Result>
{
public async Task<Result> Handle(GrantBillingGiftCommand command, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } adminId)
return Result.Failure(AuthErrors.Unauthorized);
var profile = await identityService.GetProfileAsync(command.UserId, cancellationToken);
if (profile is null)
return Result.Failure(UserErrors.NotFound);
if (!profile.BillingEnabled)
return Result.Failure(BillingErrors.NotEnabled);
var now = DateTimeOffset.UtcNow;
var baseline = profile.BillingPaidUntil is { } paidUntil && paidUntil > now ? paidUntil : now;
var newPaidUntil = baseline.AddDays(command.Days);
var extendResult = await identityService.ExtendBillingPaidUntilAsync(
command.UserId,
newPaidUntil,
cancellationToken
);
if (!extendResult.IsSuccess)
return extendResult;
await BillingConfigResumer.ResumeConfigsAsync(
dbContext,
gateway,
notifier,
logger,
command.UserId,
newPaidUntil,
cancellationToken
);
dbContext.AuditLogs.Add(
AuditLog.Create(
adminId,
"BillingGiftGranted",
"User",
command.UserId.ToString(),
metadata: $"{{\"days\":{command.Days}}}",
AuditSource.Web
)
);
await telegramNotifier.NotifyUserAsync(
command.UserId,
$"🎁 Вам подарено {command.Days} дн. подписки! Доступ продлён до {newPaidUntil:dd.MM.yyyy}.",
"/billing",
cancellationToken
);
return Result.Success();
}
}
@@ -0,0 +1,11 @@
using FluentValidation;
namespace PnvPanel.Application.Admin.Billing;
public sealed class GrantBillingGiftCommandValidator : AbstractValidator<GrantBillingGiftCommand>
{
public GrantBillingGiftCommandValidator()
{
RuleFor(x => x.Days).GreaterThan(0).LessThanOrEqualTo(365);
}
}
@@ -0,0 +1,13 @@
using LiteCqrs;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Billing;
namespace PnvPanel.Application.Admin.Billing;
public sealed record ListPaymentRequestsQuery(
PaymentRequestStatus? StatusFilter,
PaymentRequestKind? KindFilter,
string? Search,
int Page,
int PageSize
) : IQuery<Result<PagedList<AdminPaymentRequestDto>>>;
@@ -0,0 +1,63 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Billing;
public sealed class ListPaymentRequestsQueryHandler(
IAppDbContext dbContext,
IIdentityService identityService
) : IQueryHandler<ListPaymentRequestsQuery, Result<PagedList<AdminPaymentRequestDto>>>
{
public async Task<Result<PagedList<AdminPaymentRequestDto>>> Handle(
ListPaymentRequestsQuery 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.PaymentRequests.AsNoTracking();
if (query.StatusFilter is { } status)
requestsQuery = requestsQuery.Where(r => r.Status == status);
if (query.KindFilter is { } kind)
requestsQuery = requestsQuery.Where(r => r.Kind == kind);
if (!string.IsNullOrWhiteSpace(query.Search))
{
// PaymentRequest хранит только UserId — резолвим совпадающих пользователей ДО пагинации
// (иначе "поиск по имени" фильтровал бы уже отобранную страницу, а не весь набор).
var matchingUserIds = await identityService.FindUserIdsByUserNameAsync(
query.Search.Trim(),
cancellationToken
);
requestsQuery = requestsQuery.Where(r => matchingUserIds.Contains(r.UserId));
}
var page1 = await requestsQuery
.OrderByDescending(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 AdminPaymentRequestDto(
r.Id,
r.UserId,
userNames.GetValueOrDefault(r.UserId, "?"),
r.Kind,
r.Period,
r.AmountSnapshot,
r.Status,
r.CreatedAt
))
.ToList();
return Result.Success(
new PagedList<AdminPaymentRequestDto>(items, page1.Total, page1.Page, page1.PageSize)
);
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Billing;
public sealed record RejectPaymentRequestCommand(Guid RequestId, string? Reason) : ICommand<Result>;
@@ -0,0 +1,130 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Billing;
using PnvPanel.Application.Common.Concurrency;
using PnvPanel.Application.Common.Interfaces;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Audit;
using PnvPanel.Domain.Billing;
namespace PnvPanel.Application.Admin.Billing;
/// <summary>Проверка статуса + Reject() — под AdvisoryLock (по Id заявки), тот же приём, что и в
/// ConfirmPaymentRequestCommandHandler — без него отклонение с сайта, гонящееся с отклонением из
/// Telegram, могли бы оба пройти проверку "ещё не решена".</summary>
public sealed class RejectPaymentRequestCommandHandler(
IAppDbContext dbContext,
IIdentityService identityService,
IXuiPanelGateway gateway,
IRealtimeNotifier notifier,
ITelegramNotifier telegramNotifier,
ICurrentUser currentUser,
ILogger<RejectPaymentRequestCommandHandler> logger
) : ICommandHandler<RejectPaymentRequestCommand, Result>
{
public async Task<Result> Handle(
RejectPaymentRequestCommand command,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } adminId)
return Result.Failure(AuthErrors.Unauthorized);
var claimed = await AdvisoryLock.RunAsync(
dbContext,
command.RequestId,
async lockedCancellationToken =>
{
var fresh = await dbContext.PaymentRequests.FirstOrDefaultAsync(
r => r.Id == command.RequestId,
lockedCancellationToken
);
if (fresh is null)
return Result.Failure<PaymentRequest>(BillingErrors.RequestNotFound);
if (
fresh.Status
is not (PaymentRequestStatus.AwaitingPayment or PaymentRequestStatus.AwaitingConfirmation)
)
return Result.Failure<PaymentRequest>(BillingErrors.RequestNotDecidable);
fresh.Reject(adminId, command.Reason);
return Result.Success(fresh);
},
cancellationToken
);
if (!claimed.IsSuccess)
return Result.Failure(claimed.Error);
var request = claimed.Value;
// Пока заявка висела на проверке, конфиги могли быть временно "защищены" на панели
// (ProtectPendingConfigsAsync — enable/expiresAt подвинуты вперёд без изменения локального
// статуса). Раз оплату отклонили и период всё ещё просрочен, а других Subscription-заявок на
// проверке нет — снимаем защиту немедленно, не дожидаясь часового тика BillingService.
if (request.Kind == PaymentRequestKind.Subscription)
await SuspendIfStillUnpaidAsync(request.Id, request.UserId, cancellationToken);
dbContext.AuditLogs.Add(
AuditLog.Create(
adminId,
"PaymentRejected",
"PaymentRequest",
request.Id.ToString(),
metadata: null,
AuditSource.Web
)
);
var reasonSuffix = string.IsNullOrWhiteSpace(command.Reason)
? string.Empty
: $"\nПричина: {command.Reason}";
await telegramNotifier.NotifyUserAsync(
request.UserId,
$"❌ Заявка на оплату отклонена.{reasonSuffix}",
null,
cancellationToken
);
return Result.Success();
}
private async Task SuspendIfStillUnpaidAsync(
Guid rejectedRequestId,
Guid userId,
CancellationToken cancellationToken
)
{
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
if (profile is not { BillingEnabled: true })
return;
if (profile.BillingPaidUntil is { } paidUntil && paidUntil > DateTimeOffset.UtcNow)
return;
// Саму отклоняемую заявку исключаем явно: request.Reject(...) уже поменял её статус на
// Rejected в трекере EF, но до SaveChangesAsync (в конце пайплайна) в БД всё ещё лежит старое
// значение AwaitingConfirmation — без Id-исключения запрос ниже ложно принял бы её за "ещё
// одну" висящую заявку и никогда бы не приостанавливал конфиги.
var hasOtherPending = await dbContext.PaymentRequests.AnyAsync(
r =>
r.Id != rejectedRequestId
&& r.UserId == userId
&& r.Kind == PaymentRequestKind.Subscription
&& r.Status == PaymentRequestStatus.AwaitingConfirmation,
cancellationToken
);
if (hasOtherPending)
return;
await BillingConfigResumer.SuspendConfigsAsync(
dbContext,
gateway,
notifier,
logger,
userId,
cancellationToken
);
}
}
@@ -0,0 +1,10 @@
using LiteCqrs;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Billing;
public sealed record UpdateBillingSettingsCommand(
string RequisitesText,
int GraceDays,
bool DefaultBillingEnabledForNewRoles
) : ICommand<Result<BillingSettingsDto>>;
@@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Billing;
namespace PnvPanel.Application.Admin.Billing;
public sealed class UpdateBillingSettingsCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateBillingSettingsCommand, Result<BillingSettingsDto>>
{
public async Task<Result<BillingSettingsDto>> Handle(
UpdateBillingSettingsCommand command,
CancellationToken cancellationToken
)
{
var settings = await dbContext.BillingSettings.FirstOrDefaultAsync(cancellationToken);
if (settings is null)
{
settings = BillingSettings.CreateDefault();
dbContext.BillingSettings.Add(settings);
}
settings.Update(
command.RequisitesText,
command.GraceDays,
command.DefaultBillingEnabledForNewRoles
);
return Result.Success(BillingSettingsDto.FromDomain(settings));
}
}
@@ -0,0 +1,13 @@
using FluentValidation;
namespace PnvPanel.Application.Admin.Billing;
public sealed class UpdateBillingSettingsCommandValidator
: AbstractValidator<UpdateBillingSettingsCommand>
{
public UpdateBillingSettingsCommandValidator()
{
RuleFor(x => x.RequisitesText).NotEmpty().MaximumLength(4000);
RuleFor(x => x.GraceDays).GreaterThanOrEqualTo(0).LessThanOrEqualTo(365);
}
}
@@ -0,0 +1,22 @@
using PnvPanel.Domain.Configs;
using PnvPanel.Domain.Inbounds;
namespace PnvPanel.Application.Admin.Configs;
/// <summary>Строка глобального списка конфигов для админа — в отличие от VpnConfigDto (self-service)
/// содержит владельца и ноду, т.к. список не скоупится одним пользователем.</summary>
public sealed record AdminVpnConfigDto(
Guid Id,
Guid UserId,
string UserName,
string? Label,
string ClientEmail,
VpnProtocol Protocol,
string Location,
string NodeName,
long UsedUpBytes,
long UsedDownBytes,
DateTimeOffset? ExpiresAt,
ConfigStatus Status,
DateTimeOffset CreatedAt
);
@@ -0,0 +1,19 @@
using PnvPanel.Application.Common.Interfaces;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Configs;
using PnvPanel.Domain.Inbounds;
namespace PnvPanel.Application.Admin.Configs;
/// <summary><paramref name="Search"/> матчится по ClientEmail/Label — это то, по чему админ сверяет
/// конфиг с записью в 3x-ui, а не по владельцу (для поиска по пользователю есть /admin/users).
/// <paramref name="NodeId"/> фильтрует по ноде инбаунда конфига (JOIN Inbounds).</summary>
public sealed record ListAllConfigsQuery(
int Page,
int PageSize,
string? Search,
ConfigStatus? Status,
VpnProtocol? Protocol,
Guid? NodeId
) : IQuery<Result<PagedList<AdminVpnConfigDto>>>;
@@ -0,0 +1,104 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Configs;
public sealed class ListAllConfigsQueryHandler(
IAppDbContext dbContext,
IIdentityService identityService
) : IQueryHandler<ListAllConfigsQuery, Result<PagedList<AdminVpnConfigDto>>>
{
public async Task<Result<PagedList<AdminVpnConfigDto>>> Handle(
ListAllConfigsQuery query,
CancellationToken cancellationToken
)
{
var page = query.Page <= 0 ? 1 : query.Page;
var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize;
var configsQuery = dbContext.VpnConfigs.AsNoTracking();
if (query.Status is { } status)
configsQuery = configsQuery.Where(c => c.Status == status);
if (query.Protocol is { } protocol)
configsQuery = configsQuery.Where(c => c.Protocol == protocol);
if (query.NodeId is { } nodeId)
{
var nodeInboundIds = await dbContext
.Inbounds.AsNoTracking()
.Where(i => i.NodeId == nodeId)
.Select(i => i.Id)
.ToListAsync(cancellationToken);
configsQuery = configsQuery.Where(c => nodeInboundIds.Contains(c.InboundId));
}
if (!string.IsNullOrWhiteSpace(query.Search))
{
var search = query.Search.Trim();
configsQuery = configsQuery.Where(c =>
c.ClientEmail.Contains(search) || (c.Label != null && c.Label.Contains(search))
);
}
var pageResult = await configsQuery
.OrderByDescending(c => c.CreatedAt)
.ToPagedListAsync(page, pageSize, cancellationToken);
var inboundIds = pageResult.Items.Select(c => c.InboundId).Distinct().ToList();
var inbounds = (
await dbContext
.Inbounds.AsNoTracking()
.Where(i => inboundIds.Contains(i.Id))
.ToListAsync(cancellationToken)
).ToDictionary(i => i.Id);
var nodeIds = inbounds.Values.Select(i => i.NodeId).Distinct().ToList();
var nodes = (
await dbContext
.Nodes.AsNoTracking()
.Where(n => nodeIds.Contains(n.Id))
.ToListAsync(cancellationToken)
).ToDictionary(n => n.Id);
var userNames = await identityService.GetUserNamesAsync(
pageResult.Items.Select(c => c.UserId).Distinct().ToList(),
cancellationToken
);
var items = pageResult
.Items.Select(c =>
{
var inbound = inbounds.GetValueOrDefault(c.InboundId);
var node = inbound is null ? null : nodes.GetValueOrDefault(inbound.NodeId);
return new AdminVpnConfigDto(
c.Id,
c.UserId,
userNames.GetValueOrDefault(c.UserId, "?"),
c.Label,
c.ClientEmail,
c.Protocol,
inbound?.DisplayName ?? inbound?.Remark ?? "?",
node?.Name ?? "?",
c.UsedUpBytes,
c.UsedDownBytes,
c.ExpiresAt,
c.Status,
c.CreatedAt
);
})
.ToList();
return Result.Success(
new PagedList<AdminVpnConfigDto>(
items,
pageResult.Total,
pageResult.Page,
pageResult.PageSize
)
);
}
}
@@ -0,0 +1,8 @@
using LiteCqrs;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Inbounds;
/// <summary>Force-удаление недоступного на панели инбаунда вместе с каскадным отзывом ещё живых
/// конфигов на нём — см. DeleteInboundCommandHandler.</summary>
public sealed record DeleteInboundCommand(Guid InboundId) : ICommand<Result>;
@@ -0,0 +1,67 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Audit;
using PnvPanel.Domain.Configs;
namespace PnvPanel.Application.Admin.Inbounds;
/// <summary>Удаляет инбаунд, которого больше нет на панели (см. SyncNodeCommandHandler —
/// автоматически он не удаляется, пока по нему есть VpnConfig, чтобы не потерять ссылку на историю).
/// Разрешено только для `IsAvailable == false` — живой, всё ещё синхронизируемый инбаунд через этот
/// путь не удалить. Перед удалением каскадно отзывает все ещё не-Revoked конфиги на нём: панельный
/// клиент всё равно недостижим (инбаунда для него на 3x-ui уже нет), поэтому Revoke — чисто локальная
/// операция, без вызова гейтвея (см. RevokeVpnConfigCommandHandler для того же паттерна). Уже
/// Revoked-конфиги при этом остаются в БД с InboundId, указывающим на удалённую запись — это
/// осознанный компромисс (см. domain-model.md): нигде в пользовательских списках Revoked-конфиги не
/// показываются, а в админском списке отсутствующий инбаунд отображается как "?".</summary>
public sealed class DeleteInboundCommandHandler(
IAppDbContext dbContext,
IRealtimeNotifier notifier,
ICurrentUser currentUser
) : ICommandHandler<DeleteInboundCommand, Result>
{
public async Task<Result> Handle(DeleteInboundCommand 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 (inbound.IsAvailable)
return Result.Failure(InboundErrors.StillAvailable);
var configs = await dbContext
.VpnConfigs.Where(c => c.InboundId == inbound.Id && c.Status != ConfigStatus.Revoked)
.ToListAsync(cancellationToken);
foreach (var config in configs)
{
config.Revoke();
await notifier.NotifyConfigStatusChangedAsync(
config.UserId,
config.Id,
config.Status,
cancellationToken
);
}
dbContext.Inbounds.Remove(inbound);
dbContext.AuditLogs.Add(
AuditLog.Create(
currentUser.UserId,
"InboundDeleted",
"Inbound",
inbound.Id.ToString(),
metadata: configs.Count > 0 ? $"{{\"revokedConfigs\":{configs.Count}}}" : null,
AuditSource.Web
)
);
return Result.Success();
}
}
@@ -3,11 +3,31 @@ using PnvPanel.Domain.Inbounds;
namespace PnvPanel.Application.Admin.Inbounds; namespace PnvPanel.Application.Admin.Inbounds;
public sealed record InboundDto( public sealed record InboundDto(
Guid Id, Guid NodeId, string RemoteInboundId, VpnProtocol Protocol, string Remark, int Port, Guid Id,
bool IsPublished, string? DisplayName, int? MaxClients, IReadOnlyList<Guid> AllowedRoleIds, Guid NodeId,
DateTimeOffset? LastSyncAt) string RemoteInboundId,
VpnProtocol Protocol,
string Remark,
int Port,
bool IsPublished,
bool IsAvailable,
string? DisplayName,
IReadOnlyList<Guid> AllowedRoleIds,
DateTimeOffset? LastSyncAt
)
{ {
public static InboundDto FromDomain(Inbound inbound) => new( public static InboundDto FromDomain(Inbound inbound) =>
inbound.Id, inbound.NodeId, inbound.RemoteInboundId, inbound.Protocol, inbound.Remark, inbound.Port, new(
inbound.IsPublished, inbound.DisplayName, inbound.MaxClients, inbound.AllowedRoleIds, inbound.LastSyncAt); inbound.Id,
inbound.NodeId,
inbound.RemoteInboundId,
inbound.Protocol,
inbound.Remark,
inbound.Port,
inbound.IsPublished,
inbound.IsAvailable,
inbound.DisplayName,
inbound.AllowedRoleIds,
inbound.LastSyncAt
);
} }
@@ -4,5 +4,13 @@ namespace PnvPanel.Application.Admin.Inbounds;
public static class InboundErrors public static class InboundErrors
{ {
public static readonly Error NotFound = Error.NotFound("Inbounds.NotFound", "Inbound не найден."); public static readonly Error NotFound = Error.NotFound(
"Inbounds.NotFound",
"Inbound не найден."
);
public static readonly Error StillAvailable = Error.Validation(
"Inbounds.StillAvailable",
"Inbound всё ещё существует на панели — удалить можно только недоступные (IsAvailable=false)."
);
} }
@@ -1,4 +1,4 @@
using PnvPanel.Application.Common.Messaging; using LiteCqrs;
using PnvPanel.Application.Common.Models; using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Inbounds; namespace PnvPanel.Application.Admin.Inbounds;
@@ -1,19 +1,25 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging; using LiteCqrs;
using PnvPanel.Application.Common.Models; using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Inbounds; namespace PnvPanel.Application.Admin.Inbounds;
public sealed class ListInboundsQueryHandler(IAppDbContext dbContext) : IQueryHandler<ListInboundsQuery, Result<IReadOnlyList<InboundDto>>> public sealed class ListInboundsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListInboundsQuery, Result<IReadOnlyList<InboundDto>>>
{ {
public async Task<Result<IReadOnlyList<InboundDto>>> Handle(ListInboundsQuery query, CancellationToken cancellationToken) public async Task<Result<IReadOnlyList<InboundDto>>> Handle(
ListInboundsQuery query,
CancellationToken cancellationToken
)
{ {
var inboundsQuery = dbContext.Inbounds.AsNoTracking(); var inboundsQuery = dbContext.Inbounds.AsNoTracking();
if (query.NodeId is { } nodeId) if (query.NodeId is { } nodeId)
inboundsQuery = inboundsQuery.Where(i => i.NodeId == nodeId); inboundsQuery = inboundsQuery.Where(i => i.NodeId == nodeId);
var inbounds = await inboundsQuery.OrderBy(i => i.Remark).ToListAsync(cancellationToken); var inbounds = await inboundsQuery.OrderBy(i => i.Remark).ToListAsync(cancellationToken);
return Result.Success<IReadOnlyList<InboundDto>>(inbounds.Select(InboundDto.FromDomain).ToList()); return Result.Success<IReadOnlyList<InboundDto>>(
inbounds.Select(InboundDto.FromDomain).ToList()
);
} }
} }
@@ -1,8 +1,11 @@
using PnvPanel.Application.Common.Messaging; using LiteCqrs;
using PnvPanel.Application.Common.Models; using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Inbounds; namespace PnvPanel.Application.Admin.Inbounds;
public sealed record PublishInboundCommand( public sealed record PublishInboundCommand(
Guid InboundId, bool IsPublished, string? DisplayName, IReadOnlyList<Guid> AllowedRoleIds, int? MaxClients) Guid InboundId,
: ICommand<Result<InboundDto>>; bool IsPublished,
string? DisplayName,
IReadOnlyList<Guid> AllowedRoleIds
) : ICommand<Result<InboundDto>>;
@@ -1,24 +1,42 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging; using LiteCqrs;
using PnvPanel.Application.Common.Models; using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Audit;
namespace PnvPanel.Application.Admin.Inbounds; namespace PnvPanel.Application.Admin.Inbounds;
public sealed class PublishInboundCommandHandler(IAppDbContext dbContext) public sealed class PublishInboundCommandHandler(IAppDbContext dbContext, ICurrentUser currentUser)
: ICommandHandler<PublishInboundCommand, Result<InboundDto>> : ICommandHandler<PublishInboundCommand, Result<InboundDto>>
{ {
public async Task<Result<InboundDto>> Handle(PublishInboundCommand command, CancellationToken cancellationToken) public async Task<Result<InboundDto>> Handle(
PublishInboundCommand command,
CancellationToken cancellationToken
)
{ {
var inbound = await dbContext.Inbounds.FirstOrDefaultAsync(i => i.Id == command.InboundId, cancellationToken); var inbound = await dbContext.Inbounds.FirstOrDefaultAsync(
i => i.Id == command.InboundId,
cancellationToken
);
if (inbound is null) if (inbound is null)
return Result.Failure<InboundDto>(InboundErrors.NotFound); return Result.Failure<InboundDto>(InboundErrors.NotFound);
if (command.IsPublished) if (command.IsPublished)
inbound.Publish(command.DisplayName, command.AllowedRoleIds, command.MaxClients); inbound.Publish(command.DisplayName, command.AllowedRoleIds);
else else
inbound.Unpublish(); inbound.Unpublish();
dbContext.AuditLogs.Add(
AuditLog.Create(
currentUser.UserId,
command.IsPublished ? "InboundPublished" : "InboundUnpublished",
"Inbound",
inbound.Id.ToString(),
metadata: null,
AuditSource.Web
)
);
return Result.Success(InboundDto.FromDomain(inbound)); return Result.Success(InboundDto.FromDomain(inbound));
} }
} }
@@ -7,6 +7,5 @@ public sealed class PublishInboundCommandValidator : AbstractValidator<PublishIn
public PublishInboundCommandValidator() public PublishInboundCommandValidator()
{ {
RuleFor(x => x.DisplayName).MaximumLength(100); RuleFor(x => x.DisplayName).MaximumLength(100);
RuleFor(x => x.MaxClients).GreaterThan(0).When(x => x.MaxClients.HasValue);
} }
} }

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