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,20 @@
using Microsoft.Extensions.Logging;
using PnvPanel.Application.Common.Messaging;
namespace PnvPanel.Application.Common.Behaviors;
public sealed class LoggingBehavior<TRequest, TResponse>(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
var requestName = typeof(TRequest).Name;
logger.LogInformation("Обработка {RequestName}", requestName);
var response = await next();
logger.LogInformation("Обработан {RequestName}", requestName);
return response;
}
}
@@ -0,0 +1,21 @@
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
namespace PnvPanel.Application.Common.Behaviors;
/// <summary>
/// Коммитит изменения после успешного выполнения команды. Применяется автоматически только
/// к запросам, реализующим <see cref="ICommand{TResponse}"/> — благодаря generic-ограничению
/// DI-контейнер не сможет сконструировать это поведение для запросов (IQuery).
/// </summary>
public sealed class UnitOfWorkBehavior<TRequest, TResponse>(IAppDbContext dbContext)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : ICommand<TResponse>
{
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
var response = await next();
await dbContext.SaveChangesAsync(cancellationToken);
return response;
}
}
@@ -0,0 +1,45 @@
using FluentValidation;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Common.Behaviors;
public sealed class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
where TResponse : Result
{
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
if (!validators.Any())
return await next();
var context = new ValidationContext<TRequest>(request);
var failures = validators
.Select(v => v.Validate(context))
.SelectMany(r => r.Errors)
.ToList();
if (failures.Count == 0)
return await next();
var error = Error.Validation(
"Validation.Failed",
string.Join("; ", failures.Select(f => f.ErrorMessage)));
return CreateFailure(error);
}
private static TResponse CreateFailure(Error error)
{
if (typeof(TResponse) == typeof(Result))
return (TResponse)(object)Result.Failure(error);
var valueType = typeof(TResponse).GetGenericArguments()[0];
var method = typeof(Result)
.GetMethod(nameof(Result.Failure), 1, [typeof(Error)])!
.MakeGenericMethod(valueType);
return (TResponse)method.Invoke(null, [error])!;
}
}
@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using PnvPanel.Domain.Activation;
using PnvPanel.Domain.Apps;
using PnvPanel.Domain.Configs;
using PnvPanel.Domain.Inbounds;
using PnvPanel.Domain.Nodes;
namespace PnvPanel.Application.Common.Interfaces;
public interface IAppDbContext
{
DbSet<ActivationRequest> ActivationRequests { get; }
DbSet<Node> Nodes { get; }
DbSet<Inbound> Inbounds { get; }
DbSet<VpnConfig> VpnConfigs { get; }
DbSet<ClientApp> ClientApps { get; }
/// <summary>Нужен для advisory-lock при проверке квоты конфигов (см. CreateVpnConfigCommandHandler).</summary>
DatabaseFacade Database { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
}
@@ -0,0 +1,8 @@
namespace PnvPanel.Application.Common.Interfaces;
public interface ICurrentUser
{
Guid? UserId { get; }
string? UserName { get; }
bool IsAuthenticated { get; }
}
@@ -0,0 +1,33 @@
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Common.Interfaces;
public sealed record AuthenticatedUser(Guid Id, string UserName, string Role);
public sealed record CurrentUserProfile(Guid Id, string UserName, Guid RoleId, string Role, bool IsActivated, int MaxConfigs);
public interface IIdentityService
{
Task<Result<Guid>> CreateUserAsync(string userName, string password, CancellationToken cancellationToken);
Task<Result<AuthenticatedUser>> ValidateCredentialsAsync(string userName, string password, CancellationToken cancellationToken);
Task<CurrentUserProfile?> GetProfileAsync(Guid userId, CancellationToken cancellationToken);
Task<Result> ChangePasswordAsync(Guid userId, string currentPassword, string newPassword, CancellationToken cancellationToken);
/// <summary>
/// Помечает пользователя активированным. Изменение не коммитится немедленно (в отличие от
/// CreateUserAsync/ChangePasswordAsync) — оно попадает в трекер того же DbContext и сохраняется
/// вместе с изменением ActivationRequest одной транзакцией через UnitOfWorkBehavior.
/// </summary>
Task<Result> ActivateUserAsync(Guid userId, Guid activatedBy, CancellationToken cancellationToken);
Task<IReadOnlyDictionary<Guid, string>> GetUserNamesAsync(IReadOnlyCollection<Guid> userIds, CancellationToken cancellationToken);
/// <summary>Удаляет аккаунт (самоудаление). Конфиги должны быть отозваны заранее вызывающей стороной.</summary>
Task<Result> DeleteUserAsync(Guid userId, CancellationToken cancellationToken);
/// <summary>Для агрегированной подписки /sub/{userToken} (все активные конфиги пользователя).</summary>
Task<Guid?> FindUserIdBySubscriptionTokenAsync(string token, CancellationToken cancellationToken);
}
@@ -0,0 +1,6 @@
namespace PnvPanel.Application.Common.Interfaces;
public interface IJwtTokenService
{
(string AccessToken, DateTimeOffset ExpiresAt) GenerateAccessToken(AuthenticatedUser user);
}
@@ -0,0 +1,16 @@
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Common.Interfaces;
public sealed record IssuedRefreshToken(string RawToken, DateTimeOffset ExpiresAt);
public sealed record RotatedRefreshToken(Guid UserId, string RawToken, DateTimeOffset ExpiresAt);
public interface IRefreshTokenService
{
Task<IssuedRefreshToken> IssueAsync(Guid userId, CancellationToken cancellationToken);
Task<Result<RotatedRefreshToken>> RotateAsync(string rawToken, CancellationToken cancellationToken);
Task RevokeAsync(string rawToken, CancellationToken cancellationToken);
}
@@ -0,0 +1,18 @@
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Common.Interfaces;
public sealed record RoleDto(Guid Id, string Name, int MaxConfigs, bool IsSystem);
public interface IRoleService
{
Task<Result<RoleDto>> CreateRoleAsync(string name, int maxConfigs, CancellationToken cancellationToken);
Task<Result<RoleDto>> UpdateRoleAsync(Guid roleId, int maxConfigs, CancellationToken cancellationToken);
Task<Result> DeleteRoleAsync(Guid roleId, CancellationToken cancellationToken);
Task<IReadOnlyList<RoleDto>> ListRolesAsync(CancellationToken cancellationToken);
Task<Result> ChangeUserRoleAsync(Guid userId, Guid roleId, CancellationToken cancellationToken);
}
@@ -0,0 +1,9 @@
namespace PnvPanel.Application.Common.Interfaces;
/// <summary>Шифрование секретов at-rest (пароли нод). Реализация — ASP.NET Core Data Protection.</summary>
public interface ISecretProtector
{
string Protect(string plaintext);
string Unprotect(string protectedValue);
}
@@ -0,0 +1,41 @@
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Inbounds;
using PnvPanel.Domain.Nodes;
namespace PnvPanel.Application.Common.Interfaces;
public sealed record RemoteInboundInfo(string RemoteInboundId, VpnProtocol Protocol, string Remark, int Port);
public sealed record NodeProbeResult(bool IsReachable, string? ErrorMessage);
/// <summary>
/// Оркестрация панелей 3x-ui через ThreeXui.Net. Один BaseAddress в библиотеке, но нод много —
/// реализация держит клиента per-node (кэш по NodeId), см. XuiPanelGateway.
/// </summary>
public interface IXuiPanelGateway
{
Result ValidateBaseAddress(Uri baseAddress);
Task<NodeProbeResult> ProbeAsync(Node node, CancellationToken cancellationToken);
Task<Result<IReadOnlyList<RemoteInboundInfo>>> ListInboundsAsync(Node node, CancellationToken cancellationToken);
void InvalidateClient(Guid nodeId);
/// <summary>Возвращает ClientExternalId, присвоенный панелью (UUID для VLESS/VMess, пароль для Trojan/Shadowsocks).</summary>
Task<Result<string>> AddClientAsync(
Node node, string inboundRemoteId, VpnProtocol protocol, string clientEmail, string clientName,
int deviceLimit, CancellationToken cancellationToken);
Task<Result> RemoveClientAsync(
Node node, string inboundRemoteId, string clientExternalId, VpnProtocol protocol,
CancellationToken cancellationToken);
Task<Result> UpdateClientAsync(
Node node, string inboundRemoteId, string clientExternalId, VpnProtocol protocol,
string name, int deviceLimit, bool enable, CancellationToken cancellationToken);
Task<Result<string>> BuildConnectionStringAsync(
Node node, Inbound inbound, string clientExternalId, string clientName, string publicHost,
CancellationToken cancellationToken);
}
@@ -0,0 +1,4 @@
namespace PnvPanel.Application.Common.Messaging;
/// <summary>Маркер команды CQRS. Команды меняют состояние и идут в транзакции (см. UnitOfWorkBehavior).</summary>
public interface ICommand<TResponse>;
@@ -0,0 +1,6 @@
namespace PnvPanel.Application.Common.Messaging;
public interface ICommandHandler<in TCommand, TResponse> where TCommand : ICommand<TResponse>
{
Task<TResponse> Handle(TCommand command, CancellationToken cancellationToken);
}
@@ -0,0 +1,8 @@
namespace PnvPanel.Application.Common.Messaging;
public delegate Task<TResponse> RequestHandlerDelegate<TResponse>();
public interface IPipelineBehavior<TRequest, TResponse>
{
Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken);
}
@@ -0,0 +1,4 @@
namespace PnvPanel.Application.Common.Messaging;
/// <summary>Маркер запроса CQRS. Запросы только читают, без побочных эффектов.</summary>
public interface IQuery<TResponse>;
@@ -0,0 +1,6 @@
namespace PnvPanel.Application.Common.Messaging;
public interface IQueryHandler<in TQuery, TResponse> where TQuery : IQuery<TResponse>
{
Task<TResponse> Handle(TQuery query, CancellationToken cancellationToken);
}
@@ -0,0 +1,8 @@
namespace PnvPanel.Application.Common.Messaging;
/// <summary>Собственный тонкий CQRS-диспетчер (без MediatR).</summary>
public interface ISender
{
Task<TResponse> Send<TResponse>(ICommand<TResponse> command, CancellationToken cancellationToken = default);
Task<TResponse> Send<TResponse>(IQuery<TResponse> query, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,32 @@
using Microsoft.Extensions.DependencyInjection;
namespace PnvPanel.Application.Common.Messaging;
internal sealed class Sender(IServiceProvider serviceProvider) : ISender
{
public Task<TResponse> Send<TResponse>(ICommand<TResponse> command, CancellationToken cancellationToken = default)
=> Dispatch<TResponse>(command, typeof(ICommandHandler<,>), cancellationToken);
public Task<TResponse> Send<TResponse>(IQuery<TResponse> query, CancellationToken cancellationToken = default)
=> Dispatch<TResponse>(query, typeof(IQueryHandler<,>), cancellationToken);
private Task<TResponse> Dispatch<TResponse>(object request, Type handlerOpenType, CancellationToken cancellationToken)
{
var requestType = request.GetType();
var handlerType = handlerOpenType.MakeGenericType(requestType, typeof(TResponse));
var behaviorType = typeof(IPipelineBehavior<,>).MakeGenericType(requestType, typeof(TResponse));
dynamic handler = serviceProvider.GetRequiredService(handlerType);
var behaviors = ((IEnumerable<object>)serviceProvider.GetServices(behaviorType)).Reverse();
RequestHandlerDelegate<TResponse> pipeline = () => handler.Handle((dynamic)request, cancellationToken);
foreach (dynamic behavior in behaviors)
{
var next = pipeline;
pipeline = () => behavior.Handle((dynamic)request, next, cancellationToken);
}
return pipeline();
}
}
@@ -0,0 +1,23 @@
namespace PnvPanel.Application.Common.Models;
public enum ErrorType
{
Failure,
Validation,
NotFound,
Conflict,
Unauthorized,
Forbidden,
}
public sealed record Error(string Code, string Message, ErrorType Type = ErrorType.Failure)
{
public static readonly Error None = new(string.Empty, string.Empty);
public static Error Validation(string code, string message) => new(code, message, ErrorType.Validation);
public static Error NotFound(string code, string message) => new(code, message, ErrorType.NotFound);
public static Error Conflict(string code, string message) => new(code, message, ErrorType.Conflict);
public static Error Unauthorized(string code, string message) => new(code, message, ErrorType.Unauthorized);
public static Error Forbidden(string code, string message) => new(code, message, ErrorType.Forbidden);
public static Error Failure(string code, string message) => new(code, message, ErrorType.Failure);
}
@@ -0,0 +1,3 @@
namespace PnvPanel.Application.Common.Models;
public sealed record PagedList<T>(IReadOnlyList<T> Items, int Total, int Page, int PageSize);
@@ -0,0 +1,14 @@
using Microsoft.EntityFrameworkCore;
namespace PnvPanel.Application.Common.Models;
public static class PagedListExtensions
{
public static async Task<PagedList<T>> ToPagedListAsync<T>(
this IQueryable<T> query, int page, int pageSize, CancellationToken cancellationToken)
{
var total = await query.CountAsync(cancellationToken);
var items = await query.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync(cancellationToken);
return new PagedList<T>(items, total, page, pageSize);
}
}
@@ -0,0 +1,37 @@
namespace PnvPanel.Application.Common.Models;
public class Result
{
public bool IsSuccess { get; }
public Error Error { get; }
protected Result(bool isSuccess, Error error)
{
if (isSuccess && error != Error.None)
throw new InvalidOperationException("Успешный результат не может содержать ошибку.");
if (!isSuccess && error == Error.None)
throw new InvalidOperationException("Неуспешный результат обязан содержать ошибку.");
IsSuccess = isSuccess;
Error = error;
}
public static Result Success() => new(true, Error.None);
public static Result Failure(Error error) => new(false, error);
public static Result<T> Success<T>(T value) => new(value, true, Error.None);
public static Result<T> Failure<T>(Error error) => new(default, false, error);
}
public class Result<T> : Result
{
private readonly T? _value;
internal Result(T? value, bool isSuccess, Error error) : base(isSuccess, error) => _value = value;
public T Value => IsSuccess
? _value!
: throw new InvalidOperationException("Нельзя получить значение неуспешного результата.");
public static implicit operator Result<T>(T value) => Success(value);
}
@@ -0,0 +1,6 @@
namespace PnvPanel.Application.Common.Models;
public static class RoleQuota
{
public const int Unlimited = -1;
}