Enhance documentation with new features: added dark/light/system theme support, instructions page, and application catalog. Updated API and domain model for app management and automatic migrations on startup. Improved frontend structure with new routes and features for user instructions and app management.

This commit is contained in:
Leonid Pershin
2026-07-01 22:38:01 +03:00
parent d8930409fe
commit 1a8d33efa3
229 changed files with 9226 additions and 20 deletions
@@ -0,0 +1,24 @@
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Configs;
public static class ConfigErrors
{
public static readonly Error NotActivated =
Error.Forbidden("Configs.NotActivated", "Аккаунт не активирован — обратитесь к администратору.");
public static readonly Error InboundNotAvailable =
Error.NotFound("Configs.InboundNotAvailable", "Инбаунд недоступен.");
public static readonly Error InboundNotAllowedForRole =
Error.Forbidden("Configs.InboundNotAllowedForRole", "Ваша роль не даёт доступ к этому инбаунду.");
public static readonly Error NodeDisabled =
Error.Forbidden("Configs.NodeDisabled", "Сервер временно недоступен для новых конфигов.");
public static readonly Error QuotaExceeded =
Error.Conflict("Configs.QuotaExceeded", "Достигнут лимит конфигов для вашей роли.");
public static readonly Error NotFound =
Error.NotFound("Configs.NotFound", "Конфиг не найден.");
}
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Configs.Create;
public sealed record CreateVpnConfigCommand(Guid InboundId, string? Label, int? DeviceLimit) : ICommand<Result<VpnConfigDto>>;
@@ -0,0 +1,92 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Configs;
namespace PnvPanel.Application.Configs.Create;
public sealed class CreateVpnConfigCommandHandler(
IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway, ICurrentUser currentUser)
: ICommandHandler<CreateVpnConfigCommand, Result<VpnConfigDto>>
{
public async Task<Result<VpnConfigDto>> Handle(CreateVpnConfigCommand command, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return Result.Failure<VpnConfigDto>(AuthErrors.Unauthorized);
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
if (profile is null)
return Result.Failure<VpnConfigDto>(AuthErrors.Unauthorized);
if (!profile.IsActivated)
return Result.Failure<VpnConfigDto>(ConfigErrors.NotActivated);
var inbound = await dbContext.Inbounds.AsNoTracking()
.FirstOrDefaultAsync(i => i.Id == command.InboundId, cancellationToken);
if (inbound is null || !inbound.IsPublished)
return Result.Failure<VpnConfigDto>(ConfigErrors.InboundNotAvailable);
if (!inbound.AllowedRoleIds.Contains(profile.RoleId))
return Result.Failure<VpnConfigDto>(ConfigErrors.InboundNotAllowedForRole);
var node = await dbContext.Nodes.AsNoTracking().FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
if (node is null || !node.IsEnabled)
return Result.Failure<VpnConfigDto>(ConfigErrors.NodeDisabled);
var config = VpnConfig.Create(userId, inbound.Id, inbound.Protocol, command.Label, command.DeviceLimit ?? 0);
var reserveResult = await ReserveQuotaSlotAsync(userId, profile.MaxConfigs, config, cancellationToken);
if (!reserveResult.IsSuccess)
return Result.Failure<VpnConfigDto>(reserveResult.Error);
var addResult = await gateway.AddClientAsync(
node, inbound.RemoteInboundId, inbound.Protocol, config.ClientEmail,
config.Label ?? config.ClientEmail, config.DeviceLimit, cancellationToken);
if (!addResult.IsSuccess)
{
// Компенсация: квота была зарезервирована локально, но клиент в 3x-ui не создался —
// откатываем резервирование, наружу не оставляем "мёртвую" запись.
dbContext.VpnConfigs.Remove(config);
await dbContext.SaveChangesAsync(cancellationToken);
return Result.Failure<VpnConfigDto>(addResult.Error);
}
config.AssignRemoteClient(addResult.Value);
await dbContext.SaveChangesAsync(cancellationToken);
return Result.Success(VpnConfigDto.FromDomain(config, inbound));
}
/// <summary>
/// Проверка квоты + резервирование строки — под pg_advisory_xact_lock (гонки параллельных
/// созданий, см. CLAUDE.md). Лок держится только на время короткой транзакции count+insert,
/// НЕ на время внешнего HTTP-вызова к 3x-ui — иначе рискуем держать соединение к БД открытым
/// на секунды под внешним I/O.
/// </summary>
private async Task<Result> ReserveQuotaSlotAsync(Guid userId, int maxConfigs, VpnConfig config, CancellationToken cancellationToken)
{
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
await dbContext.Database.ExecuteSqlInterpolatedAsync(
$"SELECT pg_advisory_xact_lock(hashtext({userId.ToString()}))", cancellationToken);
var activeCount = await dbContext.VpnConfigs
.CountAsync(c => c.UserId == userId && c.Status == ConfigStatus.Active, cancellationToken);
if (maxConfigs != RoleQuota.Unlimited && activeCount >= maxConfigs)
{
await transaction.RollbackAsync(cancellationToken);
return Result.Failure(ConfigErrors.QuotaExceeded);
}
dbContext.VpnConfigs.Add(config);
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return Result.Success();
}
}
@@ -0,0 +1,13 @@
using FluentValidation;
namespace PnvPanel.Application.Configs.Create;
public sealed class CreateVpnConfigCommandValidator : AbstractValidator<CreateVpnConfigCommand>
{
public CreateVpnConfigCommandValidator()
{
RuleFor(x => x.InboundId).NotEmpty();
RuleFor(x => x.Label).MaximumLength(100);
RuleFor(x => x.DeviceLimit).GreaterThanOrEqualTo(0).When(x => x.DeviceLimit.HasValue);
}
}
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Configs.Edit;
public sealed record EditVpnConfigCommand(Guid ConfigId, string? Label, int? DeviceLimit) : ICommand<Result<VpnConfigDto>>;
@@ -0,0 +1,45 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Configs.Edit;
public sealed class EditVpnConfigCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway, ICurrentUser currentUser)
: ICommandHandler<EditVpnConfigCommand, Result<VpnConfigDto>>
{
public async Task<Result<VpnConfigDto>> Handle(EditVpnConfigCommand command, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return Result.Failure<VpnConfigDto>(AuthErrors.Unauthorized);
var config = await dbContext.VpnConfigs
.FirstOrDefaultAsync(c => c.Id == command.ConfigId && c.UserId == userId, cancellationToken);
if (config is null)
return Result.Failure<VpnConfigDto>(ConfigErrors.NotFound);
var inbound = await dbContext.Inbounds.AsNoTracking()
.FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
if (inbound is null)
return Result.Failure<VpnConfigDto>(ConfigErrors.InboundNotAvailable);
if (command.Label is not null)
config.Rename(command.Label);
if (command.DeviceLimit is { } deviceLimit)
{
config.SetDeviceLimit(deviceLimit);
var node = await dbContext.Nodes.AsNoTracking().FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
if (node is not null)
{
await gateway.UpdateClientAsync(
node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol,
config.Label ?? config.ClientEmail, deviceLimit, enable: true, cancellationToken);
}
}
return Result.Success(VpnConfigDto.FromDomain(config, inbound));
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace PnvPanel.Application.Configs.Edit;
public sealed class EditVpnConfigCommandValidator : AbstractValidator<EditVpnConfigCommand>
{
public EditVpnConfigCommandValidator()
{
RuleFor(x => x.Label).MaximumLength(100);
RuleFor(x => x.DeviceLimit).GreaterThanOrEqualTo(0).When(x => x.DeviceLimit.HasValue);
}
}
@@ -0,0 +1,9 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Configs.GetConfigLink;
public sealed record GetConfigLinkQuery(Guid ConfigId) : IQuery<Result<ConfigLinkDto>>;
/// <summary>SubscriptionToken — Api-слой строит из него абсолютный URL (знает scheme/host запроса).</summary>
public sealed record ConfigLinkDto(string ConnectionString, string SubscriptionToken);
@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Configs.GetConfigLink;
public sealed class GetConfigLinkQueryHandler(IAppDbContext dbContext, IXuiPanelGateway gateway, ICurrentUser currentUser)
: IQueryHandler<GetConfigLinkQuery, Result<ConfigLinkDto>>
{
public async Task<Result<ConfigLinkDto>> Handle(GetConfigLinkQuery query, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return Result.Failure<ConfigLinkDto>(AuthErrors.Unauthorized);
var config = await dbContext.VpnConfigs.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == query.ConfigId && c.UserId == userId, cancellationToken);
if (config is null)
return Result.Failure<ConfigLinkDto>(ConfigErrors.NotFound);
var inbound = await dbContext.Inbounds.AsNoTracking().FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
if (inbound is null)
return Result.Failure<ConfigLinkDto>(ConfigErrors.InboundNotAvailable);
var node = await dbContext.Nodes.AsNoTracking().FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
if (node is null)
return Result.Failure<ConfigLinkDto>(ConfigErrors.NodeDisabled);
var linkResult = await gateway.BuildConnectionStringAsync(
node, inbound, config.ClientExternalId, config.Label ?? config.ClientEmail, node.BaseAddress.Host, cancellationToken);
if (!linkResult.IsSuccess)
return Result.Failure<ConfigLinkDto>(linkResult.Error);
return Result.Success(new ConfigLinkDto(linkResult.Value, config.SubscriptionToken));
}
}
@@ -0,0 +1,8 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Configs.GetMyConfigs;
public sealed record GetMyConfigsQuery : IQuery<Result<GetMyConfigsResult>>;
public sealed record GetMyConfigsResult(IReadOnlyList<VpnConfigDto> Configs, int MaxConfigs);
@@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Configs;
namespace PnvPanel.Application.Configs.GetMyConfigs;
public sealed class GetMyConfigsQueryHandler(IAppDbContext dbContext, IIdentityService identityService, ICurrentUser currentUser)
: IQueryHandler<GetMyConfigsQuery, Result<GetMyConfigsResult>>
{
public async Task<Result<GetMyConfigsResult>> Handle(GetMyConfigsQuery query, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return Result.Failure<GetMyConfigsResult>(AuthErrors.Unauthorized);
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
if (profile is null)
return Result.Failure<GetMyConfigsResult>(AuthErrors.Unauthorized);
var rows = await dbContext.VpnConfigs.AsNoTracking()
.Where(c => c.UserId == userId && c.Status != ConfigStatus.Revoked)
.Join(dbContext.Inbounds.AsNoTracking(), c => c.InboundId, i => i.Id, (c, i) => new { Config = c, Inbound = i })
.OrderByDescending(x => x.Config.CreatedAt)
.ToListAsync(cancellationToken);
var dtos = rows.Select(x => VpnConfigDto.FromDomain(x.Config, x.Inbound)).ToList();
return Result.Success(new GetMyConfigsResult(dtos, profile.MaxConfigs));
}
}
@@ -0,0 +1,10 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Inbounds;
namespace PnvPanel.Application.Configs.ListAvailableInbounds;
public sealed record ListAvailableInboundsQuery : IQuery<Result<IReadOnlyList<AvailableInboundDto>>>;
/// <summary>Витринная карточка инбаунда для выбора при создании конфига — без деталей 3x-ui.</summary>
public sealed record AvailableInboundDto(Guid InboundId, string DisplayName, VpnProtocol Protocol);
@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Configs.ListAvailableInbounds;
public sealed class ListAvailableInboundsQueryHandler(IAppDbContext dbContext, IIdentityService identityService, ICurrentUser currentUser)
: IQueryHandler<ListAvailableInboundsQuery, Result<IReadOnlyList<AvailableInboundDto>>>
{
public async Task<Result<IReadOnlyList<AvailableInboundDto>>> Handle(ListAvailableInboundsQuery query, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return Result.Failure<IReadOnlyList<AvailableInboundDto>>(AuthErrors.Unauthorized);
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
if (profile is null)
return Result.Failure<IReadOnlyList<AvailableInboundDto>>(AuthErrors.Unauthorized);
var enabledNodeIds = dbContext.Nodes.AsNoTracking().Where(n => n.IsEnabled).Select(n => n.Id);
var inbounds = await dbContext.Inbounds.AsNoTracking()
.Where(i => i.IsPublished && enabledNodeIds.Contains(i.NodeId) && i.AllowedRoleIds.Contains(profile.RoleId))
.OrderBy(i => i.DisplayName ?? i.Remark)
.Select(i => new AvailableInboundDto(i.Id, i.DisplayName ?? i.Remark, i.Protocol))
.ToListAsync(cancellationToken);
return Result.Success<IReadOnlyList<AvailableInboundDto>>(inbounds);
}
}
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Configs.Revoke;
public sealed record RevokeVpnConfigCommand(Guid ConfigId) : ICommand<Result>;
@@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Configs;
namespace PnvPanel.Application.Configs.Revoke;
public sealed class RevokeVpnConfigCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway, ICurrentUser currentUser)
: ICommandHandler<RevokeVpnConfigCommand, Result>
{
public async Task<Result> Handle(RevokeVpnConfigCommand command, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return Result.Failure(AuthErrors.Unauthorized);
var config = await dbContext.VpnConfigs
.FirstOrDefaultAsync(c => c.Id == command.ConfigId && c.UserId == userId, cancellationToken);
if (config is null)
return Result.Failure(ConfigErrors.NotFound);
if (config.Status == ConfigStatus.Revoked)
return Result.Success();
var inbound = await dbContext.Inbounds.AsNoTracking().FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
var node = inbound is null
? null
: await dbContext.Nodes.AsNoTracking().FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
if (inbound is not null && node is not null)
await gateway.RemoveClientAsync(node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, cancellationToken);
config.Revoke();
return Result.Success();
}
}
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Configs.Rotate;
public sealed record RotateVpnConfigCommand(Guid ConfigId) : ICommand<Result<VpnConfigDto>>;
@@ -0,0 +1,53 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Configs;
namespace PnvPanel.Application.Configs.Rotate;
public sealed class RotateVpnConfigCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway, ICurrentUser currentUser)
: ICommandHandler<RotateVpnConfigCommand, Result<VpnConfigDto>>
{
public async Task<Result<VpnConfigDto>> Handle(RotateVpnConfigCommand command, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return Result.Failure<VpnConfigDto>(AuthErrors.Unauthorized);
var config = await dbContext.VpnConfigs
.FirstOrDefaultAsync(c => c.Id == command.ConfigId && c.UserId == userId, cancellationToken);
if (config is null)
return Result.Failure<VpnConfigDto>(ConfigErrors.NotFound);
if (config.Status != ConfigStatus.Active)
return Result.Failure<VpnConfigDto>(ConfigErrors.NotFound);
var inbound = await dbContext.Inbounds.AsNoTracking()
.FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken);
if (inbound is null)
return Result.Failure<VpnConfigDto>(ConfigErrors.InboundNotAvailable);
var node = await dbContext.Nodes.AsNoTracking().FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken);
if (node is null)
return Result.Failure<VpnConfigDto>(ConfigErrors.NodeDisabled);
var newClientEmail = VpnConfig.GenerateClientEmail(userId);
var addResult = await gateway.AddClientAsync(
node, inbound.RemoteInboundId, config.Protocol, newClientEmail,
config.Label ?? newClientEmail, config.DeviceLimit, cancellationToken);
if (!addResult.IsSuccess)
return Result.Failure<VpnConfigDto>(addResult.Error);
var oldClientExternalId = config.ClientExternalId;
config.Rotate(newClientEmail, addResult.Value);
await dbContext.SaveChangesAsync(cancellationToken);
// Старого клиента удаляем ПОСЛЕ коммита нового состояния: если удаление не выйдет,
// у пользователя просто останется лишний нерабочий-для-него клиент в панели — не критично.
await gateway.RemoveClientAsync(node, inbound.RemoteInboundId, oldClientExternalId, config.Protocol, cancellationToken);
return Result.Success(VpnConfigDto.FromDomain(config, inbound));
}
}
@@ -0,0 +1,17 @@
using PnvPanel.Domain.Configs;
using PnvPanel.Domain.Inbounds;
namespace PnvPanel.Application.Configs;
/// <summary>
/// Пользователю показываем только DisplayName + протокол инбаунда — адрес/хост ноды и прочие
/// детали 3x-ui в этот DTO не попадают (см. domain-model.md).
/// </summary>
public sealed record VpnConfigDto(
Guid Id, string? Label, VpnProtocol Protocol, string Location, int DeviceLimit,
long UsedUpBytes, long UsedDownBytes, DateTimeOffset? ExpiresAt, ConfigStatus Status, DateTimeOffset CreatedAt)
{
public static VpnConfigDto FromDomain(VpnConfig config, Inbound inbound) => new(
config.Id, config.Label, config.Protocol, inbound.DisplayName ?? inbound.Remark, config.DeviceLimit,
config.UsedUpBytes, config.UsedDownBytes, config.ExpiresAt, config.Status, config.CreatedAt);
}