Compare commits

..
89 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
729 changed files with 62449 additions and 3120 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 (должен быть примонтирован и переживать перезапуск контейнера)
DataProtection__KeyRingPath=/app/keys
# ── Файловое хранилище (вложения тикетов поддержки) ─────────────────────────
# Путь на диске для скриншотов к тикетам — должен быть на постоянном томе (см. docker-compose.yml,
# FileStorage__RootPath там уже задан явно под volume 'ticket_uploads', эту строку трогать не нужно).
# FileStorage__RootPath=/app/uploads
# ── Сид администратора (создаётся при первом старте, если не существует) ───
# Логин в систему — по username. Email в системе не используется.
AdminSeed__Username=admin
AdminSeed__Password=change-me-strong-admin-password
# Telegram id(ы) администраторов (через запятую). Дают права админа в боте
# и получают уведомления о запросах на активацию. Узнать id: @userinfobot.
AdminSeed__TelegramUserIds=123456789
# ── Роли по умолчанию ─────────────────────────────────────────────────────
# Квота конфигов для системной роли "user" (выдаётся при регистрации).
# Стартовая квота конфигов нового пользователя (AppUser.ConfigQuota при регистрации; далее меняется
# самостоятельно через страницу тарифа /plan, не привязана к роли).
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-бот ──────────────────────────────────────────────────────────
# Если BotToken пуст — бот не стартует, панель работает без него.
# Если BotToken пуст — бот не стартует, панель работает без него. Транспорт — только long polling
# (webhook не реализован, отдельного режима/URL для него нет). Username бота для диплинков
# (кнопка «Привязать Telegram»/QR) панель узнаёт сама через Bot API (getMe) — задавать его отдельно
# не нужно и негде (раньше был Telegram__BotUsername — убран, чтобы не ломать диплинк опечаткой/пробелом).
Telegram__BotToken=
Telegram__BotUsername=PnvPanelBot
Telegram__Mode=LongPolling
# Для Mode=Webhook:
# Telegram__WebhookUrl=https://panel.example.com/tg/webhook
# Telegram__WebhookSecret=change-me-webhook-secret
# ── Приложение ────────────────────────────────────────────────────────────
# Публичный URL сайта (для deep-link'ов бота и ссылок).
App__PublicSiteUrl=https://panel.example.com
# CORS-источники (для dev; в проде фронт и бек — один origin).
App__CorsOrigins=http://localhost:5173
# Telegram id(ы) администраторов (через запятую). Дают права админа в боте (кнопки активации)
# и получают уведомления о запросах на активацию. Узнать id: @userinfobot.
# Не связано с сид-админом выше — привязка Telegram к сид-админу делается вручную в UI.
Telegram__AdminTelegramUserIds=123456789
# Прокси для запросов к Bot API (обычно socks5://[user:pass@]host:port) — на случай, если Telegram
# заблокирован напрямую с сети сервера. Пусто (по умолчанию) — без прокси, прямое подключение.
# Telegram__ProxyUrl=socks5://127.0.0.1:39372
# Ссылка на сайт панели — кнопка «🌐 Сайт панели» в главном меню бота. Пусто — кнопки не будет.
# Telegram__PublicSiteUrl=https://dashboard.example.com
# ── ASP.NET Core ──────────────────────────────────────────────────────────
ASPNETCORE_ENVIRONMENT=Production
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
+108 -96
View File
@@ -4,34 +4,38 @@
## Что это
**PnvPanel** — self-service портал для VPN-конфигураций. Пользователи сами создают себе конфиги
(VLESS/VMess/Trojan/Shadowsocks), админ управляет серверами и пользователями. Есть **Telegram-бот**
(ссылка на сайт, просмотр конфигов, passwordless-вход через привязку Telegram). Бэкенд оркестрирует
панели **3x-ui** через библиотеку [`ThreeXui.Net`](https://github.com/mrleo1nid/ThreeXui.Net) и
хранит свою проекцию домена в PostgreSQL. Живые обновления — по SignalR. Приложение (фронт + бек +
бот) поставляется **единым Docker-образом**; PostgreSQL — отдельным контейнером в compose.
**PnvPanel** — self-service портал для VPN-конфигураций (VLESS/VMess/Trojan/Shadowsocks): пользователи
сами создают конфиги, админ управляет серверами и пользователями. Есть **Telegram-бот** (ссылка на
сайт, просмотр конфигов, passwordless-вход). Бэкенд оркестрирует панели **3x-ui** через
[`ThreeXui.Net`](https://github.com/mrleo1nid/ThreeXui.Net), CQRS — через собственную библиотеку
[`LiteCqrs.Net`](https://github.com/mrleo1nid/LiteCqrs.Net) (NuGet-пакет), и хранит проекцию домена
в PostgreSQL.
Живые обновления — SignalR. Поставка — **единый Docker-образ** (фронт+бек+бот) + PostgreSQL в compose.
> **Статус: проектирование.** Код ещё не написан. Актуальны только документация и этот файл.
> При старте реализации следуй [`docs/roadmap.md`](docs/roadmap.md) (этапы M0…M6).
> Собрано и покрыто тестами, единый образ и compose-стек проверены живьём. Есть опциональный биллинг
> (подписка по сроку, per-роль). Осознанно не реализовано: лимиты трафика на конфиг, полное
> самообслуживание в боте — см. [tech-stack.md](docs/tech-stack.md).
## Документация (single source of truth)
Прежде чем менять архитектуру или добавлять фичу — свериться с [`docs/`](docs/README.md):
- [Vision](docs/vision.md) · [Architecture](docs/architecture.md) · [Domain Model](docs/domain-model.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) · [Roadmap](docs/roadmap.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) ·
[Frontend](docs/frontend.md) · [Telegram Bot](docs/telegram-bot.md) · [API Design](docs/api-design.md)
**Держи доки в синхроне с кодом.** Меняешь контракт/архитектуру — обнови соответствующий док в том же изменении.
## Стек
- **Backend**: C# / .NET 10, ASP.NET Core Web API, Clean Architecture, CQRS (**собственный тонкий
диспетчер**, без MediatR), EF Core 10 + Npgsql (PostgreSQL), ASP.NET Core Identity + JWT, SignalR,
FluentValidation, Mapster, **Serilog** (логирование).
- **Frontend**: React 19 + Vite + TypeScript, TanStack Query/Router, shadcn/ui + Tailwind CSS v4,
Zustand, react-hook-form + zod, @microsoft/signalr, Recharts. Пакетный менеджер — pnpm.
- **Telegram**: Telegram.Bot, бот как `BackgroundService` **в процессе Api** (long polling).
- **Backend**: C# / .NET 10, ASP.NET Core Web API, Clean Architecture, CQRS (через
[`LiteCqrs.Net`](https://github.com/mrleo1nid/LiteCqrs.Net) — собственная лёгкая
CQRS-библиотека, альтернатива MediatR с явным разделением Command/Query), EF Core 10 + Npgsql,
ASP.NET Core Identity + JWT, SignalR, FluentValidation,
Serilog. Маппинг DTO вручную (`FromDomain(...)`, без Mapster). OpenAPI — нативный
`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.
## Архитектура — жёсткие правила
@@ -39,102 +43,112 @@
Слои и направление зависимостей: **Api → Infrastructure → Application → Domain** (внутрь).
- **Domain** — без внешних зависимостей. Rich model: инварианты в сущностях (приватные сеттеры,
фабричные методы, поведенческие методы). Никаких EF/HTTP/Identity здесь.
- **Application** — CQRS-хендлеры, DTO, валидаторы, **порты** (интерфейсы). Зависит только от Domain.
Никаких `Npgsql`/`SignalR`/`ThreeXui.Net` только их интерфейсы (`IAppDbContext`, `IXuiPanelGateway`,
фабричные/поведенческие методы). Никаких EF/HTTP/Identity.
- **Application** — CQRS-хендлеры, DTO, валидаторы, **порты** (интерфейсы). Зависит только от Domain
никаких `Npgsql`/`SignalR`/`ThreeXui.Net`, только их интерфейсы (`IAppDbContext`, `IXuiPanelGateway`,
`IRealtimeNotifier`, `ISecretProtector`, `ICurrentUser`, ...).
- **Infrastructure** — реализации портов: EF Core, Identity/JWT, `XuiPanelGateway`, SignalR-пуш,
фоновые сервисы, шифрование секретов.
- **Api** — Minimal API эндпоинты (по фичам), SignalR-хабы, middleware, DI composition root.
Обязательно:
- CQRS: команды меняют состояние и идут в транзакции (UnitOfWorkBehavior); запросы только читают
(`AsNoTracking` + проекция в DTO). Диспетчер — **собственный** (`ISender`/`ICommandHandler`/
`IQueryHandler`, регистрация хендлеров через DI), без внешних CQRS-библиотек.
- Управляемые ошибки — через `Result<T>`, не исключениями. Исключения — только для исключительного.
- Команды меняют состояние в транзакции (`UnitOfWorkBehavior`); запросы только читают (`AsNoTracking` +
проекция в DTO). Диспетчер — **собственный** (`ISender`/`ICommandHandler`/`IQueryHandler`, DI-регистрация).
- Управляемые ошибки — через `Result<T>`, не исключениями (исключения только для исключительного).
- Валидация — FluentValidation через `ValidationBehavior`; хендлер не перепроверяет формат ввода.
- Всё I/O асинхронно, `CancellationToken` пробрасывается до EF/HTTP. Никаких `.Result`/`.Wait()`.
- Nullable reference types включены; предупреждения анализаторов не игнорировать.
## Интеграция с 3x-ui
- Только через порт `IXuiPanelGateway`. `ThreeXui.Net` регистрируется на один `BaseAddress`, а нод
много → гейтвей держит **клиента per-node** (кэш по `NodeId`), создавая его из расшифрованных
`NodeCredentials`. Детали — в [architecture.md](docs/architecture.md#интеграция-с-3x-ui-threexuinet).
- Пароли нод **шифруются at-rest** (`ISecretProtector`), расшифровка только внутри Infrastructure,
никогда не в логах/ответах API.
- Только через порт `IXuiPanelGateway`. Один `BaseAddress` в `ThreeXui.Net`, а нод много → гейтвей
держит **клиента per-node** (кэш по `NodeId`) из расшифрованных `NodeCredentials`. Детали —
[architecture.md](docs/architecture.md#интеграция-с-3x-ui-threexuinet).
- Пароли нод **шифруются at-rest** (`ISecretProtector`), расшифровка только в Infrastructure, никогда в логах/ответах.
- Недоступность ноды → `Result.Failure`/`NodeStatus.Offline`, не 500 наружу.
- Операции с 3x-ui идемпотентны; при частичном сбое (клиент создан в панели, но упала БД) — компенсация.
- Операции идемпотентны; при частичном сбое (клиент создан в панели, упала БД) — компенсация.
## Роли, активация, сидинг
## Домен: роли, активация, конфиги
- **Роли динамические**: `AppRole : IdentityRole<Guid>` + поле `MaxConfigs` (квота на число конфигов).
Квота — **на роли, а не на `Plan`**. **У пользователя ровно одна роль**; квота = `MaxConfigs` его
роли (`admin` — без лимита). Системные роли (`admin`/`user`) не удалять/переименовывать.
- **Активация**: новый пользователь `IsActivated = false`, роль `user`. Конфиги может создавать
только активированный. `ActivationRequest` (с комментарием заявителя) одобряет админ на сайте
**или** в Telegram — одними и теми же командами (`ApproveActivationCommand`/`RejectActivationCommand`).
- **Инбаунды по ролям**: `Inbound.AllowedRoles` (M:N). При создании конфига доменный инвариант
проверяет: активирован + под квотой роли + роль входит в `AllowedRoles` инбаунда + нода включена.
Проверку квоты делать **в транзакции** (гонки параллельных созданий).
- **Понижение роли — грандфазеринг**: смена на меньшую квоту разрешена; лишние конфиги не отзываем,
но новые нельзя до входа в квоту.
- **Блокировка** (`AppUser.IsBlocked`): вход запрещён + все конфиги `Disabled` (отключить клиентов
в 3x-ui); разблокировка — обратно. Действие в `AuditLog`.
- **Аудит**: значимые действия (активация, блок, смена роли, отзыв, ноды/инбаунды) писать в `AuditLog`
Полная модель — [domain-model.md](docs/domain-model.md). Ключевые инварианты:
- **Роли динамические** (`AppRole`), квоты на роли (не на `Plan`): `MaxConfigs` (число конфигов),
`MaxIpLimit` (лимит одновременных IP клиента в 3x-ui, `limitIp`); -1 = без лимита. У пользователя
ровно одна роль; `admin` — без лимитов. Системные роли `admin`/`user` не удалять/переименовывать.
Понижение роли — грандфазеринг (лишние конфиги не отзываются, новые блокируются до входа в квоту).
- **`limitIp`** выставляется автоматически по `MaxIpLimit` роли при создании клиента (`Create`/`Rotate`);
панель не даёт настраивать его per-конфиг и не трогает уже созданных клиентов при смене роли/квоты.
- **Активация**: новый пользователь `IsActivated=false`, роль `user`; неактивированному недоступны
конфиги (создание/просмотр/редактирование/ротация/отзыв/ссылка/подписка), новости и каталог
приложений — единая проверка `RequireActivationBehavior` по маркеру `IRequiresActivation` (не
разбросанные `if` в хендлерах). На фронте до активации доступны только дашборд (форма запроса
активации) и настройки аккаунта. `ActivationRequest` одобряет админ на сайте или в Telegram —
одними командами.
- **Инбаунды по ролям** (`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).
- **Подписка**: агрегированная на юзера (`AppUser.SubscriptionToken`, все активные конфиги) + по конфигу.
- **Ротация конфига** (`Rotate()`): новый UUID/ссылка, квоту не тратит. **Бот в MVP — read-only** по конфигам.
- **Конфиг**: пользователь задаёт метку (`Label`) и лимит устройств (`DeviceLimit``limitIp` в 3x-ui, 0=без лимита), может редактировать.
- **Самоудаление аккаунта** (`DELETE /api/auth/me`): отзыв всех конфигов + удаление данных, аудит анонимизируется.
- **API без версионирования** в MVP (`/api` без `v1`). Подписка отдаёт `Subscription-Userinfo`.
- **Тема**: светлая/тёмная/системная (Tailwind `dark`, выбор в localStorage).
- **Инструкции + приложения**: отдельная страница инструкций; каталог `ClientApp` (админ CRUD:
название/ссылка/ОС/порядок/вкл), пользователю `GET /api/apps` отдаётся сгруппированным по ОС.
- **Вход — по `UserName`** (email в системе не используется вовсе; SMTP не нужен).
Восстановление пароля: через привязанный Telegram (self-service), без привязки — сброс админом
(`ResetUserPasswordCommand`). Пока Telegram не привязан — UI настойчиво предлагает его привязать.
- **Сидинг из env**: идемпотентный `DbInitializer` на старте создаёт системные роли и учётку админа
(username/пароль/Telegram id) из переменных окружения; каталог приложений `ClientApp` (если пуст) —
из [`seed/client-apps.json`](seed/client-apps.json). Единый источник примера env — [`.env.example`](.env.example);
при добавлении новой настройки обновляй и его. Секреты (пароль админа, JWT-ключ, BotToken) — только через env/secret-store.
- Telegram id админов (`AdminSeed__TelegramUserIds`) авторизуют админ-действия в боте и получают
уведомления о запросах активации.
- **Биллинг** (`AppRole.BillingEnabled`, недоступен для `admin`): пользователь оформляет
`PaymentRequest` на 3/6/12 мес (сумма — по `PricingSettings`, заморожена на заявке), админ
подтверждает/отклоняет на сайте или в Telegram (`pay:*`). Пока заявка `AwaitingConfirmation`
конфиги не гасятся, даже если срок истёк (не по вине пользователя, что админ не успел). Просрочка
без заявки → `VpnConfig.Suspend()` (статус `Expired`, отдельно от `Disable()`/блокировки админом) —
см. [domain-model.md](docs/domain-model.md#billing--подписка-по-сроку).
- **Ротация конфига** (`Rotate()`) — новый UUID/ссылка, квоту не тратит. **Бот read-only** по конфигам.
- **Подписка**: агрегированная (`AppUser.SubscriptionToken`) + по конфигу. API без версионирования
(`/api`, без `v1`); подписка отдаёт `Subscription-Userinfo`.
- **Вход по `UserName`** (email не используется). Восстановление пароля через привязанный Telegram
(self-service) либо сбросом админом (`ResetUserPasswordCommand`); без привязки UI настойчиво предлагает привязать.
- **Сидинг**: идемпотентный `DbInitializer` создаёт системные роли + админа из env
(`AdminSeed__Username`/`Password`); каталог `ClientApp` — из [`seed/client-apps.json`](seed/client-apps.json).
Источник примера env — [`.env.example`](.env.example), обновляй при новых настройках. Секреты — только через env.
`Telegram__AdminTelegramUserIds` — отдельно от сидинга, не пишется в БД, читается напрямую из `TelegramOptions`.
## Telegram-бот
- Бот — **presentation-адаптер**, не бизнес-слой. Хостится в процессе Api (`TelegramBotHostedService`,
long polling). Хендлеры апдейтов вызывают **те же** CQRS-команды/запросы через собственный `ISender`
(`GetMyConfigsQuery`, `LinkTelegramCommand`, `ApproveTelegramLoginCommand`, ...).
- `Telegram.Bot` не проникает в Application/Domain — только в `Api/Telegram/`.
- Passwordless-вход выпускает **те же** JWT/refresh, что и обычный логин. Требует привязки Telegram
(в MVP — только привязка существующего аккаунта, регистрация из бота — backlog).
- Токены привязки/входа: короткоживущие, одноразовые, высокоэнтропийные. `Telegram:BotToken` — секрет,
не логировать. Панель должна работать и без бота (если токен не задан — бот просто не стартует).
- Детали флоу — [telegram-bot.md](docs/telegram-bot.md).
Детали флоу — [telegram-bot.md](docs/telegram-bot.md).
- Presentation-адаптер, не бизнес-слой: `TelegramBotHostedService` (long polling) вызывает **те же**
CQRS-команды через `ISender`. `Telegram.Bot` не проникает в Application/Domain — только `Api/Telegram/`.
- Passwordless-вход выпускает те же JWT/refresh, что и обычный логин; требует привязки Telegram (с сайта)
либо регистрации прямо из бота (`RegisterViaTelegramCommand` — логин `@username`/id, пароль генерируется
и приходит в чат один раз). Новый аккаунт — роль `user`, `IsActivated=false`, активация как обычно.
- Токены привязки/входа — короткоживущие одноразовые. `Telegram:BotToken` — секрет, не логировать.
Без токена бот просто не стартует — панель работает и без него.
## Единый контейнер
- Один образ приложения: Api раздаёт REST (`/api`), SignalR (`/hubs`), хостит бота **и** статику SPA
из `wwwroot` (fallback на `index.html`). Фронт и бек — один origin, база API — относительный `/api`.
- Multi-stage Dockerfile: node (сборка фронта) → dotnet sdk (publish + копирование в `wwwroot`) → aspnet runtime.
- docker-compose: `app` (единый образ) + `db` (PostgreSQL). В dev — Vite-прокси `/api`,`/hubs` на бэк.
- Не вводи отдельный nginx-контейнер для статики без явной просьбы — это ломает требование единого контейнера.
- **TLS — внешний** (прокси/шлюз вне compose); `app` отдаёт HTTP + доверяет `X-Forwarded-*` через
`ForwardedHeaders` (иначе Secure-cookie/схема за прокси сломаются). Свой nginx/Caddy не добавляй.
- **Миграции** применяются авто на старте (MVP). **CI** (GitHub Actions) — только build/test, без деплоя.
- Один образ: Api раздаёт REST (`/api`), SignalR (`/hubs`), хостит бота и статику SPA из `wwwroot`
(fallback на `index.html`). Один origin, база API — относительный `/api`.
- Multi-stage Dockerfile: node (фронт) → dotnet sdk (publish + копирование в `wwwroot`) → aspnet runtime.
- docker-compose: `app` + `db` (PostgreSQL); в dev — Vite-прокси `/api`,`/hubs` на бэк.
- Не вводи отдельный nginx-контейнер для статики без явной просьбы — ломает требование единого контейнера.
- **TLS — внешний**; `app` отдаёт HTTP + доверяет `X-Forwarded-*` (`ForwardedHeaders`). Свой nginx/Caddy не добавляй.
- Миграции применяются авто на старте. 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`.
- Application организована **по фичам** (feature folders) внутри слоёв.
- Один публичный тип на файл, имя файла = имя типа. Async-методы — суффикс `Async` + `CancellationToken`.
- Секреты не логировать; логи структурные (Serilog) с `UserId`/`NodeId`/`ConfigId`/`CorrelationId`.
- Ошибки API — единый `ProblemDetails`.
- `<Verb><Noun>Command`/`<Get|List><Noun>Query` + `Handler`/`Validator` (валидатор — где есть что
проверить). DTO — суффикс `Dto`; тела запросов Api — `Body`; тела ответов без Application DTO —
`ResponseDto`. Application — по фичам (feature folders).
- Один публичный тип на файл = имя файла (кроме `Body`/`ResponseDto` в файле эндпоинтов).
Async-методы — суффикс `Async` + `CancellationToken`.
- Секреты не логировать; логи — Serilog. Явного обогащения `UserId`/`NodeId`/`ConfigId`/`CorrelationId`
пока нет — не полагайся на него при расследовании.
- Ошибки API — единый `application/problem+json`.
## Команды (ожидаемые — появятся по мере создания проектов)
## Команды
Backend (из `backend/`):
```bash
@@ -161,19 +175,17 @@ docker compose up -d # api + postgres (+ web)
```
> Окружение: Windows, основная оболочка — **PowerShell**. Для POSIX-скриптов есть Bash-инструмент.
> Пути — с учётом Windows.
## Принятые решения (зафиксированы)
## Ключевые решения
Ключевые развилки закрыты — см. [tech-stack.md](docs/tech-stack.md#принятые-решения-по-открытым-вопросам):
CQRS — **собственный диспетчер** (не MediatR); **одна роль** на пользователя; секреты нод —
**ASP.NET Data Protection**; тарифы `Plan`**backlog** (в MVP без лимитов трафика/срока);
i18n — **RU+EN** (react-i18next); Telegram — **long polling**, только **привязка** (не signup);
история трафика — **простая таблица + TTL**; логирование — **Serilog**.
См. [tech-stack.md](docs/tech-stack.md#ключевые-решения-по-домену-и-поведению): CQRS — через
LiteCqrs.Net (собственная библиотека, не MediatR); одна роль на пользователя; секреты нод — ASP.NET Data Protection; биллинг
опционален per-роль (недоступен для `admin`); i18n — RU+EN (react-i18next); Telegram — long polling,
только привязка (не signup); история трафика — простая таблица + TTL; логирование — Serilog.
## Рабочие принципы
- Не начинай крупную реализацию без сверки с доками и, при неоднозначности, без вопроса пользователю.
- Соблюдай границы слоёв — это главный инвариант проекта. Нарушение = ошибка ревью.
- Соблюдай границы слоёв — главный инвариант проекта. Нарушение = ошибка ревью.
- Обновляй документацию вместе с кодом. Не коммить и не пуши без явной просьбы.
- Отвечай пользователю на русском (язык общения в проекте — русский).
- Отвечай пользователю на русском.
+8 -3
View File
@@ -30,13 +30,18 @@ RUN dotnet publish backend/src/PnvPanel.Api/PnvPanel.Api.csproj -c Release -o /a
# ── Stage 3: runtime ──────────────────────────────────────────────────────
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
# curl — только для HEALTHCHECK в docker-compose (GET /health), в образе его нет по умолчанию.
RUN apt-get update && apt-get install -y --no-install-recommends curl \
# 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 \
ASPNETCORE_HTTP_PORTS=8080
EXPOSE 8080
COPY --from=backend /app/publish ./
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 ["dotnet", "PnvPanel.Api.dll"]
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 |
| Упаковка | Единый 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):
@@ -28,20 +50,15 @@
- [Product Vision & Scope](docs/vision.md) — что мы строим и для кого
- [Architecture](docs/architecture.md) — Clean Architecture, CQRS, интеграция, realtime, безопасность
- [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) — структура проекта, паттерны, стиль кода
- [Frontend](docs/frontend.md) — стек фронтенда и структура
- [Telegram Bot](docs/telegram-bot.md) — бот, привязка Telegram и passwordless-вход
- [API Design](docs/api-design.md) — REST-эндпоинты и SignalR-контракты
- [Roadmap](docs/roadmap.md) — этапы разработки
Пример переменных окружения (сид админа, БД, JWT, Telegram) — [`.env.example`](.env.example).
Инструкции для AI-ассистента (Claude Code) — в [`CLAUDE.md`](CLAUDE.md).
## Статус
🚧 Проектирование. Кодовая база ещё не создана — на этом этапе зафиксированы архитектура и план.
## Лицензия
[MIT](LICENSE)
-2
View File
@@ -1,5 +1,4 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
@@ -18,5 +17,4 @@
-->
<NoWarn>$(NoWarn);CA1711;CA1716;CA1848;CA1873</NoWarn>
</PropertyGroup>
</Project>
+27 -23
View File
@@ -4,37 +4,41 @@
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="FluentValidation" Version="12.1.1" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.Extensions" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.Extensions" Version="10.0.10" />
<PackageVersion Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.11" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
<PackageVersion Include="Microsoft.OpenApi" Version="2.9.0" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.7" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.10" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.10" />
<!-- Держим 2.x: Microsoft.AspNetCore.OpenApi 10.0.10 сам зависит от Microsoft.OpenApi >=2.0.0
(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="Microsoft.EntityFrameworkCore" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.10" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageVersion>
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.19.1" />
<PackageVersion Include="Telegram.Bot" Version="22.10.1" />
<PackageVersion Include="ThreeXui.Net" Version="1.0.0" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.10" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.21.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="17.14.1" />
<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.4" />
<PackageVersion Include="NSubstitute" Version="5.3.0" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.2.0" />
<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.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.10" />
</ItemGroup>
</Project>
@@ -4,11 +4,11 @@ namespace PnvPanel.Api.Common;
public static class ResultExtensions
{
public static IResult ToHttpResult(this Result result)
=> result.IsSuccess ? Results.NoContent() : ToProblem(result.Error);
public static IResult ToHttpResult(this Result result) =>
result.IsSuccess ? Results.NoContent() : ToProblem(result.Error);
public static IResult ToHttpResult<T>(this Result<T> result)
=> result.IsSuccess ? Results.Ok(result.Value) : ToProblem(result.Error);
public static IResult ToHttpResult<T>(this Result<T> result) =>
result.IsSuccess ? Results.Ok(result.Value) : ToProblem(result.Error);
private static IResult ToProblem(Error error)
{
@@ -1,7 +1,8 @@
using PnvPanel.Api.Common;
using PnvPanel.Application.Activation;
using PnvPanel.Application.Admin.Activation;
using PnvPanel.Application.Common.Messaging;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Activation;
using PnvPanel.Infrastructure.Identity;
@@ -12,54 +13,83 @@ public static class ActivationEndpoints
public static IEndpointRouteBuilder MapActivationEndpoints(this IEndpointRouteBuilder app)
{
var user = app.MapGroup("/api/activation").WithTags("Activation").RequireAuthorization();
user.MapGet("/status", GetStatus);
user.MapPost("/request", RequestActivation);
user.MapGet("/status", GetStatus).Produces<ActivationStatusDto>();
user.MapPost("/request", RequestActivation).Produces<ActivationRequestDto>();
var admin = app.MapGroup("/api/admin/activation-requests")
.WithTags("Admin.Activation")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", ListRequests);
admin.MapPost("/{id:guid}/approve", Approve);
admin.MapPost("/{id:guid}/reject", Reject);
admin.MapGet("", ListRequests).Produces<PagedList<ActivationRequestAdminDto>>();
admin.MapPost("/{id:guid}/approve", Approve).Produces(StatusCodes.Status204NoContent);
admin.MapPost("/{id:guid}/reject", Reject).Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> GetStatus(ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> GetStatus(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetActivationStatusQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> RequestActivation(RequestActivationCommand command, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> RequestActivation(
RequestActivationCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ListRequests(
[AsParameters] ListActivationRequestsRequest request, ISender sender, CancellationToken cancellationToken)
[AsParameters] ListActivationRequestsRequest request,
ISender sender,
CancellationToken cancellationToken
)
{
var query = new ListActivationRequestsQuery(request.StatusFilter, request.Page, request.PageSize);
var query = new ListActivationRequestsQuery(
request.StatusFilter,
request.Page,
request.PageSize
);
var result = await sender.Send(query, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> Approve(Guid id, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> Approve(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ApproveActivationCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> Reject(
Guid id, RejectActivationBody body, ISender sender, CancellationToken cancellationToken)
Guid id,
RejectActivationBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new RejectActivationCommand(id, body.Reason), cancellationToken);
var result = await sender.Send(
new RejectActivationCommand(id, body.Reason),
cancellationToken
);
return result.ToHttpResult();
}
}
public sealed record ListActivationRequestsRequest(ActivationStatus? StatusFilter, int Page = 1, int PageSize = 20);
public sealed record ListActivationRequestsRequest(
ActivationStatus? StatusFilter,
int Page = 1,
int PageSize = 20
);
public sealed record RejectActivationBody(string? Reason);
@@ -1,6 +1,6 @@
using PnvPanel.Api.Common;
using PnvPanel.Application.Admin.Apps;
using PnvPanel.Application.Common.Messaging;
using LiteCqrs;
using PnvPanel.Domain.Apps;
using PnvPanel.Infrastructure.Identity;
@@ -14,10 +14,10 @@ public static class AdminAppEndpoints
.WithTags("Admin.Apps")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", ListApps);
admin.MapPost("", CreateApp);
admin.MapPut("/{id:guid}", UpdateApp);
admin.MapDelete("/{id:guid}", DeleteApp);
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;
}
@@ -28,21 +28,43 @@ public static class AdminAppEndpoints
return result.ToHttpResult();
}
private static async Task<IResult> CreateApp(CreateAppCommand command, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> CreateApp(
CreateAppCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateApp(Guid id, UpdateAppBody body, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> UpdateApp(
Guid id,
UpdateAppBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var command = new UpdateAppCommand(
id, body.Name, body.DownloadUrl, body.OperatingSystem, body.Description, body.IconUrl, body.SortOrder, body.IsEnabled);
id,
body.Name,
body.DownloadUrl,
body.OperatingSystem,
body.Description,
body.IconUrl,
body.SortOrder,
body.IsEnabled,
body.IsRecommended
);
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteApp(Guid id, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> DeleteApp(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteAppCommand(id), cancellationToken);
return result.ToHttpResult();
@@ -50,5 +72,12 @@ public static class AdminAppEndpoints
}
public sealed record UpdateAppBody(
string Name, string DownloadUrl, OsPlatform OperatingSystem, string? Description, string? IconUrl,
int SortOrder, bool IsEnabled);
string Name,
string DownloadUrl,
OsPlatform OperatingSystem,
string? Description,
string? IconUrl,
int SortOrder,
bool IsEnabled,
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();
}
}
@@ -1,7 +1,9 @@
using PnvPanel.Api.Common;
using PnvPanel.Application.Admin.Audit;
using PnvPanel.Application.Admin.Stats;
using PnvPanel.Application.Common.Messaging;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Audit;
using PnvPanel.Infrastructure.Identity;
namespace PnvPanel.Api.Endpoints;
@@ -14,8 +16,8 @@ public static class AdminStatsEndpoints
.WithTags("Admin.Stats")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("/stats", GetStats);
admin.MapGet("/audit", GetAudit);
admin.MapGet("/stats", GetStats).Produces<StatsDto>();
admin.MapGet("/audit", GetAudit).Produces<PagedList<AuditLogDto>>();
return app;
}
@@ -26,9 +28,23 @@ public static class AdminStatsEndpoints
return result.ToHttpResult();
}
private static async Task<IResult> GetAudit(int page, int pageSize, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> GetAudit(
int page,
int pageSize,
AuditSource? source,
string? targetType,
string? action,
ISender sender,
CancellationToken cancellationToken
)
{
var query = new ListAuditLogsQuery(page <= 0 ? 1 : page, pageSize <= 0 ? 50 : pageSize);
var query = new ListAuditLogsQuery(
page <= 0 ? 1 : page,
pageSize <= 0 ? 50 : pageSize,
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);
@@ -1,6 +1,12 @@
using PnvPanel.Api.Common;
using PnvPanel.Application.Admin.Configs;
using PnvPanel.Application.Admin.Users;
using PnvPanel.Application.Common.Messaging;
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;
@@ -13,49 +19,135 @@ public static class AdminUserEndpoints
.WithTags("Admin.Users")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("/users", ListUsers);
admin.MapPatch("/users/{id:guid}/block", BlockUser);
admin.MapPatch("/users/{id:guid}/unblock", UnblockUser);
admin.MapPost("/users/{id:guid}/reset-password", ResetPassword);
admin.MapGet("/users/{id:guid}/configs", GetUserConfigs);
admin.MapDelete("/configs/{id:guid}", ForceRevokeConfig);
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, ISender sender, CancellationToken cancellationToken)
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);
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)
private static async Task<IResult> BlockUser(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new BlockUserCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UnblockUser(Guid id, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> UnblockUser(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new UnblockUserCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ResetPassword(Guid id, ResetPasswordBody body, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> ResetPassword(
Guid id,
ResetPasswordBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ResetUserPasswordCommand(id, body.NewPassword), cancellationToken);
var result = await sender.Send(
new ResetUserPasswordCommand(id, body.NewPassword),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> GetUserConfigs(Guid id, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> DeleteUser(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteUserCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetUserConfigs(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetUserConfigsQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ForceRevokeConfig(Guid id, ISender sender, CancellationToken cancellationToken)
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();
@@ -1,6 +1,7 @@
using PnvPanel.Api.Common;
using PnvPanel.Application.Apps;
using PnvPanel.Application.Common.Messaging;
using LiteCqrs;
using PnvPanel.Domain.Apps;
namespace PnvPanel.Api.Endpoints;
@@ -8,7 +9,10 @@ public static class AppEndpoints
{
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;
}
@@ -1,13 +1,14 @@
using PnvPanel.Api.Common;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Auth.ChangePassword;
using PnvPanel.Application.Auth.ChangeUserName;
using PnvPanel.Application.Auth.DeleteMyAccount;
using PnvPanel.Application.Auth.Login;
using PnvPanel.Application.Auth.Logout;
using PnvPanel.Application.Auth.Me;
using PnvPanel.Application.Auth.Refresh;
using PnvPanel.Application.Auth.Register;
using PnvPanel.Application.Common.Messaging;
using LiteCqrs;
namespace PnvPanel.Api.Endpoints;
@@ -21,59 +22,125 @@ public static class AuthEndpoints
.WithTags("Auth")
.RequireRateLimiting(RateLimiting.AuthPolicy);
group.MapPost("/register", Register);
group.MapPost("/login", Login);
group.MapPost("/refresh", Refresh);
group.MapPost("/logout", Logout).RequireAuthorization();
group.MapPost("/change-password", ChangePassword).RequireAuthorization();
group.MapGet("/me", Me).RequireAuthorization();
group.MapDelete("/me", DeleteMe).RequireAuthorization();
group.MapPost("/register", Register).Produces<RegisterResult>();
group.MapPost("/login", Login).Produces<AuthResponseDto>();
group.MapPost("/refresh", Refresh).Produces<AuthResponseDto>();
group
.MapPost("/logout", Logout)
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent);
group
.MapPost("/change-password", ChangePassword)
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent);
group
.MapPost("/change-username", ChangeUserName)
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent);
group.MapGet("/me", Me).RequireAuthorization().Produces<CurrentUserDto>();
group
.MapDelete("/me", DeleteMe)
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> Register(RegisterCommand command, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> Register(
RegisterCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> Login(LoginCommand command, ISender sender, HttpResponse response, CancellationToken cancellationToken)
private static async Task<IResult> Login(
LoginCommand command,
ISender sender,
HttpRequest request,
HttpResponse response,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
if (!result.IsSuccess)
return result.ToHttpResult();
SetRefreshCookie(response, result.Value.RefreshToken, result.Value.RefreshTokenExpiresAt);
SetRefreshCookie(
request,
response,
result.Value.RefreshToken,
result.Value.RefreshTokenExpiresAt
);
return Results.Ok(ToLoginResponse(result.Value));
}
private static async Task<IResult> Refresh(HttpRequest request, HttpResponse response, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> Refresh(
HttpRequest request,
HttpResponse response,
ISender sender,
CancellationToken cancellationToken
)
{
if (!request.Cookies.TryGetValue(RefreshCookieName, out var rawToken) || string.IsNullOrEmpty(rawToken))
if (
!request.Cookies.TryGetValue(RefreshCookieName, out var rawToken)
|| string.IsNullOrEmpty(rawToken)
)
return Results.Unauthorized();
var result = await sender.Send(new RefreshCommand(rawToken), cancellationToken);
if (!result.IsSuccess)
{
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions());
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request));
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));
}
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);
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions());
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request));
return Results.NoContent();
}
private static async Task<IResult> ChangePassword(ChangePasswordCommand command, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> ChangePassword(
ChangePasswordCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ChangeUserName(
ChangeUserNameCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
@@ -85,32 +152,44 @@ public static class AuthEndpoints
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);
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions());
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request));
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,
expiresAt = auth.AccessTokenExpiresAt,
user = auth.User,
};
private static void SetRefreshCookie(HttpResponse response, string rawToken, DateTimeOffset expiresAt)
{
var options = BuildCookieOptions();
var options = BuildCookieOptions(request);
options.Expires = expiresAt;
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,
Secure = true,
Secure = request.IsHttps,
SameSite = SameSiteMode.Strict,
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.Application.Common.Messaging;
using LiteCqrs;
using PnvPanel.Application.Configs;
using PnvPanel.Application.Configs.Create;
using PnvPanel.Application.Configs.Edit;
using PnvPanel.Application.Configs.GetConfigLink;
using PnvPanel.Application.Configs.GetMyConfigs;
using PnvPanel.Application.Configs.GetMySubscription;
using PnvPanel.Application.Configs.ListAvailableInbounds;
using PnvPanel.Application.Configs.Revoke;
using PnvPanel.Application.Configs.Rotate;
@@ -16,66 +18,121 @@ public static class ConfigEndpoints
{
var group = app.MapGroup("/api").WithTags("Configs").RequireAuthorization();
group.MapGet("/inbounds/available", ListAvailableInbounds);
group.MapGet("/configs", GetMyConfigs);
group.MapPost("/configs", CreateConfig);
group.MapPatch("/configs/{id:guid}", EditConfig);
group.MapPost("/configs/{id:guid}/rotate", RotateConfig);
group.MapDelete("/configs/{id:guid}", RevokeConfig);
group.MapGet("/configs/{id:guid}/link", GetConfigLink);
group
.MapGet("/inbounds/available", ListAvailableInbounds)
.Produces<IReadOnlyList<AvailableInboundDto>>();
group.MapGet("/configs", GetMyConfigs).Produces<GetMyConfigsResult>();
group.MapPost("/configs", CreateConfig).Produces<VpnConfigDto>();
group.MapPatch("/configs/{id:guid}", EditConfig).Produces<VpnConfigDto>();
group.MapPost("/configs/{id:guid}/rotate", RotateConfig).Produces<VpnConfigDto>();
group
.MapDelete("/configs/{id:guid}", RevokeConfig)
.Produces(StatusCodes.Status204NoContent);
group.MapGet("/configs/{id:guid}/link", GetConfigLink).Produces<ConfigLinkResponseDto>();
group.MapGet("/subscription", GetMySubscription).Produces<MySubscriptionResponseDto>();
return app;
}
private static async Task<IResult> ListAvailableInbounds(ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> ListAvailableInbounds(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListAvailableInboundsQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetMyConfigs(ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> GetMyConfigs(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetMyConfigsQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateConfig(CreateConfigBody body, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> CreateConfig(
CreateConfigBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var command = new CreateVpnConfigCommand(body.InboundId, body.Label, body.DeviceLimit);
var command = new CreateVpnConfigCommand(body.InboundId, body.Label);
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> EditConfig(Guid id, EditConfigBody body, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> EditConfig(
Guid id,
EditConfigBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var command = new EditVpnConfigCommand(id, body.Label, body.DeviceLimit);
var command = new EditVpnConfigCommand(id, body.Label);
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> RotateConfig(Guid id, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> RotateConfig(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new RotateVpnConfigCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> RevokeConfig(Guid id, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> RevokeConfig(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new RevokeVpnConfigCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetConfigLink(Guid id, HttpRequest request, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> GetConfigLink(
Guid id,
HttpRequest request,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetConfigLinkQuery(id), cancellationToken);
if (!result.IsSuccess)
return result.ToHttpResult();
var subscriptionUrl = $"{request.Scheme}://{request.Host}/sub/{result.Value.SubscriptionToken}";
return Results.Ok(new { connectionString = result.Value.ConnectionString, subscriptionUrl });
var subscriptionUrl =
$"{request.Scheme}://{request.Host}/sub/{result.Value.SubscriptionToken}";
return Results.Ok(
new ConfigLinkResponseDto(result.Value.ConnectionString, subscriptionUrl)
);
}
private static async Task<IResult> GetMySubscription(
HttpRequest request,
ISender sender,
CancellationToken cancellationToken
)
{
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.Application.Admin.Inbounds;
using PnvPanel.Application.Common.Messaging;
using LiteCqrs;
using PnvPanel.Infrastructure.Identity;
namespace PnvPanel.Api.Endpoints;
@@ -13,26 +13,53 @@ public static class InboundEndpoints
.WithTags("Admin.Inbounds")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", ListInbounds);
admin.MapPut("/{id:guid}/publish", PublishInbound);
admin.MapGet("", ListInbounds).Produces<IReadOnlyList<InboundDto>>();
admin.MapPut("/{id:guid}/publish", PublishInbound).Produces<InboundDto>();
admin.MapDelete("/{id:guid}", DeleteInbound);
return app;
}
private static async Task<IResult> ListInbounds(Guid? nodeId, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> ListInbounds(
Guid? nodeId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListInboundsQuery(nodeId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> PublishInbound(
Guid id, PublishInboundBody body, ISender sender, CancellationToken cancellationToken)
Guid id,
PublishInboundBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var command = new PublishInboundCommand(
id, body.IsPublished, body.DisplayName, body.AllowedRoleIds ?? [], body.MaxClients);
id,
body.IsPublished,
body.DisplayName,
body.AllowedRoleIds ?? []
);
var result = await sender.Send(command, cancellationToken);
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.Application.Admin.Nodes;
using PnvPanel.Application.Common.Messaging;
using LiteCqrs;
using PnvPanel.Infrastructure.Identity;
namespace PnvPanel.Api.Endpoints;
@@ -13,52 +13,93 @@ public static class NodeEndpoints
.WithTags("Admin.Nodes")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", ListNodes);
admin.MapPost("", RegisterNode);
admin.MapPut("/{id:guid}", UpdateNode);
admin.MapDelete("/{id:guid}", DeleteNode);
admin.MapPost("/{id:guid}/sync", SyncNode);
admin.MapPost("/{id:guid}/probe", ProbeNode);
admin.MapGet("", ListNodes).Produces<IReadOnlyList<NodeDto>>();
admin.MapPost("", RegisterNode).Produces<NodeDto>();
admin.MapPut("/{id:guid}", UpdateNode).Produces<NodeDto>();
admin.MapDelete("/{id:guid}", DeleteNode).Produces(StatusCodes.Status204NoContent);
admin.MapPost("/{id:guid}/sync", SyncNode).Produces<SyncNodeResultDto>();
admin.MapPost("/{id:guid}/probe", ProbeNode).Produces<NodeProbeResultDto>();
return app;
}
private static async Task<IResult> ListNodes(ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> ListNodes(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListNodesQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> RegisterNode(RegisterNodeCommand command, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> RegisterNode(
RegisterNodeCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateNode(Guid id, UpdateNodeBody body, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> UpdateNode(
Guid id,
UpdateNodeBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var command = new UpdateNodeCommand(id, body.Name, body.Location, body.IsEnabled, body.Username, body.Password);
var command = new UpdateNodeCommand(
id,
body.Name,
body.BaseAddress,
body.Location,
body.IsEnabled,
body.NotifyOnStatusChange,
body.Username,
body.Password
);
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteNode(Guid id, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> DeleteNode(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteNodeCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> SyncNode(Guid id, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> SyncNode(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new SyncNodeCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ProbeNode(Guid id, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> ProbeNode(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ProbeNodeCommand(id), cancellationToken);
return result.ToHttpResult();
}
}
public sealed record UpdateNodeBody(string Name, string? Location, bool IsEnabled, string? Username, string? Password);
public sealed record UpdateNodeBody(
string Name,
string 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.Application.Admin.Roles;
using PnvPanel.Application.Admin.Users;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Interfaces;
using LiteCqrs;
using PnvPanel.Infrastructure.Identity;
namespace PnvPanel.Api.Endpoints;
@@ -14,46 +15,94 @@ public static class RoleEndpoints
.WithTags("Admin.Roles")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("/roles", ListRoles);
admin.MapPost("/roles", CreateRole);
admin.MapPut("/roles/{id:guid}", UpdateRole);
admin.MapDelete("/roles/{id:guid}", DeleteRole);
admin.MapPatch("/users/{id:guid}/role", ChangeUserRole);
admin.MapGet("/roles", ListRoles).Produces<IReadOnlyList<RoleDto>>();
admin.MapPost("/roles", CreateRole).Produces<RoleDto>();
admin.MapPut("/roles/{id:guid}", UpdateRole).Produces<RoleDto>();
admin.MapDelete("/roles/{id:guid}", DeleteRole).Produces(StatusCodes.Status204NoContent);
admin
.MapPatch("/users/{id:guid}/role", ChangeUserRole)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPatch("/users/{id:guid}/plan", SetUserPlan)
.Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> ListRoles(ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> ListRoles(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListRolesQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateRole(CreateRoleCommand command, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> CreateRole(
CreateRoleCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateRole(Guid id, UpdateRoleBody body, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> UpdateRole(
Guid id,
UpdateRoleBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new UpdateRoleCommand(id, body.MaxConfigs), cancellationToken);
var result = await sender.Send(
new UpdateRoleCommand(id, body.MaxIpLimit, body.BillingEnabled),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteRole(Guid id, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> DeleteRole(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteRoleCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ChangeUserRole(Guid id, ChangeUserRoleBody body, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> ChangeUserRole(
Guid id,
ChangeUserRoleBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ChangeUserRoleCommand(id, body.RoleId), cancellationToken);
var result = await sender.Send(
new ChangeUserRoleCommand(id, body.RoleId),
cancellationToken
);
return result.ToHttpResult();
}
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();
}
}
public sealed record UpdateRoleBody(int MaxConfigs);
public sealed record UpdateRoleBody(int MaxIpLimit, bool BillingEnabled);
public sealed record ChangeUserRoleBody(Guid RoleId);
public sealed record SetUserPlanBody(Guid? PlanId, int? CustomConfigCount);
@@ -1,6 +1,6 @@
using System.Text;
using PnvPanel.Api.Common;
using PnvPanel.Application.Common.Messaging;
using LiteCqrs;
using PnvPanel.Application.Subscriptions;
namespace PnvPanel.Api.Endpoints;
@@ -12,17 +12,26 @@ public static class SubscriptionEndpoints
// Вне /api по дизайну (api-design.md) — публичный эндпоинт для VPN-клиентов.
app.MapGet("/sub/{token}", GetSubscription)
.WithTags("Subscription")
.RequireRateLimiting(RateLimiting.AuthPolicy);
.RequireRateLimiting(RateLimiting.AuthPolicy)
.Produces<string>(StatusCodes.Status200OK, "text/plain")
.Produces(StatusCodes.Status404NotFound);
return app;
}
private static async Task<IResult> GetSubscription(string token, HttpResponse response, ISender sender, CancellationToken cancellationToken)
private static async Task<IResult> GetSubscription(
string token,
HttpResponse response,
ISender sender,
CancellationToken cancellationToken
)
{
// Токен — либо AppUser.SubscriptionToken (агрегированная подписка), либо VpnConfig.SubscriptionToken
// (один конфиг). Пробуем пользовательский токен первым.
var userResult = await sender.Send(new GetUserSubscriptionQuery(token), cancellationToken);
var result = userResult.IsSuccess ? userResult : await sender.Send(new GetConfigSubscriptionQuery(token), cancellationToken);
var result = userResult.IsSuccess
? userResult
: await sender.Send(new GetConfigSubscriptionQuery(token), cancellationToken);
if (!result.IsSuccess)
return Results.NotFound();
@@ -34,7 +43,8 @@ public static class SubscriptionEndpoints
var expire = result.Value.ExpiresAt is { } exp ? exp.ToUnixTimeSeconds().ToString() : "0";
response.Headers.Append(
"Subscription-Userinfo",
$"upload={result.Value.UsedUpBytes}; download={result.Value.UsedDownBytes}; total={total}; expire={expire}");
$"upload={result.Value.UsedUpBytes}; download={result.Value.UsedDownBytes}; total={total}; expire={expire}"
);
response.Headers.Append("Profile-Update-Interval", "12");
return Results.Text(base64Body, "text/plain; charset=utf-8");
@@ -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
);
@@ -1,8 +1,9 @@
using Microsoft.Extensions.Options;
using PnvPanel.Api.Common;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using LiteCqrs;
using PnvPanel.Application.Telegram;
using PnvPanel.Infrastructure.Telegram;
using PnvPanel.Domain.Telegram;
namespace PnvPanel.Api.Endpoints;
@@ -14,27 +15,40 @@ public static class TelegramEndpoints
.WithTags("Auth.Telegram")
.RequireRateLimiting(RateLimiting.AuthPolicy);
group.MapPost("/link-token", CreateLinkToken).RequireAuthorization();
group.MapPost("/unlink", Unlink).RequireAuthorization();
group.MapPost("/login-request", CreateLoginRequest);
group.MapGet("/login-request/{id:guid}", GetLoginRequestStatus);
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, IOptions<TelegramOptions> options, CancellationToken cancellationToken)
ISender sender,
ITelegramBotInfo botInfo,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new CreateLinkTokenCommand(), cancellationToken);
if (!result.IsSuccess)
return result.ToHttpResult();
var botUsername = options.Value.BotUsername;
var deepLink = string.IsNullOrWhiteSpace(botUsername)
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 { deepLink, expiresAt = result.Value.ExpiresAt });
return Results.Ok(new LinkTokenResponseDto(deepLink, result.Value.ExpiresAt));
}
private static async Task<IResult> Unlink(ISender sender, CancellationToken cancellationToken)
@@ -44,22 +58,38 @@ public static class TelegramEndpoints
}
private static async Task<IResult> CreateLoginRequest(
HttpRequest request, ISender sender, IOptions<TelegramOptions> options, CancellationToken cancellationToken)
HttpRequest request,
ISender sender,
ITelegramBotInfo botInfo,
CancellationToken cancellationToken
)
{
var context = request.HttpContext.Connection.RemoteIpAddress?.ToString();
var result = await sender.Send(new CreateLoginRequestCommand(context), cancellationToken);
if (!result.IsSuccess)
return result.ToHttpResult();
var botUsername = options.Value.BotUsername;
var deepLink = string.IsNullOrWhiteSpace(botUsername)
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 { requestId = result.Value.RequestId, deepLink, expiresAt = result.Value.ExpiresAt });
return Results.Ok(
new TelegramLoginRequestResponseDto(
result.Value.RequestId,
deepLink,
result.Value.ExpiresAt
)
);
}
private static async Task<IResult> GetLoginRequestStatus(Guid id, HttpResponse response, ISender sender, CancellationToken cancellationToken)
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)
@@ -67,25 +97,45 @@ public static class TelegramEndpoints
if (result.Value.Auth is { } auth)
{
// Secure = IsHttps запроса (учитывает ForwardedHeaders за внешним TLS-прокси) —
// иначе браузер/HttpClient не пришлёт cookie обратно на plain-http (см. AuthEndpoints).
var cookieOptions = new CookieOptions
{
HttpOnly = true,
Secure = 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
{
status = result.Value.Status.ToString(),
accessToken = auth.AccessToken,
expiresAt = auth.AccessTokenExpiresAt,
user = auth.User,
});
return Results.Ok(
new TelegramLoginStatusResponseDto(
result.Value.Status,
auth.AccessToken,
auth.AccessTokenExpiresAt,
auth.User
)
);
}
return Results.Ok(new { status = result.Value.Status.ToString() });
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
);
@@ -2,50 +2,160 @@ 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)
Guid userId,
Guid configId,
long usedUpBytes,
long usedDownBytes,
CancellationToken cancellationToken
)
{
return hubContext.Clients.Group(GroupNames.User(userId)).SendAsync(
return hubContext
.Clients.Group(GroupNames.User(userId))
.SendAsync(
"configTrafficUpdated",
new { configId, usedUpBytes, usedDownBytes },
cancellationToken);
new
{
configId,
usedUpBytes,
usedDownBytes,
},
cancellationToken
);
}
public Task NotifyConfigStatusChangedAsync(Guid userId, Guid configId, ConfigStatus status, CancellationToken cancellationToken)
public Task NotifyConfigStatusChangedAsync(
Guid userId,
Guid configId,
ConfigStatus status,
CancellationToken cancellationToken
)
{
return hubContext.Clients.Group(GroupNames.User(userId)).SendAsync(
return hubContext
.Clients.Group(GroupNames.User(userId))
.SendAsync(
"configStatusChanged",
new { configId, status = status.ToString() },
cancellationToken);
cancellationToken
);
}
public Task NotifyNodeStatusChangedAsync(Guid nodeId, NodeStatus status, DateTimeOffset? lastSyncAt, CancellationToken cancellationToken)
public Task NotifyNodeStatusChangedAsync(
Guid nodeId,
NodeStatus status,
DateTimeOffset? lastSyncAt,
CancellationToken cancellationToken
)
{
return hubContext.Clients.Group(GroupNames.Admins).SendAsync(
return hubContext
.Clients.Group(GroupNames.Admins)
.SendAsync(
"nodeStatusChanged",
new { nodeId, status = status.ToString(), lastSyncAt },
cancellationToken);
new
{
nodeId,
status = status.ToString(),
lastSyncAt,
},
cancellationToken
);
}
public Task NotifyActivationRequestedAsync(
Guid requestId, Guid userId, string userName, string? comment, DateTimeOffset createdAt, CancellationToken cancellationToken)
Guid requestId,
Guid userId,
string userName,
string? comment,
DateTimeOffset createdAt,
CancellationToken cancellationToken
)
{
return hubContext.Clients.Group(GroupNames.Admins).SendAsync(
return hubContext
.Clients.Group(GroupNames.Admins)
.SendAsync(
"activationRequested",
new { requestId, userId, userName, comment, createdAt },
cancellationToken);
new
{
requestId,
userId,
userName,
comment,
createdAt,
},
cancellationToken
);
}
public Task NotifyUserActivatedAsync(Guid userId, CancellationToken cancellationToken)
{
return hubContext.Clients.Group(GroupNames.User(userId)).SendAsync(
"userActivated",
new { userId },
cancellationToken);
return hubContext
.Clients.Group(GroupNames.User(userId))
.SendAsync("userActivated", new { userId }, cancellationToken);
}
public Task NotifyNewsPublishedAsync(
Guid postId,
string title,
DateTimeOffset createdAt,
CancellationToken cancellationToken
)
{
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);
}
}
+5 -3
View File
@@ -1,12 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<ProjectReference Include="..\PnvPanel.Infrastructure\PnvPanel.Infrastructure.csproj" />
<ProjectReference Include="..\PnvPanel.Application\PnvPanel.Application.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="..\..\..\seed\client-apps.json" Link="seed\client-apps.json" CopyToOutputDirectory="PreserveNewest" />
<Content
Include="..\..\..\seed\client-apps.json"
Link="seed\client-apps.json"
CopyToOutputDirectory="PreserveNewest"
/>
</ItemGroup>
<ItemGroup>
@@ -27,5 +30,4 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
+114 -13
View File
@@ -1,3 +1,5 @@
using System.Net;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options;
@@ -18,17 +20,43 @@ using Telegram.Bot;
var builder = WebApplication.CreateBuilder(args);
// Структурное логирование (Serilog), конфигурация из appsettings/env.
builder.Services.AddSerilog((services, configuration) => configuration
builder.Services.AddSerilog(
(services, configuration) =>
configuration
.ReadFrom.Configuration(builder.Configuration)
.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 =>
{
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();
@@ -36,39 +64,99 @@ builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddSignalR();
// В Api, не в Infrastructure — реализации нужен IHubContext<PanelHub>, а Hub определён здесь же.
builder.Services.AddSingleton<IRealtimeNotifier, SignalRRealtimeNotifier>();
// Telegram-бот: presentation-адаптер, хостится в процессе Api (long polling). Клиент регистрируем
// всегда (даже с пустым токеном) — TelegramBotHostedService сам решает не стартовать без токена.
// всегда (даже с пустым токеном) — TelegramBotHostedService сам решает не стартовать без токена,
// а TelegramNotifier — не слать сообщения. TelegramBotClient(...) при этом валидирует формат токена
// и падает на пустой строке, поэтому при пустом BotToken подставляем синтаксически валидную заглушку —
// реальный HTTP-вызов через неё никогда не происходит (все вызывающие места сами проверяют BotToken).
builder.Services.AddSingleton<ITelegramBotClient>(sp =>
{
var options = sp.GetRequiredService<IOptions<TelegramOptions>>();
return new TelegramBotClient(options.Value.BotToken ?? string.Empty);
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 =>
{
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.QueueLimit = 0;
});
}
);
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.AddOpenApi();
builder.Services.AddHealthChecks()
.AddDbContextCheck<AppDbContext>();
builder.Services.AddHealthChecks().AddDbContextCheck<AppDbContext>();
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) на старте.
await app.Services.ApplyMigrationsAsync();
await app.Services.SeedDataAsync();
@@ -96,9 +184,22 @@ app.MapInboundEndpoints();
app.MapConfigEndpoints();
app.MapSubscriptionEndpoints();
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");
File diff suppressed because it is too large Load Diff
@@ -13,15 +13,19 @@ namespace PnvPanel.Api.Telegram;
/// вызывает те же CQRS-команды, что и веб, через собственный ISender.
/// </summary>
public sealed class TelegramBotHostedService(
ITelegramBotClient botClient, PnvBotUpdateHandler updateHandler, IOptions<TelegramOptions> options,
ILogger<TelegramBotHostedService> logger)
: BackgroundService
ITelegramBotClient botClient,
PnvBotUpdateHandler updateHandler,
IOptions<TelegramOptions> options,
ILogger<TelegramBotHostedService> logger
) : BackgroundService
{
private static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(30);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
{
logger.LogWarning("Telegram__BotToken не задан — бот не стартует.");
logger.LogWarning("Telegram__BotToken is not set — bot will not start.");
return;
}
@@ -31,8 +35,14 @@ public sealed class TelegramBotHostedService(
DropPendingUpdates = true,
};
logger.LogInformation("Telegram-бот запускается (long polling)...");
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);
@@ -41,5 +51,18 @@ public sealed class TelegramBotHostedService(
{
// Штатная остановка вместе с приложением.
}
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();
}
}
}
@@ -1,5 +1,8 @@
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;
@@ -7,30 +10,49 @@ using Telegram.Bot.Types.ReplyMarkups;
namespace PnvPanel.Api.Telegram;
internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentityService identityService, IOptions<TelegramOptions> options)
: ITelegramNotifier
internal sealed class TelegramNotifier(
ITelegramBotClient botClient,
IIdentityService identityService,
IOptions<TelegramOptions> options
) : ITelegramNotifier
{
public async Task NotifyAdminsActivationRequestedAsync(
Guid requestId, string userName, string? comment, CancellationToken cancellationToken)
Guid requestId,
string userName,
string? comment,
CancellationToken cancellationToken
)
{
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
return;
var text = $"🆕 Запрос на активацию от <b>{Escape(userName)}</b>"
+ (string.IsNullOrWhiteSpace(comment) ? string.Empty : $"\nКомментарий: {Escape(comment)}");
var text =
$"🆕 Запрос на активацию от <b>{Escape(userName)}</b>"
+ (
string.IsNullOrWhiteSpace(comment)
? string.Empty
: $"\nКомментарий: {Escape(comment)}"
);
var keyboard = new InlineKeyboardMarkup(new[]
var keyboard = new InlineKeyboardMarkup(
new[]
{
InlineKeyboardButton.WithCallbackData("✅ Активировать", $"act:approve:{requestId}"),
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"act:reject:{requestId}"),
});
}
);
foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
{
try
{
await botClient.SendMessage(
adminId, text, parseMode: ParseMode.Html, replyMarkup: keyboard, cancellationToken: cancellationToken);
adminId,
text,
parseMode: ParseMode.Html,
replyMarkup: keyboard,
cancellationToken: cancellationToken
);
}
catch
{
@@ -39,7 +61,228 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
}
}
public async Task NotifyUserAsync(Guid userId, string message, CancellationToken cancellationToken)
public async Task NotifyAdminsBugReportCreatedAsync(
Guid ticketId,
string userName,
string message,
CancellationToken cancellationToken
)
{
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
return;
var preview = message.Length > 300 ? message[..300] + "…" : message;
var text =
$"🐞 Новый тикет (баг/предложение) от <b>{Escape(userName)}</b>\n{Escape(preview)}";
// Только ссылка на сайт — переписка и картинки удобнее там, инлайн-действий для баг-тикетов нет.
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;
@@ -48,9 +291,25 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
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, cancellationToken: cancellationToken);
await botClient.SendMessage(
telegramUserId,
message,
replyMarkup: keyboard,
cancellationToken: cancellationToken
);
}
catch
{
@@ -58,5 +317,48 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
}
}
private static string Escape(string text) => text.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
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 readonly Error AlreadyPending =
Error.Conflict("Activation.AlreadyPending", "У вас уже есть необработанный запрос на активацию.");
public static readonly Error AlreadyPending = Error.Conflict(
"Activation.AlreadyPending",
"У вас уже есть необработанный запрос на активацию."
);
public static readonly Error NotFound =
Error.NotFound("Activation.NotFound", "Запрос на активацию не найден.");
public static readonly Error NotFound = Error.NotFound(
"Activation.NotFound",
"Запрос на активацию не найден."
);
public static readonly Error AlreadyDecided =
Error.Conflict("Activation.AlreadyDecided", "Запрос на активацию уже обработан.");
public static readonly Error AlreadyDecided = Error.Conflict(
"Activation.AlreadyDecided",
"Запрос на активацию уже обработан."
);
}
@@ -1,4 +1,4 @@
using PnvPanel.Application.Common.Messaging;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Activation;
@@ -1,16 +1,22 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Activation;
namespace PnvPanel.Application.Activation;
public sealed class GetActivationStatusQueryHandler(IIdentityService identityService, IAppDbContext dbContext, ICurrentUser currentUser)
: IQueryHandler<GetActivationStatusQuery, Result<ActivationStatusDto>>
public sealed class GetActivationStatusQueryHandler(
IIdentityService identityService,
IAppDbContext dbContext,
ICurrentUser currentUser
) : IQueryHandler<GetActivationStatusQuery, Result<ActivationStatusDto>>
{
public async Task<Result<ActivationStatusDto>> Handle(GetActivationStatusQuery query, CancellationToken cancellationToken)
public async Task<Result<ActivationStatusDto>> Handle(
GetActivationStatusQuery query,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } userId)
return Result.Failure<ActivationStatusDto>(AuthErrors.Unauthorized);
@@ -19,8 +25,10 @@ public sealed class GetActivationStatusQueryHandler(IIdentityService identitySer
if (profile is null)
return Result.Failure<ActivationStatusDto>(AuthErrors.Unauthorized);
var pending = await dbContext.ActivationRequests
.Where(r => r.UserId == userId && r.Status == ActivationStatus.Pending)
var pending = await dbContext
.ActivationRequests.Where(r =>
r.UserId == userId && r.Status == ActivationStatus.Pending
)
.Select(r => new ActivationRequestDto(r.Id, r.Comment, r.CreatedAt))
.FirstOrDefaultAsync(cancellationToken);
@@ -1,6 +1,7 @@
using PnvPanel.Application.Common.Messaging;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Activation;
public sealed record RequestActivationCommand(string? Comment) : ICommand<Result<ActivationRequestDto>>;
public sealed record RequestActivationCommand(string? Comment)
: ICommand<Result<ActivationRequestDto>>;
@@ -1,23 +1,31 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Activation;
namespace PnvPanel.Application.Activation;
public sealed class RequestActivationCommandHandler(
IAppDbContext dbContext, IRealtimeNotifier notifier, ITelegramNotifier telegramNotifier, ICurrentUser currentUser)
: ICommandHandler<RequestActivationCommand, Result<ActivationRequestDto>>
IAppDbContext dbContext,
IRealtimeNotifier notifier,
ITelegramNotifier telegramNotifier,
ICurrentUser currentUser
) : ICommandHandler<RequestActivationCommand, Result<ActivationRequestDto>>
{
public async Task<Result<ActivationRequestDto>> Handle(RequestActivationCommand command, CancellationToken cancellationToken)
public async Task<Result<ActivationRequestDto>> Handle(
RequestActivationCommand command,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } userId)
return Result.Failure<ActivationRequestDto>(AuthErrors.Unauthorized);
var hasPending = await dbContext.ActivationRequests
.AnyAsync(r => r.UserId == userId && r.Status == ActivationStatus.Pending, cancellationToken);
var hasPending = await dbContext.ActivationRequests.AnyAsync(
r => r.UserId == userId && r.Status == ActivationStatus.Pending,
cancellationToken
);
if (hasPending)
return Result.Failure<ActivationRequestDto>(ActivationErrors.AlreadyPending);
@@ -27,9 +35,23 @@ public sealed class RequestActivationCommandHandler(
var userName = currentUser.UserName ?? userId.ToString();
await notifier.NotifyActivationRequestedAsync(request.Id, userId, userName, request.Comment, request.CreatedAt, cancellationToken);
await telegramNotifier.NotifyAdminsActivationRequestedAsync(request.Id, userName, request.Comment, cancellationToken);
await notifier.NotifyActivationRequestedAsync(
request.Id,
userId,
userName,
request.Comment,
request.CreatedAt,
cancellationToken
);
await telegramNotifier.NotifyAdminsActivationRequestedAsync(
request.Id,
userName,
request.Comment,
cancellationToken
);
return Result.Success(new ActivationRequestDto(request.Id, request.Comment, request.CreatedAt));
return Result.Success(
new ActivationRequestDto(request.Id, request.Comment, request.CreatedAt)
);
}
}
@@ -6,6 +6,7 @@ public sealed class RequestActivationCommandValidator : AbstractValidator<Reques
{
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? Comment,
ActivationStatus Status,
DateTimeOffset CreatedAt);
DateTimeOffset CreatedAt
);
@@ -1,4 +1,4 @@
using PnvPanel.Application.Common.Messaging;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Activation;
@@ -2,24 +2,33 @@ using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Activation;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Activation;
using PnvPanel.Domain.Audit;
namespace PnvPanel.Application.Admin.Activation;
public sealed class ApproveActivationCommandHandler(
IAppDbContext dbContext, IIdentityService identityService, IRealtimeNotifier notifier,
ITelegramNotifier telegramNotifier, ICurrentUser currentUser)
: ICommandHandler<ApproveActivationCommand, Result>
IAppDbContext dbContext,
IIdentityService identityService,
IRealtimeNotifier notifier,
ITelegramNotifier telegramNotifier,
ICurrentUser currentUser
) : ICommandHandler<ApproveActivationCommand, Result>
{
public async Task<Result> Handle(ApproveActivationCommand command, CancellationToken cancellationToken)
public async Task<Result> Handle(
ApproveActivationCommand command,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } adminId)
return Result.Failure(AuthErrors.Unauthorized);
var request = await dbContext.ActivationRequests
.FirstOrDefaultAsync(r => r.Id == command.RequestId, cancellationToken);
var request = await dbContext.ActivationRequests.FirstOrDefaultAsync(
r => r.Id == command.RequestId,
cancellationToken
);
if (request is null)
return Result.Failure(ActivationErrors.NotFound);
@@ -29,12 +38,32 @@ public sealed class ApproveActivationCommandHandler(
request.Approve(adminId);
var activateResult = await identityService.ActivateUserAsync(request.UserId, adminId, cancellationToken);
var activateResult = await identityService.ActivateUserAsync(
request.UserId,
adminId,
cancellationToken
);
if (!activateResult.IsSuccess)
return activateResult;
dbContext.AuditLogs.Add(
AuditLog.Create(
adminId,
"ActivationApproved",
"User",
request.UserId.ToString(),
metadata: null,
AuditSource.Web
)
);
await notifier.NotifyUserActivatedAsync(request.UserId, cancellationToken);
await telegramNotifier.NotifyUserAsync(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.Domain.Activation;
namespace PnvPanel.Application.Admin.Activation;
public sealed record ListActivationRequestsQuery(ActivationStatus? StatusFilter, int Page, int PageSize)
: IQuery<Result<PagedList<ActivationRequestAdminDto>>>;
public sealed record ListActivationRequestsQuery(
ActivationStatus? StatusFilter,
int Page,
int PageSize
) : IQuery<Result<PagedList<ActivationRequestAdminDto>>>;
@@ -1,14 +1,19 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Activation;
public sealed class ListActivationRequestsQueryHandler(IAppDbContext dbContext, IIdentityService identityService)
: IQueryHandler<ListActivationRequestsQuery, Result<PagedList<ActivationRequestAdminDto>>>
public sealed class ListActivationRequestsQueryHandler(
IAppDbContext dbContext,
IIdentityService identityService
) : IQueryHandler<ListActivationRequestsQuery, Result<PagedList<ActivationRequestAdminDto>>>
{
public async Task<Result<PagedList<ActivationRequestAdminDto>>> Handle(ListActivationRequestsQuery query, CancellationToken cancellationToken)
public async Task<Result<PagedList<ActivationRequestAdminDto>>> Handle(
ListActivationRequestsQuery query,
CancellationToken cancellationToken
)
{
var page = query.Page <= 0 ? 1 : query.Page;
var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize;
@@ -23,13 +28,22 @@ public sealed class ListActivationRequestsQueryHandler(IAppDbContext dbContext,
var userNames = await identityService.GetUserNamesAsync(
page1.Items.Select(r => r.UserId).Distinct().ToList(),
cancellationToken);
cancellationToken
);
var items = page1.Items
.Select(r => new ActivationRequestAdminDto(
r.Id, r.UserId, userNames.GetValueOrDefault(r.UserId, "?"), r.Comment, r.Status, r.CreatedAt))
var items = page1
.Items.Select(r => new ActivationRequestAdminDto(
r.Id,
r.UserId,
userNames.GetValueOrDefault(r.UserId, "?"),
r.Comment,
r.Status,
r.CreatedAt
))
.ToList();
return Result.Success(new PagedList<ActivationRequestAdminDto>(items, page1.Total, page1.Page, page1.PageSize));
return Result.Success(
new PagedList<ActivationRequestAdminDto>(items, page1.Total, page1.Page, page1.PageSize)
);
}
}
@@ -1,4 +1,4 @@
using PnvPanel.Application.Common.Messaging;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Activation;
@@ -2,22 +2,30 @@ using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Activation;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Activation;
using PnvPanel.Domain.Audit;
namespace PnvPanel.Application.Admin.Activation;
public sealed class RejectActivationCommandHandler(IAppDbContext dbContext, ICurrentUser currentUser)
: ICommandHandler<RejectActivationCommand, Result>
public sealed class RejectActivationCommandHandler(
IAppDbContext dbContext,
ICurrentUser currentUser
) : ICommandHandler<RejectActivationCommand, Result>
{
public async Task<Result> Handle(RejectActivationCommand command, CancellationToken cancellationToken)
public async Task<Result> Handle(
RejectActivationCommand command,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } adminId)
return Result.Failure(AuthErrors.Unauthorized);
var request = await dbContext.ActivationRequests
.FirstOrDefaultAsync(r => r.Id == command.RequestId, cancellationToken);
var request = await dbContext.ActivationRequests.FirstOrDefaultAsync(
r => r.Id == command.RequestId,
cancellationToken
);
if (request is null)
return Result.Failure(ActivationErrors.NotFound);
@@ -26,6 +34,18 @@ public sealed class RejectActivationCommandHandler(IAppDbContext dbContext, ICur
return Result.Failure(ActivationErrors.AlreadyDecided);
request.Reject(adminId, command.Reason);
dbContext.AuditLogs.Add(
AuditLog.Create(
adminId,
"ActivationRejected",
"User",
request.UserId.ToString(),
metadata: null,
AuditSource.Web
)
);
return Result.Success();
}
}
@@ -3,10 +3,27 @@ using PnvPanel.Domain.Apps;
namespace PnvPanel.Application.Admin.Apps;
public sealed record AdminAppDto(
Guid Id, string Name, string DownloadUrl, OsPlatform OperatingSystem, string? Description,
string? IconUrl, int SortOrder, bool IsEnabled)
Guid Id,
string Name,
string DownloadUrl,
OsPlatform OperatingSystem,
string? Description,
string? IconUrl,
int SortOrder,
bool IsEnabled,
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);
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
);
}
@@ -4,5 +4,8 @@ namespace PnvPanel.Application.Admin.Apps;
public static class AppErrors
{
public static readonly Error NotFound = Error.NotFound("Apps.NotFound", "Приложение не найдено.");
public static readonly Error NotFound = Error.NotFound(
"Apps.NotFound",
"Приложение не найдено."
);
}
@@ -1,9 +1,15 @@
using PnvPanel.Application.Common.Messaging;
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)
: ICommand<Result<AdminAppDto>>;
string Name,
string DownloadUrl,
OsPlatform OperatingSystem,
string? Description,
string? IconUrl,
int SortOrder,
bool IsRecommended
) : ICommand<Result<AdminAppDto>>;
@@ -1,17 +1,27 @@
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
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 sealed class CreateAppCommandHandler(IAppDbContext dbContext)
: ICommandHandler<CreateAppCommand, Result<AdminAppDto>>
{
public Task<Result<AdminAppDto>> Handle(CreateAppCommand command, CancellationToken cancellationToken)
public Task<Result<AdminAppDto>> Handle(
CreateAppCommand command,
CancellationToken cancellationToken
)
{
var app = ClientApp.Create(
command.Name, new Uri(command.DownloadUrl, UriKind.Absolute), command.OperatingSystem,
command.Description, command.IconUrl, command.SortOrder);
command.Name,
new Uri(command.DownloadUrl, UriKind.Absolute),
command.OperatingSystem,
command.Description,
command.IconUrl,
command.SortOrder,
command.IsRecommended
);
dbContext.ClientApps.Add(app);
@@ -1,4 +1,4 @@
using PnvPanel.Application.Common.Messaging;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Apps;
@@ -1,15 +1,19 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Apps;
public sealed class DeleteAppCommandHandler(IAppDbContext dbContext) : ICommandHandler<DeleteAppCommand, Result>
public sealed class DeleteAppCommandHandler(IAppDbContext dbContext)
: ICommandHandler<DeleteAppCommand, Result>
{
public async Task<Result> Handle(DeleteAppCommand command, CancellationToken cancellationToken)
{
var app = await dbContext.ClientApps.FirstOrDefaultAsync(a => a.Id == command.AppId, cancellationToken);
var app = await dbContext.ClientApps.FirstOrDefaultAsync(
a => a.Id == command.AppId,
cancellationToken
);
if (app is null)
return Result.Failure(AppErrors.NotFound);
@@ -1,4 +1,4 @@
using PnvPanel.Application.Common.Messaging;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Apps;
@@ -1,18 +1,26 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Apps;
public sealed class ListAdminAppsQueryHandler(IAppDbContext dbContext) : IQueryHandler<ListAdminAppsQuery, Result<IReadOnlyList<AdminAppDto>>>
public sealed class ListAdminAppsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListAdminAppsQuery, Result<IReadOnlyList<AdminAppDto>>>
{
public async Task<Result<IReadOnlyList<AdminAppDto>>> Handle(ListAdminAppsQuery query, CancellationToken cancellationToken)
public async Task<Result<IReadOnlyList<AdminAppDto>>> Handle(
ListAdminAppsQuery query,
CancellationToken cancellationToken
)
{
var apps = await dbContext.ClientApps.AsNoTracking()
.OrderBy(a => a.OperatingSystem).ThenBy(a => a.SortOrder)
var apps = await dbContext
.ClientApps.AsNoTracking()
.OrderBy(a => a.OperatingSystem)
.ThenBy(a => a.SortOrder)
.ToListAsync(cancellationToken);
return Result.Success<IReadOnlyList<AdminAppDto>>(apps.Select(AdminAppDto.FromDomain).ToList());
return Result.Success<IReadOnlyList<AdminAppDto>>(
apps.Select(AdminAppDto.FromDomain).ToList()
);
}
}
@@ -1,10 +1,17 @@
using PnvPanel.Application.Common.Messaging;
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)
: ICommand<Result<AdminAppDto>>;
Guid AppId,
string Name,
string DownloadUrl,
OsPlatform OperatingSystem,
string? Description,
string? IconUrl,
int SortOrder,
bool IsEnabled,
bool IsRecommended
) : ICommand<Result<AdminAppDto>>;
@@ -1,21 +1,35 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Apps;
public sealed class UpdateAppCommandHandler(IAppDbContext dbContext) : ICommandHandler<UpdateAppCommand, Result<AdminAppDto>>
public sealed class UpdateAppCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateAppCommand, Result<AdminAppDto>>
{
public async Task<Result<AdminAppDto>> Handle(UpdateAppCommand command, CancellationToken cancellationToken)
public async Task<Result<AdminAppDto>> Handle(
UpdateAppCommand command,
CancellationToken cancellationToken
)
{
var app = await dbContext.ClientApps.FirstOrDefaultAsync(a => a.Id == command.AppId, cancellationToken);
var app = await dbContext.ClientApps.FirstOrDefaultAsync(
a => a.Id == command.AppId,
cancellationToken
);
if (app is null)
return Result.Failure<AdminAppDto>(AppErrors.NotFound);
app.Update(
command.Name, new Uri(command.DownloadUrl, UriKind.Absolute), command.OperatingSystem,
command.Description, command.IconUrl, command.SortOrder, command.IsEnabled);
command.Name,
new Uri(command.DownloadUrl, UriKind.Absolute),
command.OperatingSystem,
command.Description,
command.IconUrl,
command.SortOrder,
command.IsEnabled,
command.IsRecommended
);
return Result.Success(AdminAppDto.FromDomain(app));
}
@@ -1,11 +1,24 @@
using PnvPanel.Application.Common.Messaging;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Audit;
namespace PnvPanel.Application.Admin.Audit;
public sealed record ListAuditLogsQuery(int Page, int PageSize) : IQuery<Result<PagedList<AuditLogDto>>>;
public sealed record ListAuditLogsQuery(
int Page,
int PageSize,
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);
long Id,
Guid? ActorId,
string Action,
string TargetType,
string TargetId,
string? Metadata,
AuditSource Source,
DateTimeOffset CreatedAt
);
@@ -1,20 +1,42 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Audit;
public sealed class ListAuditLogsQueryHandler(IAppDbContext dbContext) : IQueryHandler<ListAuditLogsQuery, Result<PagedList<AuditLogDto>>>
public sealed class ListAuditLogsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListAuditLogsQuery, Result<PagedList<AuditLogDto>>>
{
public async Task<Result<PagedList<AuditLogDto>>> Handle(ListAuditLogsQuery query, CancellationToken cancellationToken)
public async Task<Result<PagedList<AuditLogDto>>> Handle(
ListAuditLogsQuery query,
CancellationToken cancellationToken
)
{
var page = query.Page <= 0 ? 1 : query.Page;
var pageSize = query.PageSize is <= 0 or > 200 ? 50 : query.PageSize;
var result = await dbContext.AuditLogs.AsNoTracking()
var 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))
.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;
public sealed record InboundDto(
Guid Id, Guid NodeId, string RemoteInboundId, VpnProtocol Protocol, string Remark, int Port,
bool IsPublished, string? DisplayName, int? MaxClients, IReadOnlyList<Guid> AllowedRoleIds,
DateTimeOffset? LastSyncAt)
Guid Id,
Guid NodeId,
string RemoteInboundId,
VpnProtocol Protocol,
string Remark,
int Port,
bool IsPublished,
bool IsAvailable,
string? DisplayName,
IReadOnlyList<Guid> AllowedRoleIds,
DateTimeOffset? LastSyncAt
)
{
public static InboundDto FromDomain(Inbound inbound) => new(
inbound.Id, inbound.NodeId, inbound.RemoteInboundId, inbound.Protocol, inbound.Remark, inbound.Port,
inbound.IsPublished, inbound.DisplayName, inbound.MaxClients, inbound.AllowedRoleIds, inbound.LastSyncAt);
public static InboundDto FromDomain(Inbound inbound) =>
new(
inbound.Id,
inbound.NodeId,
inbound.RemoteInboundId,
inbound.Protocol,
inbound.Remark,
inbound.Port,
inbound.IsPublished,
inbound.IsAvailable,
inbound.DisplayName,
inbound.AllowedRoleIds,
inbound.LastSyncAt
);
}
@@ -4,5 +4,13 @@ namespace PnvPanel.Application.Admin.Inbounds;
public static class InboundErrors
{
public static readonly Error NotFound = Error.NotFound("Inbounds.NotFound", "Inbound не найден.");
public static readonly Error NotFound = Error.NotFound(
"Inbounds.NotFound",
"Inbound не найден."
);
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;
namespace PnvPanel.Application.Admin.Inbounds;
@@ -1,19 +1,25 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Inbounds;
public sealed class ListInboundsQueryHandler(IAppDbContext dbContext) : IQueryHandler<ListInboundsQuery, Result<IReadOnlyList<InboundDto>>>
public sealed class ListInboundsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListInboundsQuery, Result<IReadOnlyList<InboundDto>>>
{
public async Task<Result<IReadOnlyList<InboundDto>>> Handle(ListInboundsQuery query, CancellationToken cancellationToken)
public async Task<Result<IReadOnlyList<InboundDto>>> Handle(
ListInboundsQuery query,
CancellationToken cancellationToken
)
{
var inboundsQuery = dbContext.Inbounds.AsNoTracking();
if (query.NodeId is { } nodeId)
inboundsQuery = inboundsQuery.Where(i => i.NodeId == nodeId);
var inbounds = await inboundsQuery.OrderBy(i => i.Remark).ToListAsync(cancellationToken);
return Result.Success<IReadOnlyList<InboundDto>>(inbounds.Select(InboundDto.FromDomain).ToList());
return Result.Success<IReadOnlyList<InboundDto>>(
inbounds.Select(InboundDto.FromDomain).ToList()
);
}
}
@@ -1,8 +1,11 @@
using PnvPanel.Application.Common.Messaging;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Inbounds;
public sealed record PublishInboundCommand(
Guid InboundId, bool IsPublished, string? DisplayName, IReadOnlyList<Guid> AllowedRoleIds, int? MaxClients)
: ICommand<Result<InboundDto>>;
Guid InboundId,
bool IsPublished,
string? DisplayName,
IReadOnlyList<Guid> AllowedRoleIds
) : ICommand<Result<InboundDto>>;
@@ -1,24 +1,42 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Audit;
namespace PnvPanel.Application.Admin.Inbounds;
public sealed class PublishInboundCommandHandler(IAppDbContext dbContext)
public sealed class PublishInboundCommandHandler(IAppDbContext dbContext, ICurrentUser currentUser)
: ICommandHandler<PublishInboundCommand, Result<InboundDto>>
{
public async Task<Result<InboundDto>> Handle(PublishInboundCommand command, CancellationToken cancellationToken)
public async Task<Result<InboundDto>> Handle(
PublishInboundCommand command,
CancellationToken cancellationToken
)
{
var inbound = await dbContext.Inbounds.FirstOrDefaultAsync(i => i.Id == command.InboundId, cancellationToken);
var inbound = await dbContext.Inbounds.FirstOrDefaultAsync(
i => i.Id == command.InboundId,
cancellationToken
);
if (inbound is null)
return Result.Failure<InboundDto>(InboundErrors.NotFound);
if (command.IsPublished)
inbound.Publish(command.DisplayName, command.AllowedRoleIds, command.MaxClients);
inbound.Publish(command.DisplayName, command.AllowedRoleIds);
else
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));
}
}
@@ -7,6 +7,5 @@ public sealed class PublishInboundCommandValidator : AbstractValidator<PublishIn
public PublishInboundCommandValidator()
{
RuleFor(x => x.DisplayName).MaximumLength(100);
RuleFor(x => x.MaxClients).GreaterThan(0).When(x => x.MaxClients.HasValue);
}
}
@@ -0,0 +1,8 @@
using LiteCqrs;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.Instructions;
namespace PnvPanel.Application.Admin.Instructions;
public sealed record CreateInstructionTabCommand(string Title, string Body, int SortOrder)
: ICommand<Result<InstructionTabDto>>;
@@ -0,0 +1,22 @@
using PnvPanel.Application.Common.Interfaces;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.Instructions;
using PnvPanel.Domain.Instructions;
namespace PnvPanel.Application.Admin.Instructions;
public sealed class CreateInstructionTabCommandHandler(IAppDbContext dbContext)
: ICommandHandler<CreateInstructionTabCommand, Result<InstructionTabDto>>
{
public Task<Result<InstructionTabDto>> Handle(
CreateInstructionTabCommand command,
CancellationToken cancellationToken
)
{
var tab = InstructionTab.Create(command.Title, command.Body, command.SortOrder);
dbContext.InstructionTabs.Add(tab);
return Task.FromResult(Result.Success(InstructionTabDto.FromDomain(tab)));
}
}
@@ -0,0 +1,13 @@
using FluentValidation;
namespace PnvPanel.Application.Admin.Instructions;
public sealed class CreateInstructionTabCommandValidator
: AbstractValidator<CreateInstructionTabCommand>
{
public CreateInstructionTabCommandValidator()
{
RuleFor(x => x.Title).NotEmpty().MaximumLength(100);
RuleFor(x => x.Body).NotEmpty().MaximumLength(20000);
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Instructions;
public sealed record DeleteInstructionTabCommand(Guid TabId) : ICommand<Result>;
@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using LiteCqrs;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Instructions;
public sealed class DeleteInstructionTabCommandHandler(IAppDbContext dbContext)
: ICommandHandler<DeleteInstructionTabCommand, Result>
{
public async Task<Result> Handle(
DeleteInstructionTabCommand command,
CancellationToken cancellationToken
)
{
var tab = await dbContext.InstructionTabs.FirstOrDefaultAsync(
t => t.Id == command.TabId,
cancellationToken
);
if (tab is null)
return Result.Failure(InstructionErrors.TabNotFound);
dbContext.InstructionTabs.Remove(tab);
return Result.Success();
}
}

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