From 8f6807a456265a7896afc5c92c01e8fc3da6363d Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Tue, 14 Jul 2026 23:11:50 +0300 Subject: [PATCH] Enhance inbound management by removing MaxClients property - 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. --- Dockerfile | 4 + .../Endpoints/InboundEndpoints.cs | 6 +- .../Admin/Inbounds/InboundDto.cs | 2 - .../Admin/Inbounds/PublishInboundCommand.cs | 3 +- .../Inbounds/PublishInboundCommandHandler.cs | 2 +- .../PublishInboundCommandValidator.cs | 1 - .../Admin/Users/DeleteUserCommandHandler.cs | 25 +- .../Users/ForceRevokeConfigCommandHandler.cs | 19 +- .../DeleteMyAccountCommandHandler.cs | 23 +- .../Common/Behaviors/UnitOfWorkBehavior.cs | 8 +- .../Configs/ConfigErrors.cs | 5 + .../Create/CreateVpnConfigCommandHandler.cs | 3 +- .../Edit/EditVpnConfigCommandHandler.cs | 25 +- .../Revoke/RevokeVpnConfigCommandHandler.cs | 21 +- .../src/PnvPanel.Domain/Inbounds/Inbound.cs | 8 +- ...200833_RemoveInboundMaxClients.Designer.cs | 938 ++++++++++++++++++ .../20260714200833_RemoveInboundMaxClients.cs | 28 + .../Migrations/AppDbContextModelSnapshot.cs | 3 - .../ListAllConfigsQueryHandlerTests.cs | 2 +- .../PublishInboundCommandHandlerTests.cs | 9 +- .../Users/DeleteUserCommandHandlerTests.cs | 16 +- .../GetMyConfigsQueryHandlerTests.cs | 2 +- .../RevokeVpnConfigCommandHandlerTests.cs | 27 +- .../Inbounds/InboundTests.cs | 7 +- .../Admin/NodeInboundCrudTests.cs | 2 - .../Configs/ConfigQuotaTests.cs | 1 - docker-compose.yml | 10 + docs/api-design.md | 2 +- docs/domain-model.md | 8 +- .../admin/inbounds/PublishInboundDialog.tsx | 7 +- frontend/src/features/admin/inbounds/api.ts | 3 +- frontend/src/shared/api/schema.gen.ts | 4 - frontend/src/shared/api/types.ts | 1 - frontend/src/shared/lib/i18n.ts | 2 - 34 files changed, 1150 insertions(+), 77 deletions(-) create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260714200833_RemoveInboundMaxClients.Designer.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260714200833_RemoveInboundMaxClients.cs diff --git a/Dockerfile b/Dockerfile index 5a91c37..2d4a52e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,6 +37,10 @@ ENV ASPNETCORE_ENVIRONMENT=Production \ ASPNETCORE_HTTP_PORTS=8080 EXPOSE 8080 COPY --from=backend /app/publish ./ +# Data Protection key-ring и загрузки тикетов монтируются сюда volume'ами (см. docker-compose.yml). +# Готовим права заранее — Docker скопирует владельца/содержимое в volume при первом маунте (copy-up). +RUN mkdir -p /app/keys /app/uploads && chown -R app:app /app/keys /app/uploads +USER app HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=5 \ CMD curl -f http://localhost:8080/health || exit 1 ENTRYPOINT ["dotnet", "PnvPanel.Api.dll"] diff --git a/backend/src/PnvPanel.Api/Endpoints/InboundEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/InboundEndpoints.cs index 3f7d180..928c57f 100644 --- a/backend/src/PnvPanel.Api/Endpoints/InboundEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/InboundEndpoints.cs @@ -40,8 +40,7 @@ public static class InboundEndpoints id, body.IsPublished, body.DisplayName, - body.AllowedRoleIds ?? [], - body.MaxClients + body.AllowedRoleIds ?? [] ); var result = await sender.Send(command, cancellationToken); return result.ToHttpResult(); @@ -51,6 +50,5 @@ public static class InboundEndpoints public sealed record PublishInboundBody( bool IsPublished, string? DisplayName, - IReadOnlyList? AllowedRoleIds, - int? MaxClients + IReadOnlyList? AllowedRoleIds ); diff --git a/backend/src/PnvPanel.Application/Admin/Inbounds/InboundDto.cs b/backend/src/PnvPanel.Application/Admin/Inbounds/InboundDto.cs index 3624843..f58efd7 100644 --- a/backend/src/PnvPanel.Application/Admin/Inbounds/InboundDto.cs +++ b/backend/src/PnvPanel.Application/Admin/Inbounds/InboundDto.cs @@ -11,7 +11,6 @@ public sealed record InboundDto( int Port, bool IsPublished, string? DisplayName, - int? MaxClients, IReadOnlyList AllowedRoleIds, DateTimeOffset? LastSyncAt ) @@ -26,7 +25,6 @@ public sealed record InboundDto( inbound.Port, inbound.IsPublished, inbound.DisplayName, - inbound.MaxClients, inbound.AllowedRoleIds, inbound.LastSyncAt ); diff --git a/backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommand.cs b/backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommand.cs index b8b8529..69ece38 100644 --- a/backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommand.cs +++ b/backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommand.cs @@ -7,6 +7,5 @@ public sealed record PublishInboundCommand( Guid InboundId, bool IsPublished, string? DisplayName, - IReadOnlyList AllowedRoleIds, - int? MaxClients + IReadOnlyList AllowedRoleIds ) : ICommand>; diff --git a/backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommandHandler.cs index ab341d3..5113fdc 100644 --- a/backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommandHandler.cs @@ -22,7 +22,7 @@ public sealed class PublishInboundCommandHandler(IAppDbContext dbContext, ICurre return Result.Failure(InboundErrors.NotFound); if (command.IsPublished) - inbound.Publish(command.DisplayName, command.AllowedRoleIds, command.MaxClients); + inbound.Publish(command.DisplayName, command.AllowedRoleIds); else inbound.Unpublish(); diff --git a/backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommandValidator.cs index 31780d9..f91cb1f 100644 --- a/backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommandValidator.cs +++ b/backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommandValidator.cs @@ -7,6 +7,5 @@ public sealed class PublishInboundCommandValidator : AbstractValidator x.DisplayName).MaximumLength(100); - RuleFor(x => x.MaxClients).GreaterThan(0).When(x => x.MaxClients.HasValue); } } diff --git a/backend/src/PnvPanel.Application/Admin/Users/DeleteUserCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Users/DeleteUserCommandHandler.cs index 0d00529..87d29f6 100644 --- a/backend/src/PnvPanel.Application/Admin/Users/DeleteUserCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Users/DeleteUserCommandHandler.cs @@ -1,7 +1,9 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Messaging; using PnvPanel.Application.Common.Models; +using PnvPanel.Application.Configs; using PnvPanel.Domain.Audit; using PnvPanel.Domain.Configs; @@ -13,7 +15,8 @@ public sealed class DeleteUserCommandHandler( IIdentityService identityService, IXuiPanelGateway gateway, ITelegramNotifier telegramNotifier, - ICurrentUser currentUser + ICurrentUser currentUser, + ILogger logger ) : ICommandHandler { public async Task Handle(DeleteUserCommand command, CancellationToken cancellationToken) @@ -37,7 +40,8 @@ public sealed class DeleteUserCommandHandler( .FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken); if (inbound is not null && node is not null) - await gateway.RemoveClientAsync( + { + var removeResult = await gateway.RemoveClientAsync( node, inbound.RemoteInboundId, config.ClientExternalId, @@ -45,6 +49,23 @@ public sealed class DeleteUserCommandHandler( cancellationToken ); + if (!removeResult.IsSuccess) + { + // Нода недоступна — прерываем удаление целиком, не трогая учётку/уже + // просмотренные конфиги: иначе после удаления AppUser отозвать оставшиеся + // клиенты в 3x-ui будет уже не от чего (некому будет принадлежать конфиг). + // Админ может повторить DeleteUser позже, когда нода отойдёт. + logger.LogWarning( + "Failed to remove client for config {ConfigId} on node {NodeId} while deleting user {UserId}: {Error}", + config.Id, + node.Id, + command.UserId, + removeResult.Error + ); + return Result.Failure(ConfigErrors.NodeUnavailable); + } + } + config.Revoke(); } diff --git a/backend/src/PnvPanel.Application/Admin/Users/ForceRevokeConfigCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Users/ForceRevokeConfigCommandHandler.cs index d77a0c6..aca63f2 100644 --- a/backend/src/PnvPanel.Application/Admin/Users/ForceRevokeConfigCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Users/ForceRevokeConfigCommandHandler.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Messaging; using PnvPanel.Application.Common.Models; @@ -13,7 +14,8 @@ public sealed class ForceRevokeConfigCommandHandler( IXuiPanelGateway gateway, IRealtimeNotifier notifier, ITelegramNotifier telegramNotifier, - ICurrentUser currentUser + ICurrentUser currentUser, + ILogger logger ) : ICommandHandler { public async Task Handle( @@ -41,7 +43,8 @@ public sealed class ForceRevokeConfigCommandHandler( .FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken); if (inbound is not null && node is not null) - await gateway.RemoveClientAsync( + { + var removeResult = await gateway.RemoveClientAsync( node, inbound.RemoteInboundId, config.ClientExternalId, @@ -49,6 +52,18 @@ public sealed class ForceRevokeConfigCommandHandler( cancellationToken ); + if (!removeResult.IsSuccess) + { + logger.LogWarning( + "Failed to remove client for config {ConfigId} on node {NodeId} during force-revoke: {Error}", + config.Id, + node.Id, + removeResult.Error + ); + return Result.Failure(ConfigErrors.NodeUnavailable); + } + } + config.Revoke(); await notifier.NotifyConfigStatusChangedAsync( config.UserId, diff --git a/backend/src/PnvPanel.Application/Auth/DeleteMyAccount/DeleteMyAccountCommandHandler.cs b/backend/src/PnvPanel.Application/Auth/DeleteMyAccount/DeleteMyAccountCommandHandler.cs index 9b2f27b..6e875b3 100644 --- a/backend/src/PnvPanel.Application/Auth/DeleteMyAccount/DeleteMyAccountCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Auth/DeleteMyAccount/DeleteMyAccountCommandHandler.cs @@ -1,7 +1,9 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Messaging; using PnvPanel.Application.Common.Models; +using PnvPanel.Application.Configs; using PnvPanel.Domain.Configs; namespace PnvPanel.Application.Auth.DeleteMyAccount; @@ -10,7 +12,8 @@ public sealed class DeleteMyAccountCommandHandler( IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway, - ICurrentUser currentUser + ICurrentUser currentUser, + ILogger logger ) : ICommandHandler { public async Task Handle( @@ -37,7 +40,8 @@ public sealed class DeleteMyAccountCommandHandler( .FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken); if (inbound is not null && node is not null) - await gateway.RemoveClientAsync( + { + var removeResult = await gateway.RemoveClientAsync( node, inbound.RemoteInboundId, config.ClientExternalId, @@ -45,6 +49,21 @@ public sealed class DeleteMyAccountCommandHandler( cancellationToken ); + if (!removeResult.IsSuccess) + { + // Прерываем самоудаление целиком — иначе после удаления учётки отозвать + // оставшиеся клиенты в 3x-ui будет уже не от чего. Пользователь может повторить. + logger.LogWarning( + "Failed to remove client for config {ConfigId} on node {NodeId} while user {UserId} deletes own account: {Error}", + config.Id, + node.Id, + userId, + removeResult.Error + ); + return Result.Failure(ConfigErrors.NodeUnavailable); + } + } + config.Revoke(); } diff --git a/backend/src/PnvPanel.Application/Common/Behaviors/UnitOfWorkBehavior.cs b/backend/src/PnvPanel.Application/Common/Behaviors/UnitOfWorkBehavior.cs index ae9a662..8988476 100644 --- a/backend/src/PnvPanel.Application/Common/Behaviors/UnitOfWorkBehavior.cs +++ b/backend/src/PnvPanel.Application/Common/Behaviors/UnitOfWorkBehavior.cs @@ -1,5 +1,6 @@ using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; namespace PnvPanel.Application.Common.Behaviors; @@ -19,7 +20,12 @@ public sealed class UnitOfWorkBehavior(IAppDbContext dbCont ) { var response = await next(); - await dbContext.SaveChangesAsync(cancellationToken); + + // Не коммитим мутации, сделанные до отказа хендлера (например Approve() до фейла + // последующего шага) — иначе в БД закрепляется частичное/противоречивое состояние. + if (response is not Result { IsSuccess: false }) + await dbContext.SaveChangesAsync(cancellationToken); + return response; } } diff --git a/backend/src/PnvPanel.Application/Configs/ConfigErrors.cs b/backend/src/PnvPanel.Application/Configs/ConfigErrors.cs index 2a1c5da..d3f5a7c 100644 --- a/backend/src/PnvPanel.Application/Configs/ConfigErrors.cs +++ b/backend/src/PnvPanel.Application/Configs/ConfigErrors.cs @@ -25,4 +25,9 @@ public static class ConfigErrors ); public static readonly Error NotFound = Error.NotFound("Configs.NotFound", "Конфиг не найден."); + + public static readonly Error NodeUnavailable = Error.Failure( + "Configs.NodeUnavailable", + "Сервер временно недоступен. Попробуйте повторить операцию позже." + ); } diff --git a/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs b/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs index 7e775ab..15f9115 100644 --- a/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs @@ -4,6 +4,7 @@ using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Messaging; using PnvPanel.Application.Common.Models; using PnvPanel.Domain.Configs; +using PnvPanel.Domain.Nodes; namespace PnvPanel.Application.Configs.Create; @@ -39,7 +40,7 @@ public sealed class CreateVpnConfigCommandHandler( var node = await dbContext .Nodes.AsNoTracking() .FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken); - if (node is null || !node.IsEnabled) + if (node is null || !node.IsEnabled || node.Status == NodeStatus.Offline) return Result.Failure(ConfigErrors.NodeDisabled); var config = VpnConfig.Create(userId, inbound.Id, inbound.Protocol, command.Label); diff --git a/backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommandHandler.cs b/backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommandHandler.cs index 0226c8c..b492d9c 100644 --- a/backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommandHandler.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using PnvPanel.Application.Auth; using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Messaging; @@ -9,7 +10,8 @@ namespace PnvPanel.Application.Configs.Edit; public sealed class EditVpnConfigCommandHandler( IAppDbContext dbContext, IXuiPanelGateway gateway, - ICurrentUser currentUser + ICurrentUser currentUser, + ILogger logger ) : ICommandHandler> { public async Task> Handle( @@ -35,23 +37,36 @@ public sealed class EditVpnConfigCommandHandler( if (command.Label is not null) { - config.Rename(command.Label); - var node = await dbContext .Nodes.AsNoTracking() .FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken); if (node is not null) { - await gateway.UpdateClientAsync( + var updateResult = await gateway.UpdateClientAsync( node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, - config.Label ?? config.ClientEmail, + command.Label, enable: true, cancellationToken ); + + if (!updateResult.IsSuccess) + { + // Нода недоступна — не переименовываем локально, иначе метка разойдётся + // с тем, что реально хранится в 3x-ui. Пользователь может повторить. + logger.LogWarning( + "Failed to rename client for config {ConfigId} on node {NodeId}: {Error}", + config.Id, + node.Id, + updateResult.Error + ); + return Result.Failure(ConfigErrors.NodeUnavailable); + } } + + config.Rename(command.Label); } return Result.Success(VpnConfigDto.FromDomain(config, inbound)); diff --git a/backend/src/PnvPanel.Application/Configs/Revoke/RevokeVpnConfigCommandHandler.cs b/backend/src/PnvPanel.Application/Configs/Revoke/RevokeVpnConfigCommandHandler.cs index 4d64b60..07de128 100644 --- a/backend/src/PnvPanel.Application/Configs/Revoke/RevokeVpnConfigCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Configs/Revoke/RevokeVpnConfigCommandHandler.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using PnvPanel.Application.Auth; using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Messaging; @@ -11,7 +12,8 @@ public sealed class RevokeVpnConfigCommandHandler( IAppDbContext dbContext, IXuiPanelGateway gateway, IRealtimeNotifier notifier, - ICurrentUser currentUser + ICurrentUser currentUser, + ILogger logger ) : ICommandHandler { public async Task Handle( @@ -42,7 +44,8 @@ public sealed class RevokeVpnConfigCommandHandler( .FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken); if (inbound is not null && node is not null) - await gateway.RemoveClientAsync( + { + var removeResult = await gateway.RemoveClientAsync( node, inbound.RemoteInboundId, config.ClientExternalId, @@ -50,6 +53,20 @@ public sealed class RevokeVpnConfigCommandHandler( cancellationToken ); + if (!removeResult.IsSuccess) + { + // Нода недоступна/сбой панели — не помечаем конфиг Revoked локально, иначе БД + // разойдётся с реальным состоянием клиента в 3x-ui. Пользователь может повторить. + logger.LogWarning( + "Failed to remove client for config {ConfigId} on node {NodeId}: {Error}", + config.Id, + node.Id, + removeResult.Error + ); + return Result.Failure(ConfigErrors.NodeUnavailable); + } + } + config.Revoke(); await notifier.NotifyConfigStatusChangedAsync( userId, diff --git a/backend/src/PnvPanel.Domain/Inbounds/Inbound.cs b/backend/src/PnvPanel.Domain/Inbounds/Inbound.cs index a001d71..e08bf49 100644 --- a/backend/src/PnvPanel.Domain/Inbounds/Inbound.cs +++ b/backend/src/PnvPanel.Domain/Inbounds/Inbound.cs @@ -17,7 +17,6 @@ public sealed class Inbound : Entity public int Port { get; private set; } public bool IsPublished { get; private set; } public string? DisplayName { get; private set; } - public int? MaxClients { get; private set; } public IReadOnlyList AllowedRoleIds { get; private set; } = []; public DateTimeOffset? LastSyncAt { get; private set; } @@ -52,16 +51,11 @@ public sealed class Inbound : Entity LastSyncAt = DateTimeOffset.UtcNow; } - public void Publish( - string? displayName, - IReadOnlyCollection allowedRoleIds, - int? maxClients - ) + public void Publish(string? displayName, IReadOnlyCollection allowedRoleIds) { IsPublished = true; DisplayName = displayName; AllowedRoleIds = allowedRoleIds.Distinct().ToList(); - MaxClients = maxClients; } public void Unpublish() => IsPublished = false; diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260714200833_RemoveInboundMaxClients.Designer.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260714200833_RemoveInboundMaxClients.Designer.cs new file mode 100644 index 0000000..f3caf48 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260714200833_RemoveInboundMaxClients.Designer.cs @@ -0,0 +1,938 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PnvPanel.Infrastructure.Persistence; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260714200833_RemoveInboundMaxClients")] + partial class RemoveInboundMaxClients + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Activation.ActivationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Comment") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedBy") + .HasColumnType("uuid"); + + b.Property("RejectionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("ActivationRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Apps.ClientApp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("DownloadUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IconUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("IsRecommended") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OperatingSystem") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ClientApps", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Audit.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ActorId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Metadata") + .HasColumnType("jsonb"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("AuditLogs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Configs.TrafficSample", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConfigId") + .HasColumnType("uuid"); + + b.Property("DownBytes") + .HasColumnType("bigint"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UpBytes") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ConfigId", "Timestamp"); + + b.ToTable("TrafficSamples", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Configs.VpnConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientEmail") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClientExternalId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InboundId") + .HasColumnType("uuid"); + + b.Property("Label") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UsedDownBytes") + .HasColumnType("bigint"); + + b.Property("UsedUpBytes") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("InboundId"); + + b.HasIndex("SubscriptionToken") + .IsUnique(); + + b.HasIndex("UserId", "Status"); + + b.ToTable("VpnConfigs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Inbounds.Inbound", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.PrimitiveCollection("AllowedRoleIds") + .IsRequired() + .HasColumnType("uuid[]"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Remark") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RemoteInboundId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "RemoteInboundId") + .IsUnique(); + + b.ToTable("Inbounds", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Instructions.InstructionIntro", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("InstructionIntros", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Instructions.InstructionTab", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SortOrder"); + + b.ToTable("InstructionTabs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.News.NewsPost", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("NewsPosts", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BaseAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Location") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.ToTable("Nodes", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Support.SupportTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ProposedMaxConfigs") + .HasColumnType("integer"); + + b.Property("ProposedMaxIpLimit") + .HasColumnType("integer"); + + b.Property("ProposedRoleName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Type", "Status"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("SupportTickets", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Support.TicketAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CommentId") + .HasColumnType("uuid"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StoredFileName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CommentId"); + + b.HasIndex("StoredFileName") + .IsUnique(); + + b.ToTable("TicketAttachments", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Support.TicketComment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TicketId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TicketId", "CreatedAt"); + + b.ToTable("TicketComments", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLinkToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("TelegramLinkTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLoginRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("TelegramLoginRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("MaxConfigs") + .HasColumnType("integer"); + + b.Property("MaxIpLimit") + .HasColumnType("integer"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ActivatedBy") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActivated") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("TelegramLinkedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TelegramUserId") + .HasColumnType("bigint"); + + b.Property("TelegramUsername") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("SubscriptionToken") + .IsUnique(); + + b.HasIndex("TelegramUserId") + .IsUnique(); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b => + { + b.OwnsOne("PnvPanel.Domain.Nodes.NodeCredentials", "Credentials", b1 => + { + b1.Property("NodeId") + .HasColumnType("uuid"); + + b1.Property("ProtectedPassword") + .IsRequired() + .HasColumnType("text") + .HasColumnName("CredentialsProtectedPassword"); + + b1.Property("Username") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("CredentialsUsername"); + + b1.HasKey("NodeId"); + + b1.ToTable("Nodes"); + + b1.WithOwner() + .HasForeignKey("NodeId"); + }); + + b.Navigation("Credentials") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260714200833_RemoveInboundMaxClients.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260714200833_RemoveInboundMaxClients.cs new file mode 100644 index 0000000..5d9b582 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260714200833_RemoveInboundMaxClients.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + /// + public partial class RemoveInboundMaxClients : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "MaxClients", + table: "Inbounds"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "MaxClients", + table: "Inbounds", + type: "integer", + nullable: true); + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index 5160307..90b11f0 100644 --- a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -365,9 +365,6 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations b.Property("LastSyncAt") .HasColumnType("timestamp with time zone"); - b.Property("MaxClients") - .HasColumnType("integer"); - b.Property("NodeId") .HasColumnType("uuid"); diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Configs/ListAllConfigsQueryHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Configs/ListAllConfigsQueryHandlerTests.cs index d9c071e..8f015ff 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Configs/ListAllConfigsQueryHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Configs/ListAllConfigsQueryHandlerTests.cs @@ -26,7 +26,7 @@ public class ListAllConfigsQueryHandlerTests null ); var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443); - inbound.Publish("Germany", [], null); + inbound.Publish("Germany", []); var config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "my-config"); dbContext.Nodes.Add(node); diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Inbounds/PublishInboundCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Inbounds/PublishInboundCommandHandlerTests.cs index f67a711..eb3d502 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Inbounds/PublishInboundCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Inbounds/PublishInboundCommandHandlerTests.cs @@ -22,14 +22,13 @@ public class PublishInboundCommandHandlerTests var roleId = Guid.NewGuid(); var handler = new PublishInboundCommandHandler(dbContext, _currentUser); - var command = new PublishInboundCommand(inbound.Id, true, "EU Fast", [roleId], 100); + var command = new PublishInboundCommand(inbound.Id, true, "EU Fast", [roleId]); var result = await handler.Handle(command, CancellationToken.None); Assert.True(result.IsSuccess); Assert.True(inbound.IsPublished); Assert.Equal("EU Fast", inbound.DisplayName); - Assert.Equal(100, inbound.MaxClients); Assert.Contains(roleId, inbound.AllowedRoleIds); Assert.Equal("EU Fast", result.Value.DisplayName); } @@ -39,13 +38,13 @@ public class PublishInboundCommandHandlerTests { using var dbContext = InMemoryDbContextFactory.Create(); var inbound = Inbound.FromRemote(Guid.NewGuid(), "1", VpnProtocol.Vless, "remark", 443); - inbound.Publish("EU Fast", [Guid.NewGuid()], 100); + inbound.Publish("EU Fast", [Guid.NewGuid()]); dbContext.Inbounds.Add(inbound); await dbContext.SaveChangesAsync(CancellationToken.None); var handler = new PublishInboundCommandHandler(dbContext, _currentUser); - var command = new PublishInboundCommand(inbound.Id, false, null, [], null); + var command = new PublishInboundCommand(inbound.Id, false, null, []); var result = await handler.Handle(command, CancellationToken.None); @@ -60,7 +59,7 @@ public class PublishInboundCommandHandlerTests var handler = new PublishInboundCommandHandler(dbContext, _currentUser); - var command = new PublishInboundCommand(Guid.NewGuid(), true, "EU Fast", [], null); + var command = new PublishInboundCommand(Guid.NewGuid(), true, "EU Fast", []); var result = await handler.Handle(command, CancellationToken.None); diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Users/DeleteUserCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Users/DeleteUserCommandHandlerTests.cs index 33ccc10..5ca34e3 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Users/DeleteUserCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Users/DeleteUserCommandHandlerTests.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Logging; using NSubstitute; using PnvPanel.Application.Admin.Users; using PnvPanel.Application.Common.Interfaces; @@ -16,6 +17,9 @@ public class DeleteUserCommandHandlerTests private readonly IXuiPanelGateway _gateway = Substitute.For(); private readonly ITelegramNotifier _telegramNotifier = Substitute.For(); private readonly ICurrentUser _currentUser = Substitute.For(); + private readonly ILogger _logger = Substitute.For< + ILogger + >(); [Fact] public async Task Handle_WhenAdminTargetsSelf_ReturnsCannotDeleteSelfWithoutTouchingConfigs() @@ -29,7 +33,8 @@ public class DeleteUserCommandHandlerTests _identityService, _gateway, _telegramNotifier, - _currentUser + _currentUser, + _logger ); var result = await handler.Handle(new DeleteUserCommand(adminId), CancellationToken.None); @@ -82,7 +87,8 @@ public class DeleteUserCommandHandlerTests _identityService, _gateway, _telegramNotifier, - _currentUser + _currentUser, + _logger ); var result = await handler.Handle(new DeleteUserCommand(userId), CancellationToken.None); @@ -125,7 +131,8 @@ public class DeleteUserCommandHandlerTests _identityService, _gateway, _telegramNotifier, - _currentUser + _currentUser, + _logger ); var result = await handler.Handle(new DeleteUserCommand(userId), CancellationToken.None); @@ -157,7 +164,8 @@ public class DeleteUserCommandHandlerTests _identityService, _gateway, _telegramNotifier, - _currentUser + _currentUser, + _logger ); var result = await handler.Handle(new DeleteUserCommand(userId), CancellationToken.None); diff --git a/backend/tests/PnvPanel.Application.Tests/Configs/GetMyConfigs/GetMyConfigsQueryHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Configs/GetMyConfigs/GetMyConfigsQueryHandlerTests.cs index bfabb4a..53fc772 100644 --- a/backend/tests/PnvPanel.Application.Tests/Configs/GetMyConfigs/GetMyConfigsQueryHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Configs/GetMyConfigs/GetMyConfigsQueryHandlerTests.cs @@ -22,7 +22,7 @@ public class GetMyConfigsQueryHandlerTests var otherUserId = Guid.NewGuid(); var inbound = Inbound.FromRemote(Guid.NewGuid(), "1", VpnProtocol.Vless, "remark", 443); - inbound.Publish("My inbound", [], null); + inbound.Publish("My inbound", []); var activeConfig = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "Active"); var revokedConfig = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "Revoked"); diff --git a/backend/tests/PnvPanel.Application.Tests/Configs/Revoke/RevokeVpnConfigCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Configs/Revoke/RevokeVpnConfigCommandHandlerTests.cs index 69f9cfa..0c31ee5 100644 --- a/backend/tests/PnvPanel.Application.Tests/Configs/Revoke/RevokeVpnConfigCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Configs/Revoke/RevokeVpnConfigCommandHandlerTests.cs @@ -1,5 +1,7 @@ +using Microsoft.Extensions.Logging; using NSubstitute; using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Models; using PnvPanel.Application.Configs; using PnvPanel.Application.Configs.Revoke; using PnvPanel.Application.Tests.TestSupport; @@ -14,6 +16,9 @@ public class RevokeVpnConfigCommandHandlerTests { private readonly IXuiPanelGateway _gateway = Substitute.For(); private readonly IRealtimeNotifier _notifier = Substitute.For(); + private readonly ILogger _logger = Substitute.For< + ILogger + >(); [Fact] public async Task Handle_WhenActiveConfigOwnedByUser_RevokesRemovesRemoteClientAndNotifies() @@ -36,11 +41,22 @@ public class RevokeVpnConfigCommandHandlerTests dbContext.VpnConfigs.Add(config); await dbContext.SaveChangesAsync(CancellationToken.None); + _gateway + .RemoveClientAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any() + ) + .Returns(Result.Success()); + var handler = new RevokeVpnConfigCommandHandler( dbContext, _gateway, _notifier, - FakeCurrentUser.Authenticated(userId) + FakeCurrentUser.Authenticated(userId), + _logger ); var result = await handler.Handle( @@ -87,7 +103,8 @@ public class RevokeVpnConfigCommandHandlerTests dbContext, _gateway, _notifier, - FakeCurrentUser.Authenticated(userId) + FakeCurrentUser.Authenticated(userId), + _logger ); var result = await handler.Handle( @@ -117,7 +134,8 @@ public class RevokeVpnConfigCommandHandlerTests dbContext, _gateway, _notifier, - FakeCurrentUser.Authenticated(userId) + FakeCurrentUser.Authenticated(userId), + _logger ); var result = await handler.Handle( @@ -138,7 +156,8 @@ public class RevokeVpnConfigCommandHandlerTests dbContext, _gateway, _notifier, - FakeCurrentUser.Anonymous() + FakeCurrentUser.Anonymous(), + _logger ); var result = await handler.Handle( diff --git a/backend/tests/PnvPanel.Domain.Tests/Inbounds/InboundTests.cs b/backend/tests/PnvPanel.Domain.Tests/Inbounds/InboundTests.cs index 2dfeb19..aecbc46 100644 --- a/backend/tests/PnvPanel.Domain.Tests/Inbounds/InboundTests.cs +++ b/backend/tests/PnvPanel.Domain.Tests/Inbounds/InboundTests.cs @@ -35,16 +35,15 @@ public class InboundTests } [Fact] - public void Publish_SetsDisplayNameRolesAndMaxClients() + public void Publish_SetsDisplayNameAndRoles() { var inbound = Inbound.FromRemote(Guid.NewGuid(), "12", VpnProtocol.Vless, "Germany", 443); var roleId = Guid.NewGuid(); - inbound.Publish("Germany (VLESS)", [roleId, roleId], 100); + inbound.Publish("Germany (VLESS)", [roleId, roleId]); Assert.True(inbound.IsPublished); Assert.Equal("Germany (VLESS)", inbound.DisplayName); - Assert.Equal(100, inbound.MaxClients); Assert.Single(inbound.AllowedRoleIds); Assert.Contains(roleId, inbound.AllowedRoleIds); } @@ -54,7 +53,7 @@ public class InboundTests { var inbound = Inbound.FromRemote(Guid.NewGuid(), "12", VpnProtocol.Vless, "Germany", 443); var roleId = Guid.NewGuid(); - inbound.Publish("Germany", [roleId], null); + inbound.Publish("Germany", [roleId]); inbound.Unpublish(); diff --git a/backend/tests/PnvPanel.IntegrationTests/Admin/NodeInboundCrudTests.cs b/backend/tests/PnvPanel.IntegrationTests/Admin/NodeInboundCrudTests.cs index 0b03250..c0f5960 100644 --- a/backend/tests/PnvPanel.IntegrationTests/Admin/NodeInboundCrudTests.cs +++ b/backend/tests/PnvPanel.IntegrationTests/Admin/NodeInboundCrudTests.cs @@ -27,7 +27,6 @@ public class NodeInboundCrudTests(PnvPanelWebApplicationFactory factory) int Port, bool IsPublished, string? DisplayName, - int? MaxClients, IReadOnlyList AllowedRoleIds ); @@ -83,7 +82,6 @@ public class NodeInboundCrudTests(PnvPanelWebApplicationFactory factory) isPublished = true, displayName = "Germany (VLESS)", allowedRoleIds = Array.Empty(), - maxClients = (int?)null, } ); Assert.Equal(HttpStatusCode.OK, publishResponse.StatusCode); diff --git a/backend/tests/PnvPanel.IntegrationTests/Configs/ConfigQuotaTests.cs b/backend/tests/PnvPanel.IntegrationTests/Configs/ConfigQuotaTests.cs index 25a6397..3b75611 100644 --- a/backend/tests/PnvPanel.IntegrationTests/Configs/ConfigQuotaTests.cs +++ b/backend/tests/PnvPanel.IntegrationTests/Configs/ConfigQuotaTests.cs @@ -88,7 +88,6 @@ public class ConfigQuotaTests(PnvPanelWebApplicationFactory factory) isPublished = true, displayName = "Quota inbound", allowedRoleIds = new[] { userRole.Id }, - maxClients = (int?)null, } ); Assert.Equal(HttpStatusCode.OK, publishResponse.StatusCode); diff --git a/docker-compose.yml b/docker-compose.yml index ec4c159..dec4666 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,11 @@ services: timeout: 5s retries: 10 restart: unless-stopped + logging: + driver: json-file + options: + max-size: '10m' + max-file: '3' app: build: @@ -46,6 +51,11 @@ services: ports: - '8085:8085' restart: unless-stopped + logging: + driver: json-file + options: + max-size: '10m' + max-file: '3' volumes: pgdata: diff --git a/docs/api-design.md b/docs/api-design.md index be61846..4f83709 100644 --- a/docs/api-design.md +++ b/docs/api-design.md @@ -267,7 +267,7 @@ approve/reject над `ActivationRequest`. | Метод | Путь | Роль | Тело запроса | Тело ответа | | ----- | -------------------------------------- | ----- | ---------------------------------------------------------------------------- | ------------- | | GET | `/api/admin/inbounds` | admin | query: `nodeId?` | `InboundDto[]` | -| PUT | `/api/admin/inbounds/{id}/publish` | admin | `{ isPublished, displayName?, allowedRoleIds?, maxClients? }` | `InboundDto` | +| PUT | `/api/admin/inbounds/{id}/publish` | admin | `{ isPublished, displayName?, allowedRoleIds? }` | `InboundDto` | ## Admin — Users & Stats diff --git a/docs/domain-model.md b/docs/domain-model.md index 5e789f6..c30e8f1 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -63,12 +63,12 @@ AppUser | `IsPublished` | `bool` | Доступен ли для самообслуживания пользователями | | `AllowedRoleIds` | `Guid[]` | Id ролей, которым разрешено создавать конфиги (native PostgreSQL `uuid[]`; не навигация на `AppRole` — тот в Infrastructure/Identity, Domain на него не ссылается) | | `DisplayName` | `string?` | Витринное имя для пользователя, напр. «Германия (Trojan)» | -| `MaxClients` | `int?` | Лимит клиентов (null = без лимита) | | `LastSyncAt` | `DateTimeOffset?` | | -Инварианты: конфиг можно создать только если `IsPublished && Node.IsEnabled`, **роль пользователя -входит в `AllowedRoles`**, и при заданном `MaxClients` он не достигнут. Публикация инбаунда админом -включает выбор `AllowedRoles` (напр. «Германия (Trojan)» → роли `user`, `vip`). +Инварианты: конфиг можно создать только если `IsPublished && Node.IsEnabled && Node.Status != +Offline`, и **роль пользователя входит в `AllowedRoles`**. Публикация инбаунда админом включает +выбор `AllowedRoles` (напр. «Германия (Trojan)» → роли `user`, `vip`). Лимита числа клиентов на +инбаунд нет — квота ограничивается только на уровне пользователя (`AppRole.MaxConfigs`). > **Пользователю показываем только `DisplayName` + протокол.** Адрес/хост ноды, `RemoteInboundId`, > `Port` и прочие детали 3x-ui в пользовательские DTO не попадают (только в админские). diff --git a/frontend/src/features/admin/inbounds/PublishInboundDialog.tsx b/frontend/src/features/admin/inbounds/PublishInboundDialog.tsx index d0de2d8..c33ee07 100644 --- a/frontend/src/features/admin/inbounds/PublishInboundDialog.tsx +++ b/frontend/src/features/admin/inbounds/PublishInboundDialog.tsx @@ -24,14 +24,13 @@ export function PublishInboundDialog({ const queryClient = useQueryClient() const [isPublished, setIsPublished] = useState(inbound.isPublished) const [displayName, setDisplayName] = useState(inbound.displayName ?? inbound.remark) - const [maxClients, setMaxClients] = useState(inbound.maxClients?.toString() ?? '') const [selectedRoles, setSelectedRoles] = useState>(new Set(inbound.allowedRoleIds)) const rolesQuery = useQuery({ queryKey: ['admin-roles'], queryFn: listRoles, enabled: open }) const mutation = useMutation({ mutationFn: () => - publishInbound(inbound.id, isPublished, displayName.trim() || undefined, Array.from(selectedRoles), maxClients ? Number(maxClients) : undefined), + publishInbound(inbound.id, isPublished, displayName.trim() || undefined, Array.from(selectedRoles)), onSuccess: async () => { toast.success(t('admin.nodes.publishSaved')) await queryClient.invalidateQueries({ queryKey: ['admin-inbounds', inbound.nodeId] }) @@ -74,10 +73,6 @@ export function PublishInboundDialog({ setDisplayName(e.target.value)} /> -
- - setMaxClients(e.target.value)} /> -
diff --git a/frontend/src/features/admin/inbounds/api.ts b/frontend/src/features/admin/inbounds/api.ts index c9bbd3e..267924d 100644 --- a/frontend/src/features/admin/inbounds/api.ts +++ b/frontend/src/features/admin/inbounds/api.ts @@ -10,10 +10,9 @@ export function publishInbound( isPublished: boolean, displayName: string | undefined, allowedRoleIds: string[], - maxClients: number | undefined, ) { return apiRequest(`/admin/inbounds/${id}/publish`, { method: 'PUT', - body: { isPublished, displayName: displayName ?? null, allowedRoleIds, maxClients: maxClients ?? null }, + body: { isPublished, displayName: displayName ?? null, allowedRoleIds }, }) } diff --git a/frontend/src/shared/api/schema.gen.ts b/frontend/src/shared/api/schema.gen.ts index 01d9a24..f2d1f99 100644 --- a/frontend/src/shared/api/schema.gen.ts +++ b/frontend/src/shared/api/schema.gen.ts @@ -1885,8 +1885,6 @@ export interface components { port: number | string; isPublished: boolean; displayName: null | string; - /** Format: int32 */ - maxClients: null | number | string; allowedRoleIds: string[]; /** Format: date-time */ lastSyncAt: null | string; @@ -1955,8 +1953,6 @@ export interface components { isPublished: boolean; displayName: null | string; allowedRoleIds: null | string[]; - /** Format: int32 */ - maxClients: null | number | string; }; RegisterCommand: { userName: string; diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts index 1e53250..dea6f32 100644 --- a/frontend/src/shared/api/types.ts +++ b/frontend/src/shared/api/types.ts @@ -215,7 +215,6 @@ export type InboundDto = { port: number isPublished: boolean displayName: string | null - maxClients: number | null allowedRoleIds: string[] lastSyncAt: string | null } diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index fc7df78..f1d921e 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -294,7 +294,6 @@ const resources = { unpublished: 'Не опубликован', publishSaved: 'Настройки публикации сохранены.', displayName: 'Отображаемое имя', - maxClients: 'Лимит клиентов (необязательно)', allowedRoles: 'Доступно ролям', isPublishedLabel: 'Опубликовать инбаунд', optional: 'необязательно', @@ -712,7 +711,6 @@ const resources = { unpublished: 'Not published', publishSaved: 'Publishing settings saved.', displayName: 'Display name', - maxClients: 'Client limit (optional)', allowedRoles: 'Allowed for roles', isPublishedLabel: 'Publish inbound', optional: 'optional',