diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8654c63 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +# Backend build artifacts +**/bin/ +**/obj/ + +# Frontend +**/node_modules/ +frontend/dist/ + +# VCS / IDE / env +.git/ +.vs/ +.vscode/ +.idea/ +**/.env +**/.env.* +!**/.env.example + +# Docs & misc (не нужны в образе) +docs/ +*.md diff --git a/CLAUDE.md b/CLAUDE.md index d595c9e..14e1b58 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,11 +88,15 @@ - **Конфиг**: пользователь задаёт метку (`Label`) и лимит устройств (`DeviceLimit` → `limitIp` в 3x-ui, 0=без лимита), может редактировать. - **Самоудаление аккаунта** (`DELETE /api/auth/me`): отзыв всех конфигов + удаление данных, аудит анонимизируется. - **API без версионирования** в MVP (`/api` без `v1`). Подписка отдаёт `Subscription-Userinfo`. +- **Тема**: светлая/тёмная/системная (Tailwind `dark`, выбор в localStorage). +- **Инструкции + приложения**: отдельная страница инструкций; каталог `ClientApp` (админ CRUD: + название/ссылка/ОС/порядок/вкл), пользователю `GET /api/apps` отдаётся сгруппированным по ОС. - **Вход — по `UserName`** (email в системе не используется вовсе; SMTP не нужен). Восстановление пароля: через привязанный Telegram (self-service), без привязки — сброс админом (`ResetUserPasswordCommand`). Пока Telegram не привязан — UI настойчиво предлагает его привязать. - **Сидинг из env**: идемпотентный `DbInitializer` на старте создаёт системные роли и учётку админа - (username/пароль/Telegram id) из переменных окружения. Единый источник примера — [`.env.example`](.env.example); + (username/пароль/Telegram id) из переменных окружения; каталог приложений `ClientApp` (если пуст) — + из [`seed/client-apps.json`](seed/client-apps.json). Единый источник примера env — [`.env.example`](.env.example); при добавлении новой настройки обновляй и его. Секреты (пароль админа, JWT-ключ, BotToken) — только через env/secret-store. - Telegram id админов (`AdminSeed__TelegramUserIds`) авторизуют админ-действия в боте и получают уведомления о запросах активации. @@ -116,6 +120,9 @@ - Multi-stage Dockerfile: node (сборка фронта) → dotnet sdk (publish + копирование в `wwwroot`) → aspnet runtime. - docker-compose: `app` (единый образ) + `db` (PostgreSQL). В dev — Vite-прокси `/api`,`/hubs` на бэк. - Не вводи отдельный nginx-контейнер для статики без явной просьбы — это ломает требование единого контейнера. +- **TLS — внешний** (прокси/шлюз вне compose); `app` отдаёт HTTP + доверяет `X-Forwarded-*` через + `ForwardedHeaders` (иначе Secure-cookie/схема за прокси сломаются). Свой nginx/Caddy не добавляй. +- **Миграции** применяются авто на старте (MVP). **CI** (GitHub Actions) — только build/test, без деплоя. ## Соглашения по коду diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..18c2c0c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# syntax=docker/dockerfile:1 + +# ── Stage 1: сборка фронтенда (Vite → dist) ─────────────────────────────── +FROM node:22-alpine AS frontend +WORKDIR /app/frontend +RUN corepack enable +COPY frontend/package.json frontend/pnpm-lock.yaml ./ +RUN corepack prepare pnpm@11.9.0 --activate && pnpm install --frozen-lockfile +COPY frontend/ ./ +RUN pnpm build + +# ── Stage 2: publish бэкенда, статика фронта в wwwroot ──────────────────── +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS backend +WORKDIR /src +# Сначала манифесты для кэша restore +COPY backend/PnvPanel.sln backend/Directory.Build.props backend/Directory.Packages.props ./backend/ +COPY backend/src/PnvPanel.Domain/PnvPanel.Domain.csproj ./backend/src/PnvPanel.Domain/ +COPY backend/src/PnvPanel.Application/PnvPanel.Application.csproj ./backend/src/PnvPanel.Application/ +COPY backend/src/PnvPanel.Infrastructure/PnvPanel.Infrastructure.csproj ./backend/src/PnvPanel.Infrastructure/ +COPY backend/src/PnvPanel.Api/PnvPanel.Api.csproj ./backend/src/PnvPanel.Api/ +RUN dotnet restore backend/PnvPanel.sln +# Исходники бэкенда +COPY backend/ ./backend/ +# Сид каталога приложений (PnvPanel.Api.csproj ссылается на него через ../../../seed/) +COPY seed/ ./seed/ +# Статика собранного фронта → wwwroot (перекрывает заглушку) +COPY --from=frontend /app/frontend/dist/ ./backend/src/PnvPanel.Api/wwwroot/ +RUN dotnet publish backend/src/PnvPanel.Api/PnvPanel.Api.csproj -c Release -o /app/publish --no-restore + +# ── Stage 3: runtime ────────────────────────────────────────────────────── +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app +ENV ASPNETCORE_ENVIRONMENT=Production \ + ASPNETCORE_HTTP_PORTS=8080 +EXPOSE 8080 +COPY --from=backend /app/publish ./ +ENTRYPOINT ["dotnet", "PnvPanel.Api.dll"] diff --git a/backend/Directory.Build.props b/backend/Directory.Build.props new file mode 100644 index 0000000..cb652c6 --- /dev/null +++ b/backend/Directory.Build.props @@ -0,0 +1,22 @@ + + + + net10.0 + latest + enable + enable + true + true + latest + false + true + + $(NoWarn);CA1711;CA1716;CA1848;CA1873 + + + diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props new file mode 100644 index 0000000..15e774b --- /dev/null +++ b/backend/Directory.Packages.props @@ -0,0 +1,29 @@ + + + true + + + + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + \ No newline at end of file diff --git a/backend/PnvPanel.slnx b/backend/PnvPanel.slnx new file mode 100644 index 0000000..811000e --- /dev/null +++ b/backend/PnvPanel.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/backend/src/PnvPanel.Api/Common/RateLimiting.cs b/backend/src/PnvPanel.Api/Common/RateLimiting.cs new file mode 100644 index 0000000..0edb423 --- /dev/null +++ b/backend/src/PnvPanel.Api/Common/RateLimiting.cs @@ -0,0 +1,6 @@ +namespace PnvPanel.Api.Common; + +public static class RateLimiting +{ + public const string AuthPolicy = "auth"; +} diff --git a/backend/src/PnvPanel.Api/Common/ResultExtensions.cs b/backend/src/PnvPanel.Api/Common/ResultExtensions.cs new file mode 100644 index 0000000..86f780d --- /dev/null +++ b/backend/src/PnvPanel.Api/Common/ResultExtensions.cs @@ -0,0 +1,27 @@ +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Api.Common; + +public static class ResultExtensions +{ + public static IResult ToHttpResult(this Result result) + => result.IsSuccess ? Results.NoContent() : ToProblem(result.Error); + + public static IResult ToHttpResult(this Result result) + => result.IsSuccess ? Results.Ok(result.Value) : ToProblem(result.Error); + + private static IResult ToProblem(Error error) + { + var statusCode = error.Type switch + { + ErrorType.Validation => StatusCodes.Status400BadRequest, + ErrorType.Unauthorized => StatusCodes.Status401Unauthorized, + ErrorType.Forbidden => StatusCodes.Status403Forbidden, + ErrorType.NotFound => StatusCodes.Status404NotFound, + ErrorType.Conflict => StatusCodes.Status409Conflict, + _ => StatusCodes.Status422UnprocessableEntity, + }; + + return Results.Problem(title: error.Code, detail: error.Message, statusCode: statusCode); + } +} diff --git a/backend/src/PnvPanel.Api/Endpoints/ActivationEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/ActivationEndpoints.cs new file mode 100644 index 0000000..9dd5cba --- /dev/null +++ b/backend/src/PnvPanel.Api/Endpoints/ActivationEndpoints.cs @@ -0,0 +1,65 @@ +using PnvPanel.Api.Common; +using PnvPanel.Application.Activation; +using PnvPanel.Application.Admin.Activation; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Domain.Activation; +using PnvPanel.Infrastructure.Identity; + +namespace PnvPanel.Api.Endpoints; + +public static class ActivationEndpoints +{ + public static IEndpointRouteBuilder MapActivationEndpoints(this IEndpointRouteBuilder app) + { + var user = app.MapGroup("/api/activation").WithTags("Activation").RequireAuthorization(); + user.MapGet("/status", GetStatus); + user.MapPost("/request", RequestActivation); + + var admin = app.MapGroup("/api/admin/activation-requests") + .WithTags("Admin.Activation") + .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); + + admin.MapGet("", ListRequests); + admin.MapPost("/{id:guid}/approve", Approve); + admin.MapPost("/{id:guid}/reject", Reject); + + return app; + } + + private static async Task GetStatus(ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new GetActivationStatusQuery(), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task RequestActivation(RequestActivationCommand command, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(command, cancellationToken); + return result.ToHttpResult(); + } + + private static async Task ListRequests( + [AsParameters] ListActivationRequestsRequest request, ISender sender, CancellationToken cancellationToken) + { + var query = new ListActivationRequestsQuery(request.StatusFilter, request.Page, request.PageSize); + var result = await sender.Send(query, cancellationToken); + return result.ToHttpResult(); + } + + private static async Task Approve(Guid id, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new ApproveActivationCommand(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task Reject( + Guid id, RejectActivationBody body, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new RejectActivationCommand(id, body.Reason), cancellationToken); + return result.ToHttpResult(); + } +} + +public sealed record ListActivationRequestsRequest(ActivationStatus? StatusFilter, int Page = 1, int PageSize = 20); + +public sealed record RejectActivationBody(string? Reason); diff --git a/backend/src/PnvPanel.Api/Endpoints/AppEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/AppEndpoints.cs new file mode 100644 index 0000000..668676a --- /dev/null +++ b/backend/src/PnvPanel.Api/Endpoints/AppEndpoints.cs @@ -0,0 +1,20 @@ +using PnvPanel.Api.Common; +using PnvPanel.Application.Apps; +using PnvPanel.Application.Common.Messaging; + +namespace PnvPanel.Api.Endpoints; + +public static class AppEndpoints +{ + public static IEndpointRouteBuilder MapAppEndpoints(this IEndpointRouteBuilder app) + { + app.MapGet("/api/apps", ListApps).WithTags("Apps").RequireAuthorization(); + return app; + } + + private static async Task ListApps(ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new ListAppsQuery(), cancellationToken); + return result.ToHttpResult(); + } +} diff --git a/backend/src/PnvPanel.Api/Endpoints/AuthEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/AuthEndpoints.cs new file mode 100644 index 0000000..cf661d4 --- /dev/null +++ b/backend/src/PnvPanel.Api/Endpoints/AuthEndpoints.cs @@ -0,0 +1,116 @@ +using PnvPanel.Api.Common; +using PnvPanel.Application.Auth; +using PnvPanel.Application.Auth.ChangePassword; +using PnvPanel.Application.Auth.DeleteMyAccount; +using PnvPanel.Application.Auth.Login; +using PnvPanel.Application.Auth.Logout; +using PnvPanel.Application.Auth.Me; +using PnvPanel.Application.Auth.Refresh; +using PnvPanel.Application.Auth.Register; +using PnvPanel.Application.Common.Messaging; + +namespace PnvPanel.Api.Endpoints; + +public static class AuthEndpoints +{ + private const string RefreshCookieName = "pnv_refresh_token"; + + public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/auth") + .WithTags("Auth") + .RequireRateLimiting(RateLimiting.AuthPolicy); + + group.MapPost("/register", Register); + group.MapPost("/login", Login); + group.MapPost("/refresh", Refresh); + group.MapPost("/logout", Logout).RequireAuthorization(); + group.MapPost("/change-password", ChangePassword).RequireAuthorization(); + group.MapGet("/me", Me).RequireAuthorization(); + group.MapDelete("/me", DeleteMe).RequireAuthorization(); + + return app; + } + + private static async Task Register(RegisterCommand command, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(command, cancellationToken); + return result.ToHttpResult(); + } + + private static async Task Login(LoginCommand command, ISender sender, HttpResponse response, CancellationToken cancellationToken) + { + var result = await sender.Send(command, cancellationToken); + if (!result.IsSuccess) + return result.ToHttpResult(); + + SetRefreshCookie(response, result.Value.RefreshToken, result.Value.RefreshTokenExpiresAt); + return Results.Ok(ToLoginResponse(result.Value)); + } + + private static async Task Refresh(HttpRequest request, HttpResponse response, ISender sender, CancellationToken cancellationToken) + { + if (!request.Cookies.TryGetValue(RefreshCookieName, out var rawToken) || string.IsNullOrEmpty(rawToken)) + return Results.Unauthorized(); + + var result = await sender.Send(new RefreshCommand(rawToken), cancellationToken); + if (!result.IsSuccess) + { + response.Cookies.Delete(RefreshCookieName, BuildCookieOptions()); + return result.ToHttpResult(); + } + + SetRefreshCookie(response, result.Value.RefreshToken, result.Value.RefreshTokenExpiresAt); + return Results.Ok(ToLoginResponse(result.Value)); + } + + private static async Task Logout(HttpRequest request, HttpResponse response, ISender sender, CancellationToken cancellationToken) + { + if (request.Cookies.TryGetValue(RefreshCookieName, out var rawToken) && !string.IsNullOrEmpty(rawToken)) + await sender.Send(new LogoutCommand(rawToken), cancellationToken); + + response.Cookies.Delete(RefreshCookieName, BuildCookieOptions()); + return Results.NoContent(); + } + + private static async Task ChangePassword(ChangePasswordCommand command, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(command, cancellationToken); + return result.ToHttpResult(); + } + + private static async Task Me(ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new GetCurrentUserQuery(), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task DeleteMe(HttpResponse response, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new DeleteMyAccountCommand(), cancellationToken); + response.Cookies.Delete(RefreshCookieName, BuildCookieOptions()); + return result.ToHttpResult(); + } + + private static object ToLoginResponse(AuthResult auth) => new + { + accessToken = auth.AccessToken, + expiresAt = auth.AccessTokenExpiresAt, + user = auth.User, + }; + + private static void SetRefreshCookie(HttpResponse response, string rawToken, DateTimeOffset expiresAt) + { + var options = BuildCookieOptions(); + options.Expires = expiresAt; + response.Cookies.Append(RefreshCookieName, rawToken, options); + } + + private static CookieOptions BuildCookieOptions() => new() + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Strict, + Path = "/api/auth", + }; +} diff --git a/backend/src/PnvPanel.Api/Endpoints/ConfigEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/ConfigEndpoints.cs new file mode 100644 index 0000000..9b32f7d --- /dev/null +++ b/backend/src/PnvPanel.Api/Endpoints/ConfigEndpoints.cs @@ -0,0 +1,81 @@ +using PnvPanel.Api.Common; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Configs.Create; +using PnvPanel.Application.Configs.Edit; +using PnvPanel.Application.Configs.GetConfigLink; +using PnvPanel.Application.Configs.GetMyConfigs; +using PnvPanel.Application.Configs.ListAvailableInbounds; +using PnvPanel.Application.Configs.Revoke; +using PnvPanel.Application.Configs.Rotate; + +namespace PnvPanel.Api.Endpoints; + +public static class ConfigEndpoints +{ + public static IEndpointRouteBuilder MapConfigEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api").WithTags("Configs").RequireAuthorization(); + + group.MapGet("/inbounds/available", ListAvailableInbounds); + group.MapGet("/configs", GetMyConfigs); + group.MapPost("/configs", CreateConfig); + group.MapPatch("/configs/{id:guid}", EditConfig); + group.MapPost("/configs/{id:guid}/rotate", RotateConfig); + group.MapDelete("/configs/{id:guid}", RevokeConfig); + group.MapGet("/configs/{id:guid}/link", GetConfigLink); + + return app; + } + + private static async Task ListAvailableInbounds(ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new ListAvailableInboundsQuery(), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task GetMyConfigs(ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new GetMyConfigsQuery(), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task CreateConfig(CreateConfigBody body, ISender sender, CancellationToken cancellationToken) + { + var command = new CreateVpnConfigCommand(body.InboundId, body.Label, body.DeviceLimit); + var result = await sender.Send(command, cancellationToken); + return result.ToHttpResult(); + } + + private static async Task EditConfig(Guid id, EditConfigBody body, ISender sender, CancellationToken cancellationToken) + { + var command = new EditVpnConfigCommand(id, body.Label, body.DeviceLimit); + var result = await sender.Send(command, cancellationToken); + return result.ToHttpResult(); + } + + private static async Task RotateConfig(Guid id, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new RotateVpnConfigCommand(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task RevokeConfig(Guid id, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new RevokeVpnConfigCommand(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task GetConfigLink(Guid id, HttpRequest request, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new GetConfigLinkQuery(id), cancellationToken); + if (!result.IsSuccess) + return result.ToHttpResult(); + + var subscriptionUrl = $"{request.Scheme}://{request.Host}/sub/{result.Value.SubscriptionToken}"; + return Results.Ok(new { connectionString = result.Value.ConnectionString, subscriptionUrl }); + } +} + +public sealed record CreateConfigBody(Guid InboundId, string? Label, int? DeviceLimit); + +public sealed record EditConfigBody(string? Label, int? DeviceLimit); diff --git a/backend/src/PnvPanel.Api/Endpoints/InboundEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/InboundEndpoints.cs new file mode 100644 index 0000000..4b31810 --- /dev/null +++ b/backend/src/PnvPanel.Api/Endpoints/InboundEndpoints.cs @@ -0,0 +1,38 @@ +using PnvPanel.Api.Common; +using PnvPanel.Application.Admin.Inbounds; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Infrastructure.Identity; + +namespace PnvPanel.Api.Endpoints; + +public static class InboundEndpoints +{ + public static IEndpointRouteBuilder MapInboundEndpoints(this IEndpointRouteBuilder app) + { + var admin = app.MapGroup("/api/admin/inbounds") + .WithTags("Admin.Inbounds") + .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); + + admin.MapGet("", ListInbounds); + admin.MapPut("/{id:guid}/publish", PublishInbound); + + return app; + } + + private static async Task ListInbounds(Guid? nodeId, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new ListInboundsQuery(nodeId), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task PublishInbound( + Guid id, PublishInboundBody body, ISender sender, CancellationToken cancellationToken) + { + var command = new PublishInboundCommand( + id, body.IsPublished, body.DisplayName, body.AllowedRoleIds ?? [], body.MaxClients); + var result = await sender.Send(command, cancellationToken); + return result.ToHttpResult(); + } +} + +public sealed record PublishInboundBody(bool IsPublished, string? DisplayName, IReadOnlyList? AllowedRoleIds, int? MaxClients); diff --git a/backend/src/PnvPanel.Api/Endpoints/NodeEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/NodeEndpoints.cs new file mode 100644 index 0000000..714d752 --- /dev/null +++ b/backend/src/PnvPanel.Api/Endpoints/NodeEndpoints.cs @@ -0,0 +1,64 @@ +using PnvPanel.Api.Common; +using PnvPanel.Application.Admin.Nodes; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Infrastructure.Identity; + +namespace PnvPanel.Api.Endpoints; + +public static class NodeEndpoints +{ + public static IEndpointRouteBuilder MapNodeEndpoints(this IEndpointRouteBuilder app) + { + var admin = app.MapGroup("/api/admin/nodes") + .WithTags("Admin.Nodes") + .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); + + admin.MapGet("", ListNodes); + admin.MapPost("", RegisterNode); + admin.MapPut("/{id:guid}", UpdateNode); + admin.MapDelete("/{id:guid}", DeleteNode); + admin.MapPost("/{id:guid}/sync", SyncNode); + admin.MapPost("/{id:guid}/probe", ProbeNode); + + return app; + } + + private static async Task ListNodes(ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new ListNodesQuery(), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task RegisterNode(RegisterNodeCommand command, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(command, cancellationToken); + return result.ToHttpResult(); + } + + private static async Task UpdateNode(Guid id, UpdateNodeBody body, ISender sender, CancellationToken cancellationToken) + { + var command = new UpdateNodeCommand(id, body.Name, body.Location, body.IsEnabled, body.Username, body.Password); + var result = await sender.Send(command, cancellationToken); + return result.ToHttpResult(); + } + + private static async Task DeleteNode(Guid id, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new DeleteNodeCommand(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task SyncNode(Guid id, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new SyncNodeCommand(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task ProbeNode(Guid id, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new ProbeNodeCommand(id), cancellationToken); + return result.ToHttpResult(); + } +} + +public sealed record UpdateNodeBody(string Name, string? Location, bool IsEnabled, string? Username, string? Password); diff --git a/backend/src/PnvPanel.Api/Endpoints/RoleEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/RoleEndpoints.cs new file mode 100644 index 0000000..6778d8b --- /dev/null +++ b/backend/src/PnvPanel.Api/Endpoints/RoleEndpoints.cs @@ -0,0 +1,59 @@ +using PnvPanel.Api.Common; +using PnvPanel.Application.Admin.Roles; +using PnvPanel.Application.Admin.Users; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Infrastructure.Identity; + +namespace PnvPanel.Api.Endpoints; + +public static class RoleEndpoints +{ + public static IEndpointRouteBuilder MapRoleEndpoints(this IEndpointRouteBuilder app) + { + var admin = app.MapGroup("/api/admin") + .WithTags("Admin.Roles") + .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); + + admin.MapGet("/roles", ListRoles); + admin.MapPost("/roles", CreateRole); + admin.MapPut("/roles/{id:guid}", UpdateRole); + admin.MapDelete("/roles/{id:guid}", DeleteRole); + admin.MapPatch("/users/{id:guid}/role", ChangeUserRole); + + return app; + } + + private static async Task ListRoles(ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new ListRolesQuery(), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task CreateRole(CreateRoleCommand command, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(command, cancellationToken); + return result.ToHttpResult(); + } + + private static async Task UpdateRole(Guid id, UpdateRoleBody body, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new UpdateRoleCommand(id, body.MaxConfigs), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task DeleteRole(Guid id, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new DeleteRoleCommand(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task ChangeUserRole(Guid id, ChangeUserRoleBody body, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new ChangeUserRoleCommand(id, body.RoleId), cancellationToken); + return result.ToHttpResult(); + } +} + +public sealed record UpdateRoleBody(int MaxConfigs); + +public sealed record ChangeUserRoleBody(Guid RoleId); diff --git a/backend/src/PnvPanel.Api/Endpoints/SubscriptionEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/SubscriptionEndpoints.cs new file mode 100644 index 0000000..cab366f --- /dev/null +++ b/backend/src/PnvPanel.Api/Endpoints/SubscriptionEndpoints.cs @@ -0,0 +1,42 @@ +using System.Text; +using PnvPanel.Api.Common; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Subscriptions; + +namespace PnvPanel.Api.Endpoints; + +public static class SubscriptionEndpoints +{ + public static IEndpointRouteBuilder MapSubscriptionEndpoints(this IEndpointRouteBuilder app) + { + // Вне /api по дизайну (api-design.md) — публичный эндпоинт для VPN-клиентов. + app.MapGet("/sub/{token}", GetSubscription) + .WithTags("Subscription") + .RequireRateLimiting(RateLimiting.AuthPolicy); + + return app; + } + + private static async Task GetSubscription(string token, HttpResponse response, ISender sender, CancellationToken cancellationToken) + { + // Токен — либо AppUser.SubscriptionToken (агрегированная подписка), либо VpnConfig.SubscriptionToken + // (один конфиг). Пробуем пользовательский токен первым. + var userResult = await sender.Send(new GetUserSubscriptionQuery(token), cancellationToken); + var result = userResult.IsSuccess ? userResult : await sender.Send(new GetConfigSubscriptionQuery(token), cancellationToken); + + if (!result.IsSuccess) + return Results.NotFound(); + + var body = string.Join('\n', result.Value.ConnectionStrings); + var base64Body = Convert.ToBase64String(Encoding.UTF8.GetBytes(body)); + + var total = result.Value.UsedUpBytes + result.Value.UsedDownBytes; + var expire = result.Value.ExpiresAt is { } exp ? exp.ToUnixTimeSeconds().ToString() : "0"; + response.Headers.Append( + "Subscription-Userinfo", + $"upload={result.Value.UsedUpBytes}; download={result.Value.UsedDownBytes}; total={total}; expire={expire}"); + response.Headers.Append("Profile-Update-Interval", "12"); + + return Results.Text(base64Body, "text/plain; charset=utf-8"); + } +} diff --git a/backend/src/PnvPanel.Api/PnvPanel.Api.csproj b/backend/src/PnvPanel.Api/PnvPanel.Api.csproj new file mode 100644 index 0000000..e0fcc93 --- /dev/null +++ b/backend/src/PnvPanel.Api/PnvPanel.Api.csproj @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + net10.0 + enable + enable + + + diff --git a/backend/src/PnvPanel.Api/Program.cs b/backend/src/PnvPanel.Api/Program.cs new file mode 100644 index 0000000..87917b1 --- /dev/null +++ b/backend/src/PnvPanel.Api/Program.cs @@ -0,0 +1,83 @@ +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.RateLimiting; +using PnvPanel.Api.Common; +using PnvPanel.Api.Endpoints; +using PnvPanel.Application; +using PnvPanel.Infrastructure; +using PnvPanel.Infrastructure.Identity; +using PnvPanel.Infrastructure.Persistence; +using Scalar.AspNetCore; +using Serilog; + +var builder = WebApplication.CreateBuilder(args); + +// Структурное логирование (Serilog), конфигурация из appsettings/env. +builder.Services.AddSerilog((services, configuration) => configuration + .ReadFrom.Configuration(builder.Configuration) + .ReadFrom.Services(services) + .Enrich.FromLogContext()); + +// За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера). +builder.Services.Configure(options => +{ + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; + options.KnownIPNetworks.Clear(); + options.KnownProxies.Clear(); +}); + +builder.Services.AddHttpContextAccessor(); +builder.Services.AddApplication(); +builder.Services.AddInfrastructure(builder.Configuration); + +builder.Services.AddRateLimiter(options => +{ + options.AddFixedWindowLimiter(RateLimiting.AuthPolicy, limiterOptions => + { + limiterOptions.PermitLimit = 20; + limiterOptions.Window = TimeSpan.FromMinutes(1); + limiterOptions.QueueLimit = 0; + }); + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; +}); + +builder.Services.AddProblemDetails(); +builder.Services.AddOpenApi(); +builder.Services.AddHealthChecks() + .AddDbContextCheck(); + +var app = builder.Build(); + +// Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте. +await app.Services.ApplyMigrationsAsync(); +await app.Services.SeedDataAsync(); + +app.UseForwardedHeaders(); +app.UseSerilogRequestLogging(); +app.UseExceptionHandler(); + +app.UseRateLimiter(); + +app.UseAuthentication(); +app.UseAuthorization(); + +// OpenAPI-схема (/openapi/v1.json) + современный UI Scalar (/scalar). +app.MapOpenApi(); +app.MapScalarApiReference(); + +app.MapHealthChecks("/health"); + +app.MapAuthEndpoints(); +app.MapActivationEndpoints(); +app.MapRoleEndpoints(); +app.MapNodeEndpoints(); +app.MapInboundEndpoints(); +app.MapConfigEndpoints(); +app.MapSubscriptionEndpoints(); +app.MapAppEndpoints(); + +// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов. +app.UseDefaultFiles(); +app.UseStaticFiles(); +app.MapFallbackToFile("index.html"); + +app.Run(); diff --git a/backend/src/PnvPanel.Api/Properties/launchSettings.json b/backend/src/PnvPanel.Api/Properties/launchSettings.json new file mode 100644 index 0000000..24042ab --- /dev/null +++ b/backend/src/PnvPanel.Api/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5278", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7075;http://localhost:5278", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/backend/src/PnvPanel.Api/appsettings.Development.json b/backend/src/PnvPanel.Api/appsettings.Development.json new file mode 100644 index 0000000..c55eb3e --- /dev/null +++ b/backend/src/PnvPanel.Api/appsettings.Development.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AdminSeed": { + "Username": "admin", + "Password": "Passw0rd!Dev" + } +} diff --git a/backend/src/PnvPanel.Api/appsettings.json b/backend/src/PnvPanel.Api/appsettings.json new file mode 100644 index 0000000..7943560 --- /dev/null +++ b/backend/src/PnvPanel.Api/appsettings.json @@ -0,0 +1,32 @@ +{ + "ConnectionStrings": { + "Default": "Host=localhost;Port=5432;Database=pnvpanel;Username=pnvpanel;Password=pnvpanel" + }, + "Jwt": { + "Issuer": "PnvPanel", + "Audience": "PnvPanel", + "SigningKey": "change-me-min-32-chars-random-secret", + "AccessTokenMinutes": 15, + "RefreshTokenDays": 30 + }, + "AdminSeed": { + "Username": "", + "Password": "" + }, + "Roles": { + "DefaultUserMaxConfigs": 3 + }, + "Serilog": { + "Using": [ "Serilog.Sinks.Console" ], + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + }, + "WriteTo": [ { "Name": "Console" } ], + "Enrich": [ "FromLogContext" ] + }, + "AllowedHosts": "*" +} diff --git a/backend/src/PnvPanel.Api/wwwroot/index.html b/backend/src/PnvPanel.Api/wwwroot/index.html new file mode 100644 index 0000000..fce07a0 --- /dev/null +++ b/backend/src/PnvPanel.Api/wwwroot/index.html @@ -0,0 +1,17 @@ + + + + + + PnvPanel + + +
+

PnvPanel

+

Каркас приложения (M0). Здесь будет собранное SPA (React + Vite).

+

+ API: /scalar · Health: /health +

+
+ + diff --git a/backend/src/PnvPanel.Application/Activation/ActivationErrors.cs b/backend/src/PnvPanel.Application/Activation/ActivationErrors.cs new file mode 100644 index 0000000..eed1d2b --- /dev/null +++ b/backend/src/PnvPanel.Application/Activation/ActivationErrors.cs @@ -0,0 +1,15 @@ +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Activation; + +public static class ActivationErrors +{ + public static readonly Error AlreadyPending = + Error.Conflict("Activation.AlreadyPending", "У вас уже есть необработанный запрос на активацию."); + + public static readonly Error NotFound = + Error.NotFound("Activation.NotFound", "Запрос на активацию не найден."); + + public static readonly Error AlreadyDecided = + Error.Conflict("Activation.AlreadyDecided", "Запрос на активацию уже обработан."); +} diff --git a/backend/src/PnvPanel.Application/Activation/ActivationRequestDto.cs b/backend/src/PnvPanel.Application/Activation/ActivationRequestDto.cs new file mode 100644 index 0000000..b36bf5b --- /dev/null +++ b/backend/src/PnvPanel.Application/Activation/ActivationRequestDto.cs @@ -0,0 +1,3 @@ +namespace PnvPanel.Application.Activation; + +public sealed record ActivationRequestDto(Guid Id, string? Comment, DateTimeOffset CreatedAt); diff --git a/backend/src/PnvPanel.Application/Activation/ActivationStatusDto.cs b/backend/src/PnvPanel.Application/Activation/ActivationStatusDto.cs new file mode 100644 index 0000000..187ed46 --- /dev/null +++ b/backend/src/PnvPanel.Application/Activation/ActivationStatusDto.cs @@ -0,0 +1,3 @@ +namespace PnvPanel.Application.Activation; + +public sealed record ActivationStatusDto(bool IsActivated, ActivationRequestDto? PendingRequest); diff --git a/backend/src/PnvPanel.Application/Activation/GetActivationStatusQuery.cs b/backend/src/PnvPanel.Application/Activation/GetActivationStatusQuery.cs new file mode 100644 index 0000000..2e98c66 --- /dev/null +++ b/backend/src/PnvPanel.Application/Activation/GetActivationStatusQuery.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Activation; + +public sealed record GetActivationStatusQuery : IQuery>; diff --git a/backend/src/PnvPanel.Application/Activation/GetActivationStatusQueryHandler.cs b/backend/src/PnvPanel.Application/Activation/GetActivationStatusQueryHandler.cs new file mode 100644 index 0000000..54d2e05 --- /dev/null +++ b/backend/src/PnvPanel.Application/Activation/GetActivationStatusQueryHandler.cs @@ -0,0 +1,29 @@ +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.Activation; + +namespace PnvPanel.Application.Activation; + +public sealed class GetActivationStatusQueryHandler(IIdentityService identityService, IAppDbContext dbContext, ICurrentUser currentUser) + : IQueryHandler> +{ + public async Task> Handle(GetActivationStatusQuery query, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return Result.Failure(AuthErrors.Unauthorized); + + var profile = await identityService.GetProfileAsync(userId, cancellationToken); + if (profile is null) + return Result.Failure(AuthErrors.Unauthorized); + + var pending = await dbContext.ActivationRequests + .Where(r => r.UserId == userId && r.Status == ActivationStatus.Pending) + .Select(r => new ActivationRequestDto(r.Id, r.Comment, r.CreatedAt)) + .FirstOrDefaultAsync(cancellationToken); + + return Result.Success(new ActivationStatusDto(profile.IsActivated, pending)); + } +} diff --git a/backend/src/PnvPanel.Application/Activation/RequestActivationCommand.cs b/backend/src/PnvPanel.Application/Activation/RequestActivationCommand.cs new file mode 100644 index 0000000..a601190 --- /dev/null +++ b/backend/src/PnvPanel.Application/Activation/RequestActivationCommand.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Activation; + +public sealed record RequestActivationCommand(string? Comment) : ICommand>; diff --git a/backend/src/PnvPanel.Application/Activation/RequestActivationCommandHandler.cs b/backend/src/PnvPanel.Application/Activation/RequestActivationCommandHandler.cs new file mode 100644 index 0000000..ebc960c --- /dev/null +++ b/backend/src/PnvPanel.Application/Activation/RequestActivationCommandHandler.cs @@ -0,0 +1,29 @@ +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.Activation; + +namespace PnvPanel.Application.Activation; + +public sealed class RequestActivationCommandHandler(IAppDbContext dbContext, ICurrentUser currentUser) + : ICommandHandler> +{ + public async Task> Handle(RequestActivationCommand command, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return Result.Failure(AuthErrors.Unauthorized); + + var hasPending = await dbContext.ActivationRequests + .AnyAsync(r => r.UserId == userId && r.Status == ActivationStatus.Pending, cancellationToken); + + if (hasPending) + return Result.Failure(ActivationErrors.AlreadyPending); + + var request = ActivationRequest.Create(userId, command.Comment); + dbContext.ActivationRequests.Add(request); + + return Result.Success(new ActivationRequestDto(request.Id, request.Comment, request.CreatedAt)); + } +} diff --git a/backend/src/PnvPanel.Application/Activation/RequestActivationCommandValidator.cs b/backend/src/PnvPanel.Application/Activation/RequestActivationCommandValidator.cs new file mode 100644 index 0000000..d95ca34 --- /dev/null +++ b/backend/src/PnvPanel.Application/Activation/RequestActivationCommandValidator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace PnvPanel.Application.Activation; + +public sealed class RequestActivationCommandValidator : AbstractValidator +{ + public RequestActivationCommandValidator() + { + RuleFor(x => x.Comment).MaximumLength(500); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Activation/ActivationRequestAdminDto.cs b/backend/src/PnvPanel.Application/Admin/Activation/ActivationRequestAdminDto.cs new file mode 100644 index 0000000..21864f9 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Activation/ActivationRequestAdminDto.cs @@ -0,0 +1,11 @@ +using PnvPanel.Domain.Activation; + +namespace PnvPanel.Application.Admin.Activation; + +public sealed record ActivationRequestAdminDto( + Guid Id, + Guid UserId, + string UserName, + string? Comment, + ActivationStatus Status, + DateTimeOffset CreatedAt); diff --git a/backend/src/PnvPanel.Application/Admin/Activation/ApproveActivationCommand.cs b/backend/src/PnvPanel.Application/Admin/Activation/ApproveActivationCommand.cs new file mode 100644 index 0000000..749f7e3 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Activation/ApproveActivationCommand.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Activation; + +public sealed record ApproveActivationCommand(Guid RequestId) : ICommand; diff --git a/backend/src/PnvPanel.Application/Admin/Activation/ApproveActivationCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Activation/ApproveActivationCommandHandler.cs new file mode 100644 index 0000000..1cbbffd --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Activation/ApproveActivationCommandHandler.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Activation; +using PnvPanel.Application.Auth; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Activation; + +namespace PnvPanel.Application.Admin.Activation; + +public sealed class ApproveActivationCommandHandler(IAppDbContext dbContext, IIdentityService identityService, ICurrentUser currentUser) + : ICommandHandler +{ + public async Task Handle(ApproveActivationCommand command, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } adminId) + return Result.Failure(AuthErrors.Unauthorized); + + var request = await dbContext.ActivationRequests + .FirstOrDefaultAsync(r => r.Id == command.RequestId, cancellationToken); + + if (request is null) + return Result.Failure(ActivationErrors.NotFound); + + if (request.Status != ActivationStatus.Pending) + return Result.Failure(ActivationErrors.AlreadyDecided); + + request.Approve(adminId); + + return await identityService.ActivateUserAsync(request.UserId, adminId, cancellationToken); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Activation/ListActivationRequestsQuery.cs b/backend/src/PnvPanel.Application/Admin/Activation/ListActivationRequestsQuery.cs new file mode 100644 index 0000000..b9f3b67 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Activation/ListActivationRequestsQuery.cs @@ -0,0 +1,8 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Activation; + +namespace PnvPanel.Application.Admin.Activation; + +public sealed record ListActivationRequestsQuery(ActivationStatus? StatusFilter, int Page, int PageSize) + : IQuery>>; diff --git a/backend/src/PnvPanel.Application/Admin/Activation/ListActivationRequestsQueryHandler.cs b/backend/src/PnvPanel.Application/Admin/Activation/ListActivationRequestsQueryHandler.cs new file mode 100644 index 0000000..b72d243 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Activation/ListActivationRequestsQueryHandler.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Activation; + +public sealed class ListActivationRequestsQueryHandler(IAppDbContext dbContext, IIdentityService identityService) + : IQueryHandler>> +{ + public async Task>> Handle(ListActivationRequestsQuery query, CancellationToken cancellationToken) + { + var page = query.Page <= 0 ? 1 : query.Page; + var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize; + + var requestsQuery = dbContext.ActivationRequests.AsNoTracking(); + if (query.StatusFilter is { } status) + requestsQuery = requestsQuery.Where(r => r.Status == status); + + var page1 = await requestsQuery + .OrderBy(r => r.CreatedAt) + .ToPagedListAsync(page, pageSize, cancellationToken); + + var userNames = await identityService.GetUserNamesAsync( + page1.Items.Select(r => r.UserId).Distinct().ToList(), + cancellationToken); + + var items = page1.Items + .Select(r => new ActivationRequestAdminDto( + r.Id, r.UserId, userNames.GetValueOrDefault(r.UserId, "?"), r.Comment, r.Status, r.CreatedAt)) + .ToList(); + + return Result.Success(new PagedList(items, page1.Total, page1.Page, page1.PageSize)); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Activation/RejectActivationCommand.cs b/backend/src/PnvPanel.Application/Admin/Activation/RejectActivationCommand.cs new file mode 100644 index 0000000..694c8c9 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Activation/RejectActivationCommand.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Activation; + +public sealed record RejectActivationCommand(Guid RequestId, string? Reason) : ICommand; diff --git a/backend/src/PnvPanel.Application/Admin/Activation/RejectActivationCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Activation/RejectActivationCommandHandler.cs new file mode 100644 index 0000000..087686a --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Activation/RejectActivationCommandHandler.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Activation; +using PnvPanel.Application.Auth; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Activation; + +namespace PnvPanel.Application.Admin.Activation; + +public sealed class RejectActivationCommandHandler(IAppDbContext dbContext, ICurrentUser currentUser) + : ICommandHandler +{ + public async Task Handle(RejectActivationCommand command, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } adminId) + return Result.Failure(AuthErrors.Unauthorized); + + var request = await dbContext.ActivationRequests + .FirstOrDefaultAsync(r => r.Id == command.RequestId, cancellationToken); + + if (request is null) + return Result.Failure(ActivationErrors.NotFound); + + if (request.Status != ActivationStatus.Pending) + return Result.Failure(ActivationErrors.AlreadyDecided); + + request.Reject(adminId, command.Reason); + return Result.Success(); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Activation/RejectActivationCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/Activation/RejectActivationCommandValidator.cs new file mode 100644 index 0000000..6a79605 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Activation/RejectActivationCommandValidator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace PnvPanel.Application.Admin.Activation; + +public sealed class RejectActivationCommandValidator : AbstractValidator +{ + public RejectActivationCommandValidator() + { + RuleFor(x => x.Reason).MaximumLength(500); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Inbounds/InboundDto.cs b/backend/src/PnvPanel.Application/Admin/Inbounds/InboundDto.cs new file mode 100644 index 0000000..d8b260a --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Inbounds/InboundDto.cs @@ -0,0 +1,13 @@ +using PnvPanel.Domain.Inbounds; + +namespace PnvPanel.Application.Admin.Inbounds; + +public sealed record InboundDto( + Guid Id, Guid NodeId, string RemoteInboundId, VpnProtocol Protocol, string Remark, int Port, + bool IsPublished, string? DisplayName, int? MaxClients, IReadOnlyList AllowedRoleIds, + DateTimeOffset? LastSyncAt) +{ + public static InboundDto FromDomain(Inbound inbound) => new( + inbound.Id, inbound.NodeId, inbound.RemoteInboundId, inbound.Protocol, inbound.Remark, inbound.Port, + inbound.IsPublished, inbound.DisplayName, inbound.MaxClients, inbound.AllowedRoleIds, inbound.LastSyncAt); +} diff --git a/backend/src/PnvPanel.Application/Admin/Inbounds/InboundErrors.cs b/backend/src/PnvPanel.Application/Admin/Inbounds/InboundErrors.cs new file mode 100644 index 0000000..ceac891 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Inbounds/InboundErrors.cs @@ -0,0 +1,8 @@ +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Inbounds; + +public static class InboundErrors +{ + public static readonly Error NotFound = Error.NotFound("Inbounds.NotFound", "Inbound не найден."); +} diff --git a/backend/src/PnvPanel.Application/Admin/Inbounds/ListInboundsQuery.cs b/backend/src/PnvPanel.Application/Admin/Inbounds/ListInboundsQuery.cs new file mode 100644 index 0000000..1d7cc98 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Inbounds/ListInboundsQuery.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Inbounds; + +public sealed record ListInboundsQuery(Guid? NodeId) : IQuery>>; diff --git a/backend/src/PnvPanel.Application/Admin/Inbounds/ListInboundsQueryHandler.cs b/backend/src/PnvPanel.Application/Admin/Inbounds/ListInboundsQueryHandler.cs new file mode 100644 index 0000000..d71e5be --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Inbounds/ListInboundsQueryHandler.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Inbounds; + +public sealed class ListInboundsQueryHandler(IAppDbContext dbContext) : IQueryHandler>> +{ + public async Task>> Handle(ListInboundsQuery query, CancellationToken cancellationToken) + { + var inboundsQuery = dbContext.Inbounds.AsNoTracking(); + if (query.NodeId is { } nodeId) + inboundsQuery = inboundsQuery.Where(i => i.NodeId == nodeId); + + var inbounds = await inboundsQuery.OrderBy(i => i.Remark).ToListAsync(cancellationToken); + return Result.Success>(inbounds.Select(InboundDto.FromDomain).ToList()); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommand.cs b/backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommand.cs new file mode 100644 index 0000000..e84b41c --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommand.cs @@ -0,0 +1,8 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Inbounds; + +public sealed record PublishInboundCommand( + Guid InboundId, bool IsPublished, string? DisplayName, IReadOnlyList AllowedRoleIds, int? MaxClients) + : ICommand>; diff --git a/backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommandHandler.cs new file mode 100644 index 0000000..11957a5 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommandHandler.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Inbounds; + +public sealed class PublishInboundCommandHandler(IAppDbContext dbContext) + : ICommandHandler> +{ + public async Task> Handle(PublishInboundCommand command, CancellationToken cancellationToken) + { + var inbound = await dbContext.Inbounds.FirstOrDefaultAsync(i => i.Id == command.InboundId, cancellationToken); + if (inbound is null) + return Result.Failure(InboundErrors.NotFound); + + if (command.IsPublished) + inbound.Publish(command.DisplayName, command.AllowedRoleIds, command.MaxClients); + else + inbound.Unpublish(); + + return Result.Success(InboundDto.FromDomain(inbound)); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommandValidator.cs new file mode 100644 index 0000000..31780d9 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Inbounds/PublishInboundCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace PnvPanel.Application.Admin.Inbounds; + +public sealed class PublishInboundCommandValidator : AbstractValidator +{ + public PublishInboundCommandValidator() + { + RuleFor(x => x.DisplayName).MaximumLength(100); + RuleFor(x => x.MaxClients).GreaterThan(0).When(x => x.MaxClients.HasValue); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/DeleteNodeCommand.cs b/backend/src/PnvPanel.Application/Admin/Nodes/DeleteNodeCommand.cs new file mode 100644 index 0000000..f11322b --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Nodes/DeleteNodeCommand.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Nodes; + +public sealed record DeleteNodeCommand(Guid NodeId) : ICommand; diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/DeleteNodeCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Nodes/DeleteNodeCommandHandler.cs new file mode 100644 index 0000000..690f950 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Nodes/DeleteNodeCommandHandler.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Nodes; + +public sealed class DeleteNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway) + : ICommandHandler +{ + public async Task Handle(DeleteNodeCommand command, CancellationToken cancellationToken) + { + var node = await dbContext.Nodes.FirstOrDefaultAsync(n => n.Id == command.NodeId, cancellationToken); + if (node is null) + return Result.Failure(NodeErrors.NotFound); + + var inbounds = await dbContext.Inbounds.Where(i => i.NodeId == node.Id).ToListAsync(cancellationToken); + dbContext.Inbounds.RemoveRange(inbounds); + dbContext.Nodes.Remove(node); + gateway.InvalidateClient(node.Id); + + return Result.Success(); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/ListNodesQuery.cs b/backend/src/PnvPanel.Application/Admin/Nodes/ListNodesQuery.cs new file mode 100644 index 0000000..e5aa661 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Nodes/ListNodesQuery.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Nodes; + +public sealed record ListNodesQuery : IQuery>>; diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/ListNodesQueryHandler.cs b/backend/src/PnvPanel.Application/Admin/Nodes/ListNodesQueryHandler.cs new file mode 100644 index 0000000..5491a4a --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Nodes/ListNodesQueryHandler.cs @@ -0,0 +1,15 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Nodes; + +public sealed class ListNodesQueryHandler(IAppDbContext dbContext) : IQueryHandler>> +{ + public async Task>> Handle(ListNodesQuery query, CancellationToken cancellationToken) + { + var nodes = await dbContext.Nodes.AsNoTracking().OrderBy(n => n.Name).ToListAsync(cancellationToken); + return Result.Success>(nodes.Select(NodeDto.FromDomain).ToList()); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/NodeDto.cs b/backend/src/PnvPanel.Application/Admin/Nodes/NodeDto.cs new file mode 100644 index 0000000..923964b --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Nodes/NodeDto.cs @@ -0,0 +1,13 @@ +using PnvPanel.Domain.Nodes; + +namespace PnvPanel.Application.Admin.Nodes; + +/// Админский DTO ноды. Пароль никогда не попадает в ответ API. +public sealed record NodeDto( + Guid Id, string Name, string BaseAddress, string Username, string? Location, + NodeStatus Status, bool IsEnabled, DateTimeOffset? LastSyncAt) +{ + public static NodeDto FromDomain(Node node) => new( + node.Id, node.Name, node.BaseAddress.ToString(), node.Credentials.Username, node.Location, + node.Status, node.IsEnabled, node.LastSyncAt); +} diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/NodeErrors.cs b/backend/src/PnvPanel.Application/Admin/Nodes/NodeErrors.cs new file mode 100644 index 0000000..d76fe9c --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Nodes/NodeErrors.cs @@ -0,0 +1,9 @@ +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Nodes; + +public static class NodeErrors +{ + public static readonly Error NotFound = Error.NotFound("Nodes.NotFound", "Нода не найдена."); + public static readonly Error InvalidBaseAddress = Error.Validation("Nodes.InvalidBaseAddress", "Некорректный адрес панели."); +} diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/ProbeNodeCommand.cs b/backend/src/PnvPanel.Application/Admin/Nodes/ProbeNodeCommand.cs new file mode 100644 index 0000000..794b5db --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Nodes/ProbeNodeCommand.cs @@ -0,0 +1,9 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Nodes; + +namespace PnvPanel.Application.Admin.Nodes; + +public sealed record ProbeNodeCommand(Guid NodeId) : ICommand>; + +public sealed record NodeProbeResultDto(bool IsReachable, string? ErrorMessage, NodeStatus Status); diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/ProbeNodeCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Nodes/ProbeNodeCommandHandler.cs new file mode 100644 index 0000000..a2ebb69 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Nodes/ProbeNodeCommandHandler.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Nodes; + +namespace PnvPanel.Application.Admin.Nodes; + +public sealed class ProbeNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway) + : ICommandHandler> +{ + public async Task> Handle(ProbeNodeCommand command, CancellationToken cancellationToken) + { + var node = await dbContext.Nodes.FirstOrDefaultAsync(n => n.Id == command.NodeId, cancellationToken); + if (node is null) + return Result.Failure(NodeErrors.NotFound); + + var probe = await gateway.ProbeAsync(node, cancellationToken); + node.UpdateStatus(probe.IsReachable ? NodeStatus.Online : NodeStatus.Offline); + + return Result.Success(new NodeProbeResultDto(probe.IsReachable, probe.ErrorMessage, node.Status)); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/RegisterNodeCommand.cs b/backend/src/PnvPanel.Application/Admin/Nodes/RegisterNodeCommand.cs new file mode 100644 index 0000000..900e957 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Nodes/RegisterNodeCommand.cs @@ -0,0 +1,7 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Nodes; + +public sealed record RegisterNodeCommand(string Name, string BaseAddress, string Username, string Password, string? Location) + : ICommand>; diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/RegisterNodeCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Nodes/RegisterNodeCommandHandler.cs new file mode 100644 index 0000000..4dfe409 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Nodes/RegisterNodeCommandHandler.cs @@ -0,0 +1,27 @@ +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Nodes; + +namespace PnvPanel.Application.Admin.Nodes; + +public sealed class RegisterNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway, ISecretProtector secretProtector) + : ICommandHandler> +{ + public Task> Handle(RegisterNodeCommand command, CancellationToken cancellationToken) + { + if (!Uri.TryCreate(command.BaseAddress, UriKind.Absolute, out var baseAddress)) + return Task.FromResult(Result.Failure(NodeErrors.InvalidBaseAddress)); + + var validation = gateway.ValidateBaseAddress(baseAddress); + if (!validation.IsSuccess) + return Task.FromResult(Result.Failure(validation.Error)); + + var credentials = new NodeCredentials(command.Username, secretProtector.Protect(command.Password)); + var node = Node.Register(command.Name, baseAddress, credentials, command.Location); + + dbContext.Nodes.Add(node); + + return Task.FromResult(Result.Success(NodeDto.FromDomain(node))); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/RegisterNodeCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/Nodes/RegisterNodeCommandValidator.cs new file mode 100644 index 0000000..62cd779 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Nodes/RegisterNodeCommandValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; + +namespace PnvPanel.Application.Admin.Nodes; + +public sealed class RegisterNodeCommandValidator : AbstractValidator +{ + public RegisterNodeCommandValidator() + { + RuleFor(x => x.Name).NotEmpty().MaximumLength(100); + RuleFor(x => x.BaseAddress).NotEmpty().MaximumLength(500); + RuleFor(x => x.Username).NotEmpty().MaximumLength(200); + RuleFor(x => x.Password).NotEmpty(); + RuleFor(x => x.Location).MaximumLength(100); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/SyncNodeCommand.cs b/backend/src/PnvPanel.Application/Admin/Nodes/SyncNodeCommand.cs new file mode 100644 index 0000000..6e4c02a --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Nodes/SyncNodeCommand.cs @@ -0,0 +1,9 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Nodes; + +namespace PnvPanel.Application.Admin.Nodes; + +public sealed record SyncNodeCommand(Guid NodeId) : ICommand>; + +public sealed record SyncNodeResultDto(int InboundsSynced, NodeStatus Status); diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/SyncNodeCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Nodes/SyncNodeCommandHandler.cs new file mode 100644 index 0000000..32f0897 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Nodes/SyncNodeCommandHandler.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Inbounds; +using PnvPanel.Domain.Nodes; + +namespace PnvPanel.Application.Admin.Nodes; + +public sealed class SyncNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway) + : ICommandHandler> +{ + public async Task> Handle(SyncNodeCommand command, CancellationToken cancellationToken) + { + var node = await dbContext.Nodes.FirstOrDefaultAsync(n => n.Id == command.NodeId, cancellationToken); + if (node is null) + return Result.Failure(NodeErrors.NotFound); + + var remoteResult = await gateway.ListInboundsAsync(node, cancellationToken); + if (!remoteResult.IsSuccess) + { + node.UpdateStatus(NodeStatus.Offline); + return Result.Failure(remoteResult.Error); + } + + var existing = await dbContext.Inbounds.Where(i => i.NodeId == node.Id).ToListAsync(cancellationToken); + var existingByRemoteId = existing.ToDictionary(i => i.RemoteInboundId); + + foreach (var remote in remoteResult.Value) + { + if (existingByRemoteId.TryGetValue(remote.RemoteInboundId, out var inbound)) + inbound.UpdateFromRemote(remote.Protocol, remote.Remark, remote.Port); + else + dbContext.Inbounds.Add(Inbound.FromRemote(node.Id, remote.RemoteInboundId, remote.Protocol, remote.Remark, remote.Port)); + } + + // Inbound, пропавший на панели, снимаем с публикации (не удаляем — реконсиляция дрейфа, + // см. architecture.md); новые конфиги на нём создать будет нельзя, старые не трогаем. + var remoteIds = remoteResult.Value.Select(r => r.RemoteInboundId).ToHashSet(); + foreach (var stale in existing.Where(i => i.IsPublished && !remoteIds.Contains(i.RemoteInboundId))) + stale.Unpublish(); + + node.UpdateStatus(NodeStatus.Online); + node.MarkSynced(); + + return Result.Success(new SyncNodeResultDto(remoteResult.Value.Count, node.Status)); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommand.cs b/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommand.cs new file mode 100644 index 0000000..f67c93f --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommand.cs @@ -0,0 +1,8 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Nodes; + +public sealed record UpdateNodeCommand( + Guid NodeId, string Name, string? Location, bool IsEnabled, string? Username, string? Password) + : ICommand>; diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommandHandler.cs new file mode 100644 index 0000000..a78934e --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommandHandler.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Nodes; + +namespace PnvPanel.Application.Admin.Nodes; + +public sealed class UpdateNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway, ISecretProtector secretProtector) + : ICommandHandler> +{ + public async Task> Handle(UpdateNodeCommand command, CancellationToken cancellationToken) + { + var node = await dbContext.Nodes.FirstOrDefaultAsync(n => n.Id == command.NodeId, cancellationToken); + if (node is null) + return Result.Failure(NodeErrors.NotFound); + + node.UpdateDetails(command.Name, command.Location); + + if (command.IsEnabled) + node.Enable(); + else + node.Disable(); + + if (!string.IsNullOrWhiteSpace(command.Username) && !string.IsNullOrWhiteSpace(command.Password)) + { + node.UpdateCredentials(new NodeCredentials(command.Username, secretProtector.Protect(command.Password))); + gateway.InvalidateClient(node.Id); + } + + return Result.Success(NodeDto.FromDomain(node)); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommandValidator.cs new file mode 100644 index 0000000..a5c06d7 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Nodes/UpdateNodeCommandValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace PnvPanel.Application.Admin.Nodes; + +public sealed class UpdateNodeCommandValidator : AbstractValidator +{ + public UpdateNodeCommandValidator() + { + RuleFor(x => x.Name).NotEmpty().MaximumLength(100); + RuleFor(x => x.Location).MaximumLength(100); + RuleFor(x => x.Username).MaximumLength(200); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommand.cs b/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommand.cs new file mode 100644 index 0000000..134d630 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommand.cs @@ -0,0 +1,7 @@ +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Roles; + +public sealed record CreateRoleCommand(string Name, int MaxConfigs) : ICommand>; diff --git a/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandHandler.cs new file mode 100644 index 0000000..600cccf --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandHandler.cs @@ -0,0 +1,11 @@ +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Roles; + +public sealed class CreateRoleCommandHandler(IRoleService roleService) : ICommandHandler> +{ + public Task> Handle(CreateRoleCommand command, CancellationToken cancellationToken) + => roleService.CreateRoleAsync(command.Name, command.MaxConfigs, cancellationToken); +} diff --git a/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandValidator.cs new file mode 100644 index 0000000..5ddfb40 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandValidator.cs @@ -0,0 +1,16 @@ +using FluentValidation; + +namespace PnvPanel.Application.Admin.Roles; + +public sealed class CreateRoleCommandValidator : AbstractValidator +{ + public CreateRoleCommandValidator() + { + RuleFor(x => x.Name) + .NotEmpty() + .Length(2, 32) + .Matches("^[a-zA-Z0-9_-]+$"); + + RuleFor(x => x.MaxConfigs).GreaterThanOrEqualTo(-1); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Roles/DeleteRoleCommand.cs b/backend/src/PnvPanel.Application/Admin/Roles/DeleteRoleCommand.cs new file mode 100644 index 0000000..a67395f --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Roles/DeleteRoleCommand.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Roles; + +public sealed record DeleteRoleCommand(Guid RoleId) : ICommand; diff --git a/backend/src/PnvPanel.Application/Admin/Roles/DeleteRoleCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Roles/DeleteRoleCommandHandler.cs new file mode 100644 index 0000000..bb76045 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Roles/DeleteRoleCommandHandler.cs @@ -0,0 +1,11 @@ +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Roles; + +public sealed class DeleteRoleCommandHandler(IRoleService roleService) : ICommandHandler +{ + public Task Handle(DeleteRoleCommand command, CancellationToken cancellationToken) + => roleService.DeleteRoleAsync(command.RoleId, cancellationToken); +} diff --git a/backend/src/PnvPanel.Application/Admin/Roles/ListRolesQuery.cs b/backend/src/PnvPanel.Application/Admin/Roles/ListRolesQuery.cs new file mode 100644 index 0000000..80f2419 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Roles/ListRolesQuery.cs @@ -0,0 +1,7 @@ +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Roles; + +public sealed record ListRolesQuery : IQuery>>; diff --git a/backend/src/PnvPanel.Application/Admin/Roles/ListRolesQueryHandler.cs b/backend/src/PnvPanel.Application/Admin/Roles/ListRolesQueryHandler.cs new file mode 100644 index 0000000..6634db8 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Roles/ListRolesQueryHandler.cs @@ -0,0 +1,12 @@ +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Roles; + +public sealed class ListRolesQueryHandler(IRoleService roleService) + : IQueryHandler>> +{ + public async Task>> Handle(ListRolesQuery query, CancellationToken cancellationToken) + => Result.Success(await roleService.ListRolesAsync(cancellationToken)); +} diff --git a/backend/src/PnvPanel.Application/Admin/Roles/RoleErrors.cs b/backend/src/PnvPanel.Application/Admin/Roles/RoleErrors.cs new file mode 100644 index 0000000..55d12f6 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Roles/RoleErrors.cs @@ -0,0 +1,11 @@ +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Roles; + +public static class RoleErrors +{ + public static readonly Error NotFound = Error.NotFound("Roles.NotFound", "Роль не найдена."); + public static readonly Error DuplicateName = Error.Conflict("Roles.DuplicateName", "Роль с таким именем уже существует."); + public static readonly Error CannotModifySystemRole = Error.Forbidden("Roles.CannotModifySystemRole", "Системную роль нельзя удалить."); + public static readonly Error RoleInUse = Error.Conflict("Roles.RoleInUse", "Роль назначена пользователям — сначала переназначьте их."); +} diff --git a/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommand.cs b/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommand.cs new file mode 100644 index 0000000..79775e0 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommand.cs @@ -0,0 +1,7 @@ +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Roles; + +public sealed record UpdateRoleCommand(Guid RoleId, int MaxConfigs) : ICommand>; diff --git a/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandHandler.cs new file mode 100644 index 0000000..38e71f6 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandHandler.cs @@ -0,0 +1,11 @@ +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Roles; + +public sealed class UpdateRoleCommandHandler(IRoleService roleService) : ICommandHandler> +{ + public Task> Handle(UpdateRoleCommand command, CancellationToken cancellationToken) + => roleService.UpdateRoleAsync(command.RoleId, command.MaxConfigs, cancellationToken); +} diff --git a/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandValidator.cs new file mode 100644 index 0000000..ff5f251 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandValidator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace PnvPanel.Application.Admin.Roles; + +public sealed class UpdateRoleCommandValidator : AbstractValidator +{ + public UpdateRoleCommandValidator() + { + RuleFor(x => x.MaxConfigs).GreaterThanOrEqualTo(-1); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Users/ChangeUserRoleCommand.cs b/backend/src/PnvPanel.Application/Admin/Users/ChangeUserRoleCommand.cs new file mode 100644 index 0000000..6cbc2cd --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Users/ChangeUserRoleCommand.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Users; + +public sealed record ChangeUserRoleCommand(Guid UserId, Guid RoleId) : ICommand; diff --git a/backend/src/PnvPanel.Application/Admin/Users/ChangeUserRoleCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Users/ChangeUserRoleCommandHandler.cs new file mode 100644 index 0000000..aed80e5 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Users/ChangeUserRoleCommandHandler.cs @@ -0,0 +1,11 @@ +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Users; + +public sealed class ChangeUserRoleCommandHandler(IRoleService roleService) : ICommandHandler +{ + public Task Handle(ChangeUserRoleCommand command, CancellationToken cancellationToken) + => roleService.ChangeUserRoleAsync(command.UserId, command.RoleId, cancellationToken); +} diff --git a/backend/src/PnvPanel.Application/Admin/Users/UserErrors.cs b/backend/src/PnvPanel.Application/Admin/Users/UserErrors.cs new file mode 100644 index 0000000..6cb9654 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Users/UserErrors.cs @@ -0,0 +1,8 @@ +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Users; + +public static class UserErrors +{ + public static readonly Error NotFound = Error.NotFound("Users.NotFound", "Пользователь не найден."); +} diff --git a/backend/src/PnvPanel.Application/Apps/ClientAppDto.cs b/backend/src/PnvPanel.Application/Apps/ClientAppDto.cs new file mode 100644 index 0000000..5d66310 --- /dev/null +++ b/backend/src/PnvPanel.Application/Apps/ClientAppDto.cs @@ -0,0 +1,9 @@ +using PnvPanel.Domain.Apps; + +namespace PnvPanel.Application.Apps; + +public sealed record ClientAppDto(Guid Id, string Name, string DownloadUrl, string? Description, string? IconUrl) +{ + public static ClientAppDto FromDomain(ClientApp app) => + new(app.Id, app.Name, app.DownloadUrl.ToString(), app.Description, app.IconUrl); +} diff --git a/backend/src/PnvPanel.Application/Apps/ListAppsQuery.cs b/backend/src/PnvPanel.Application/Apps/ListAppsQuery.cs new file mode 100644 index 0000000..b529714 --- /dev/null +++ b/backend/src/PnvPanel.Application/Apps/ListAppsQuery.cs @@ -0,0 +1,7 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Apps; + +namespace PnvPanel.Application.Apps; + +public sealed record ListAppsQuery : IQuery>>>; diff --git a/backend/src/PnvPanel.Application/Apps/ListAppsQueryHandler.cs b/backend/src/PnvPanel.Application/Apps/ListAppsQueryHandler.cs new file mode 100644 index 0000000..df66f69 --- /dev/null +++ b/backend/src/PnvPanel.Application/Apps/ListAppsQueryHandler.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Apps; + +namespace PnvPanel.Application.Apps; + +public sealed class ListAppsQueryHandler(IAppDbContext dbContext) + : IQueryHandler>>> +{ + public async Task>>> Handle( + ListAppsQuery query, CancellationToken cancellationToken) + { + var apps = await dbContext.ClientApps.AsNoTracking() + .Where(a => a.IsEnabled) + .OrderBy(a => a.SortOrder) + .ToListAsync(cancellationToken); + + var grouped = apps + .GroupBy(a => a.OperatingSystem) + .ToDictionary( + g => g.Key, + g => (IReadOnlyList)g.Select(ClientAppDto.FromDomain).ToList()); + + return Result.Success>>(grouped); + } +} diff --git a/backend/src/PnvPanel.Application/Auth/AuthErrors.cs b/backend/src/PnvPanel.Application/Auth/AuthErrors.cs new file mode 100644 index 0000000..3e4afc8 --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/AuthErrors.cs @@ -0,0 +1,21 @@ +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Auth; + +public static class AuthErrors +{ + public static readonly Error DuplicateUserName = + Error.Conflict("Auth.DuplicateUserName", "Пользователь с таким именем уже существует."); + + public static readonly Error InvalidCredentials = + Error.Unauthorized("Auth.InvalidCredentials", "Неверное имя пользователя или пароль."); + + public static readonly Error LockedOut = + Error.Unauthorized("Auth.LockedOut", "Слишком много неудачных попыток входа. Попробуйте позже."); + + public static readonly Error InvalidRefreshToken = + Error.Unauthorized("Auth.InvalidRefreshToken", "Недействительный refresh-токен."); + + public static readonly Error Unauthorized = + Error.Unauthorized("Auth.Unauthorized", "Требуется аутентификация."); +} diff --git a/backend/src/PnvPanel.Application/Auth/AuthResult.cs b/backend/src/PnvPanel.Application/Auth/AuthResult.cs new file mode 100644 index 0000000..fe246c0 --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/AuthResult.cs @@ -0,0 +1,8 @@ +namespace PnvPanel.Application.Auth; + +public sealed record AuthResult( + string AccessToken, + DateTimeOffset AccessTokenExpiresAt, + string RefreshToken, + DateTimeOffset RefreshTokenExpiresAt, + CurrentUserDto User); diff --git a/backend/src/PnvPanel.Application/Auth/ChangePassword/ChangePasswordCommand.cs b/backend/src/PnvPanel.Application/Auth/ChangePassword/ChangePasswordCommand.cs new file mode 100644 index 0000000..122e399 --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/ChangePassword/ChangePasswordCommand.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Auth.ChangePassword; + +public sealed record ChangePasswordCommand(string CurrentPassword, string NewPassword) : ICommand; diff --git a/backend/src/PnvPanel.Application/Auth/ChangePassword/ChangePasswordCommandHandler.cs b/backend/src/PnvPanel.Application/Auth/ChangePassword/ChangePasswordCommandHandler.cs new file mode 100644 index 0000000..87b97a1 --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/ChangePassword/ChangePasswordCommandHandler.cs @@ -0,0 +1,17 @@ +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Auth.ChangePassword; + +public sealed class ChangePasswordCommandHandler(IIdentityService identityService, ICurrentUser currentUser) + : ICommandHandler +{ + public Task Handle(ChangePasswordCommand command, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return Task.FromResult(Result.Failure(AuthErrors.Unauthorized)); + + return identityService.ChangePasswordAsync(userId, command.CurrentPassword, command.NewPassword, cancellationToken); + } +} diff --git a/backend/src/PnvPanel.Application/Auth/ChangePassword/ChangePasswordCommandValidator.cs b/backend/src/PnvPanel.Application/Auth/ChangePassword/ChangePasswordCommandValidator.cs new file mode 100644 index 0000000..d8f6acd --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/ChangePassword/ChangePasswordCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace PnvPanel.Application.Auth.ChangePassword; + +public sealed class ChangePasswordCommandValidator : AbstractValidator +{ + public ChangePasswordCommandValidator() + { + RuleFor(x => x.CurrentPassword).NotEmpty(); + RuleFor(x => x.NewPassword).NotEmpty().MinimumLength(8); + } +} diff --git a/backend/src/PnvPanel.Application/Auth/CurrentUserDto.cs b/backend/src/PnvPanel.Application/Auth/CurrentUserDto.cs new file mode 100644 index 0000000..5d7276c --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/CurrentUserDto.cs @@ -0,0 +1,3 @@ +namespace PnvPanel.Application.Auth; + +public sealed record CurrentUserDto(Guid Id, string UserName, string Role, bool IsActivated); diff --git a/backend/src/PnvPanel.Application/Auth/DeleteMyAccount/DeleteMyAccountCommand.cs b/backend/src/PnvPanel.Application/Auth/DeleteMyAccount/DeleteMyAccountCommand.cs new file mode 100644 index 0000000..d76567e --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/DeleteMyAccount/DeleteMyAccountCommand.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Auth.DeleteMyAccount; + +public sealed record DeleteMyAccountCommand : ICommand; diff --git a/backend/src/PnvPanel.Application/Auth/DeleteMyAccount/DeleteMyAccountCommandHandler.cs b/backend/src/PnvPanel.Application/Auth/DeleteMyAccount/DeleteMyAccountCommandHandler.cs new file mode 100644 index 0000000..c8d3c9e --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/DeleteMyAccount/DeleteMyAccountCommandHandler.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Configs; + +namespace PnvPanel.Application.Auth.DeleteMyAccount; + +public sealed class DeleteMyAccountCommandHandler(IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway, ICurrentUser currentUser) + : ICommandHandler +{ + public async Task Handle(DeleteMyAccountCommand command, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return Result.Failure(AuthErrors.Unauthorized); + + var configs = await dbContext.VpnConfigs + .Where(c => c.UserId == userId && c.Status != ConfigStatus.Revoked) + .ToListAsync(cancellationToken); + + foreach (var config in configs) + { + 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(); + } + + await dbContext.SaveChangesAsync(cancellationToken); + + return await identityService.DeleteUserAsync(userId, cancellationToken); + } +} diff --git a/backend/src/PnvPanel.Application/Auth/Login/LoginCommand.cs b/backend/src/PnvPanel.Application/Auth/Login/LoginCommand.cs new file mode 100644 index 0000000..09a63a3 --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/Login/LoginCommand.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Auth.Login; + +public sealed record LoginCommand(string UserName, string Password) : ICommand>; diff --git a/backend/src/PnvPanel.Application/Auth/Login/LoginCommandHandler.cs b/backend/src/PnvPanel.Application/Auth/Login/LoginCommandHandler.cs new file mode 100644 index 0000000..1210a40 --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/Login/LoginCommandHandler.cs @@ -0,0 +1,35 @@ +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Auth.Login; + +public sealed class LoginCommandHandler( + IIdentityService identityService, + IJwtTokenService jwtTokenService, + IRefreshTokenService refreshTokenService) : ICommandHandler> +{ + public async Task> Handle(LoginCommand command, CancellationToken cancellationToken) + { + var credentialsResult = await identityService.ValidateCredentialsAsync(command.UserName, command.Password, cancellationToken); + if (!credentialsResult.IsSuccess) + return Result.Failure(credentialsResult.Error); + + var user = credentialsResult.Value; + var profile = await identityService.GetProfileAsync(user.Id, cancellationToken); + if (profile is null) + return Result.Failure(AuthErrors.InvalidCredentials); + + var (accessToken, accessExpiresAt) = jwtTokenService.GenerateAccessToken(user); + var refreshToken = await refreshTokenService.IssueAsync(user.Id, cancellationToken); + + var dto = new CurrentUserDto(profile.Id, profile.UserName, profile.Role, profile.IsActivated); + + return Result.Success(new AuthResult( + accessToken, + accessExpiresAt, + refreshToken.RawToken, + refreshToken.ExpiresAt, + dto)); + } +} diff --git a/backend/src/PnvPanel.Application/Auth/Login/LoginCommandValidator.cs b/backend/src/PnvPanel.Application/Auth/Login/LoginCommandValidator.cs new file mode 100644 index 0000000..f5090c1 --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/Login/LoginCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace PnvPanel.Application.Auth.Login; + +public sealed class LoginCommandValidator : AbstractValidator +{ + public LoginCommandValidator() + { + RuleFor(x => x.UserName).NotEmpty(); + RuleFor(x => x.Password).NotEmpty(); + } +} diff --git a/backend/src/PnvPanel.Application/Auth/Logout/LogoutCommand.cs b/backend/src/PnvPanel.Application/Auth/Logout/LogoutCommand.cs new file mode 100644 index 0000000..7a490cb --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/Logout/LogoutCommand.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Auth.Logout; + +public sealed record LogoutCommand(string RawRefreshToken) : ICommand; diff --git a/backend/src/PnvPanel.Application/Auth/Logout/LogoutCommandHandler.cs b/backend/src/PnvPanel.Application/Auth/Logout/LogoutCommandHandler.cs new file mode 100644 index 0000000..64bdf7a --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/Logout/LogoutCommandHandler.cs @@ -0,0 +1,15 @@ +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Auth.Logout; + +public sealed class LogoutCommandHandler(IRefreshTokenService refreshTokenService) + : ICommandHandler +{ + public async Task Handle(LogoutCommand command, CancellationToken cancellationToken) + { + await refreshTokenService.RevokeAsync(command.RawRefreshToken, cancellationToken); + return Result.Success(); + } +} diff --git a/backend/src/PnvPanel.Application/Auth/Me/GetCurrentUserQuery.cs b/backend/src/PnvPanel.Application/Auth/Me/GetCurrentUserQuery.cs new file mode 100644 index 0000000..d9f9b64 --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/Me/GetCurrentUserQuery.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Auth.Me; + +public sealed record GetCurrentUserQuery : IQuery>; diff --git a/backend/src/PnvPanel.Application/Auth/Me/GetCurrentUserQueryHandler.cs b/backend/src/PnvPanel.Application/Auth/Me/GetCurrentUserQueryHandler.cs new file mode 100644 index 0000000..6c39d19 --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/Me/GetCurrentUserQueryHandler.cs @@ -0,0 +1,21 @@ +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Auth.Me; + +public sealed class GetCurrentUserQueryHandler(IIdentityService identityService, ICurrentUser currentUser) + : IQueryHandler> +{ + public async Task> Handle(GetCurrentUserQuery query, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return Result.Failure(AuthErrors.Unauthorized); + + var profile = await identityService.GetProfileAsync(userId, cancellationToken); + if (profile is null) + return Result.Failure(AuthErrors.Unauthorized); + + return Result.Success(new CurrentUserDto(profile.Id, profile.UserName, profile.Role, profile.IsActivated)); + } +} diff --git a/backend/src/PnvPanel.Application/Auth/Refresh/RefreshCommand.cs b/backend/src/PnvPanel.Application/Auth/Refresh/RefreshCommand.cs new file mode 100644 index 0000000..c0c6ee5 --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/Refresh/RefreshCommand.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Auth.Refresh; + +public sealed record RefreshCommand(string RawRefreshToken) : ICommand>; diff --git a/backend/src/PnvPanel.Application/Auth/Refresh/RefreshCommandHandler.cs b/backend/src/PnvPanel.Application/Auth/Refresh/RefreshCommandHandler.cs new file mode 100644 index 0000000..a4ddc64 --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/Refresh/RefreshCommandHandler.cs @@ -0,0 +1,34 @@ +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Auth.Refresh; + +public sealed class RefreshCommandHandler( + IIdentityService identityService, + IJwtTokenService jwtTokenService, + IRefreshTokenService refreshTokenService) : ICommandHandler> +{ + public async Task> Handle(RefreshCommand command, CancellationToken cancellationToken) + { + var rotated = await refreshTokenService.RotateAsync(command.RawRefreshToken, cancellationToken); + if (!rotated.IsSuccess) + return Result.Failure(rotated.Error); + + var profile = await identityService.GetProfileAsync(rotated.Value.UserId, cancellationToken); + if (profile is null) + return Result.Failure(AuthErrors.InvalidRefreshToken); + + var authUser = new AuthenticatedUser(profile.Id, profile.UserName, profile.Role); + var (accessToken, accessExpiresAt) = jwtTokenService.GenerateAccessToken(authUser); + + var dto = new CurrentUserDto(profile.Id, profile.UserName, profile.Role, profile.IsActivated); + + return Result.Success(new AuthResult( + accessToken, + accessExpiresAt, + rotated.Value.RawToken, + rotated.Value.ExpiresAt, + dto)); + } +} diff --git a/backend/src/PnvPanel.Application/Auth/Refresh/RefreshCommandValidator.cs b/backend/src/PnvPanel.Application/Auth/Refresh/RefreshCommandValidator.cs new file mode 100644 index 0000000..9563969 --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/Refresh/RefreshCommandValidator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace PnvPanel.Application.Auth.Refresh; + +public sealed class RefreshCommandValidator : AbstractValidator +{ + public RefreshCommandValidator() + { + RuleFor(x => x.RawRefreshToken).NotEmpty(); + } +} diff --git a/backend/src/PnvPanel.Application/Auth/Register/RegisterCommand.cs b/backend/src/PnvPanel.Application/Auth/Register/RegisterCommand.cs new file mode 100644 index 0000000..a0481ea --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/Register/RegisterCommand.cs @@ -0,0 +1,8 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Auth.Register; + +public sealed record RegisterCommand(string UserName, string Password) : ICommand>; + +public sealed record RegisterResult(Guid Id, string UserName); diff --git a/backend/src/PnvPanel.Application/Auth/Register/RegisterCommandHandler.cs b/backend/src/PnvPanel.Application/Auth/Register/RegisterCommandHandler.cs new file mode 100644 index 0000000..54409f8 --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/Register/RegisterCommandHandler.cs @@ -0,0 +1,18 @@ +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Auth.Register; + +public sealed class RegisterCommandHandler(IIdentityService identityService) + : ICommandHandler> +{ + public async Task> Handle(RegisterCommand command, CancellationToken cancellationToken) + { + var result = await identityService.CreateUserAsync(command.UserName, command.Password, cancellationToken); + + return result.IsSuccess + ? Result.Success(new RegisterResult(result.Value, command.UserName)) + : Result.Failure(result.Error); + } +} diff --git a/backend/src/PnvPanel.Application/Auth/Register/RegisterCommandValidator.cs b/backend/src/PnvPanel.Application/Auth/Register/RegisterCommandValidator.cs new file mode 100644 index 0000000..a763e27 --- /dev/null +++ b/backend/src/PnvPanel.Application/Auth/Register/RegisterCommandValidator.cs @@ -0,0 +1,19 @@ +using FluentValidation; + +namespace PnvPanel.Application.Auth.Register; + +public sealed class RegisterCommandValidator : AbstractValidator +{ + public RegisterCommandValidator() + { + RuleFor(x => x.UserName) + .NotEmpty() + .Length(3, 32) + .Matches("^[a-zA-Z0-9_.-]+$") + .WithMessage("Имя пользователя может содержать только латиницу, цифры, '_', '.', '-'."); + + RuleFor(x => x.Password) + .NotEmpty() + .MinimumLength(8); + } +} diff --git a/backend/src/PnvPanel.Application/Common/Behaviors/LoggingBehavior.cs b/backend/src/PnvPanel.Application/Common/Behaviors/LoggingBehavior.cs new file mode 100644 index 0000000..7cb8660 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Behaviors/LoggingBehavior.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.Logging; +using PnvPanel.Application.Common.Messaging; + +namespace PnvPanel.Application.Common.Behaviors; + +public sealed class LoggingBehavior(ILogger> logger) + : IPipelineBehavior + where TRequest : notnull +{ + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + var requestName = typeof(TRequest).Name; + logger.LogInformation("Обработка {RequestName}", requestName); + + var response = await next(); + + logger.LogInformation("Обработан {RequestName}", requestName); + return response; + } +} diff --git a/backend/src/PnvPanel.Application/Common/Behaviors/UnitOfWorkBehavior.cs b/backend/src/PnvPanel.Application/Common/Behaviors/UnitOfWorkBehavior.cs new file mode 100644 index 0000000..e4953e0 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Behaviors/UnitOfWorkBehavior.cs @@ -0,0 +1,21 @@ +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; + +namespace PnvPanel.Application.Common.Behaviors; + +/// +/// Коммитит изменения после успешного выполнения команды. Применяется автоматически только +/// к запросам, реализующим — благодаря generic-ограничению +/// DI-контейнер не сможет сконструировать это поведение для запросов (IQuery). +/// +public sealed class UnitOfWorkBehavior(IAppDbContext dbContext) + : IPipelineBehavior + where TRequest : ICommand +{ + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + var response = await next(); + await dbContext.SaveChangesAsync(cancellationToken); + return response; + } +} diff --git a/backend/src/PnvPanel.Application/Common/Behaviors/ValidationBehavior.cs b/backend/src/PnvPanel.Application/Common/Behaviors/ValidationBehavior.cs new file mode 100644 index 0000000..f40d78e --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Behaviors/ValidationBehavior.cs @@ -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(IEnumerable> validators) + : IPipelineBehavior + where TRequest : notnull + where TResponse : Result +{ + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + if (!validators.Any()) + return await next(); + + var context = new ValidationContext(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])!; + } +} diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs new file mode 100644 index 0000000..2380c90 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs @@ -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 ActivationRequests { get; } + + DbSet Nodes { get; } + + DbSet Inbounds { get; } + + DbSet VpnConfigs { get; } + + DbSet ClientApps { get; } + + /// Нужен для advisory-lock при проверке квоты конфигов (см. CreateVpnConfigCommandHandler). + DatabaseFacade Database { get; } + + Task SaveChangesAsync(CancellationToken cancellationToken); +} diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/ICurrentUser.cs b/backend/src/PnvPanel.Application/Common/Interfaces/ICurrentUser.cs new file mode 100644 index 0000000..5918a9b --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Interfaces/ICurrentUser.cs @@ -0,0 +1,8 @@ +namespace PnvPanel.Application.Common.Interfaces; + +public interface ICurrentUser +{ + Guid? UserId { get; } + string? UserName { get; } + bool IsAuthenticated { get; } +} diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs new file mode 100644 index 0000000..2822b31 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IIdentityService.cs @@ -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> CreateUserAsync(string userName, string password, CancellationToken cancellationToken); + + Task> ValidateCredentialsAsync(string userName, string password, CancellationToken cancellationToken); + + Task GetProfileAsync(Guid userId, CancellationToken cancellationToken); + + Task ChangePasswordAsync(Guid userId, string currentPassword, string newPassword, CancellationToken cancellationToken); + + /// + /// Помечает пользователя активированным. Изменение не коммитится немедленно (в отличие от + /// CreateUserAsync/ChangePasswordAsync) — оно попадает в трекер того же DbContext и сохраняется + /// вместе с изменением ActivationRequest одной транзакцией через UnitOfWorkBehavior. + /// + Task ActivateUserAsync(Guid userId, Guid activatedBy, CancellationToken cancellationToken); + + Task> GetUserNamesAsync(IReadOnlyCollection userIds, CancellationToken cancellationToken); + + /// Удаляет аккаунт (самоудаление). Конфиги должны быть отозваны заранее вызывающей стороной. + Task DeleteUserAsync(Guid userId, CancellationToken cancellationToken); + + /// Для агрегированной подписки /sub/{userToken} (все активные конфиги пользователя). + Task FindUserIdBySubscriptionTokenAsync(string token, CancellationToken cancellationToken); +} diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IJwtTokenService.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IJwtTokenService.cs new file mode 100644 index 0000000..b4d0f21 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IJwtTokenService.cs @@ -0,0 +1,6 @@ +namespace PnvPanel.Application.Common.Interfaces; + +public interface IJwtTokenService +{ + (string AccessToken, DateTimeOffset ExpiresAt) GenerateAccessToken(AuthenticatedUser user); +} diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IRefreshTokenService.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IRefreshTokenService.cs new file mode 100644 index 0000000..78b3908 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IRefreshTokenService.cs @@ -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 IssueAsync(Guid userId, CancellationToken cancellationToken); + + Task> RotateAsync(string rawToken, CancellationToken cancellationToken); + + Task RevokeAsync(string rawToken, CancellationToken cancellationToken); +} diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IRoleService.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IRoleService.cs new file mode 100644 index 0000000..2f9ce64 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IRoleService.cs @@ -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> CreateRoleAsync(string name, int maxConfigs, CancellationToken cancellationToken); + + Task> UpdateRoleAsync(Guid roleId, int maxConfigs, CancellationToken cancellationToken); + + Task DeleteRoleAsync(Guid roleId, CancellationToken cancellationToken); + + Task> ListRolesAsync(CancellationToken cancellationToken); + + Task ChangeUserRoleAsync(Guid userId, Guid roleId, CancellationToken cancellationToken); +} diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/ISecretProtector.cs b/backend/src/PnvPanel.Application/Common/Interfaces/ISecretProtector.cs new file mode 100644 index 0000000..9b03706 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Interfaces/ISecretProtector.cs @@ -0,0 +1,9 @@ +namespace PnvPanel.Application.Common.Interfaces; + +/// Шифрование секретов at-rest (пароли нод). Реализация — ASP.NET Core Data Protection. +public interface ISecretProtector +{ + string Protect(string plaintext); + + string Unprotect(string protectedValue); +} diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IXuiPanelGateway.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IXuiPanelGateway.cs new file mode 100644 index 0000000..5cef6c3 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IXuiPanelGateway.cs @@ -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); + +/// +/// Оркестрация панелей 3x-ui через ThreeXui.Net. Один BaseAddress в библиотеке, но нод много — +/// реализация держит клиента per-node (кэш по NodeId), см. XuiPanelGateway. +/// +public interface IXuiPanelGateway +{ + Result ValidateBaseAddress(Uri baseAddress); + + Task ProbeAsync(Node node, CancellationToken cancellationToken); + + Task>> ListInboundsAsync(Node node, CancellationToken cancellationToken); + + void InvalidateClient(Guid nodeId); + + /// Возвращает ClientExternalId, присвоенный панелью (UUID для VLESS/VMess, пароль для Trojan/Shadowsocks). + Task> AddClientAsync( + Node node, string inboundRemoteId, VpnProtocol protocol, string clientEmail, string clientName, + int deviceLimit, CancellationToken cancellationToken); + + Task RemoveClientAsync( + Node node, string inboundRemoteId, string clientExternalId, VpnProtocol protocol, + CancellationToken cancellationToken); + + Task UpdateClientAsync( + Node node, string inboundRemoteId, string clientExternalId, VpnProtocol protocol, + string name, int deviceLimit, bool enable, CancellationToken cancellationToken); + + Task> BuildConnectionStringAsync( + Node node, Inbound inbound, string clientExternalId, string clientName, string publicHost, + CancellationToken cancellationToken); +} diff --git a/backend/src/PnvPanel.Application/Common/Messaging/ICommand.cs b/backend/src/PnvPanel.Application/Common/Messaging/ICommand.cs new file mode 100644 index 0000000..ae73398 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Messaging/ICommand.cs @@ -0,0 +1,4 @@ +namespace PnvPanel.Application.Common.Messaging; + +/// Маркер команды CQRS. Команды меняют состояние и идут в транзакции (см. UnitOfWorkBehavior). +public interface ICommand; diff --git a/backend/src/PnvPanel.Application/Common/Messaging/ICommandHandler.cs b/backend/src/PnvPanel.Application/Common/Messaging/ICommandHandler.cs new file mode 100644 index 0000000..963ee24 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Messaging/ICommandHandler.cs @@ -0,0 +1,6 @@ +namespace PnvPanel.Application.Common.Messaging; + +public interface ICommandHandler where TCommand : ICommand +{ + Task Handle(TCommand command, CancellationToken cancellationToken); +} diff --git a/backend/src/PnvPanel.Application/Common/Messaging/IPipelineBehavior.cs b/backend/src/PnvPanel.Application/Common/Messaging/IPipelineBehavior.cs new file mode 100644 index 0000000..dbbce6d --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Messaging/IPipelineBehavior.cs @@ -0,0 +1,8 @@ +namespace PnvPanel.Application.Common.Messaging; + +public delegate Task RequestHandlerDelegate(); + +public interface IPipelineBehavior +{ + Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken); +} diff --git a/backend/src/PnvPanel.Application/Common/Messaging/IQuery.cs b/backend/src/PnvPanel.Application/Common/Messaging/IQuery.cs new file mode 100644 index 0000000..b273f98 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Messaging/IQuery.cs @@ -0,0 +1,4 @@ +namespace PnvPanel.Application.Common.Messaging; + +/// Маркер запроса CQRS. Запросы только читают, без побочных эффектов. +public interface IQuery; diff --git a/backend/src/PnvPanel.Application/Common/Messaging/IQueryHandler.cs b/backend/src/PnvPanel.Application/Common/Messaging/IQueryHandler.cs new file mode 100644 index 0000000..fc8f6b9 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Messaging/IQueryHandler.cs @@ -0,0 +1,6 @@ +namespace PnvPanel.Application.Common.Messaging; + +public interface IQueryHandler where TQuery : IQuery +{ + Task Handle(TQuery query, CancellationToken cancellationToken); +} diff --git a/backend/src/PnvPanel.Application/Common/Messaging/ISender.cs b/backend/src/PnvPanel.Application/Common/Messaging/ISender.cs new file mode 100644 index 0000000..35484f8 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Messaging/ISender.cs @@ -0,0 +1,8 @@ +namespace PnvPanel.Application.Common.Messaging; + +/// Собственный тонкий CQRS-диспетчер (без MediatR). +public interface ISender +{ + Task Send(ICommand command, CancellationToken cancellationToken = default); + Task Send(IQuery query, CancellationToken cancellationToken = default); +} diff --git a/backend/src/PnvPanel.Application/Common/Messaging/Sender.cs b/backend/src/PnvPanel.Application/Common/Messaging/Sender.cs new file mode 100644 index 0000000..6a706f3 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Messaging/Sender.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace PnvPanel.Application.Common.Messaging; + +internal sealed class Sender(IServiceProvider serviceProvider) : ISender +{ + public Task Send(ICommand command, CancellationToken cancellationToken = default) + => Dispatch(command, typeof(ICommandHandler<,>), cancellationToken); + + public Task Send(IQuery query, CancellationToken cancellationToken = default) + => Dispatch(query, typeof(IQueryHandler<,>), cancellationToken); + + private Task Dispatch(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)serviceProvider.GetServices(behaviorType)).Reverse(); + + RequestHandlerDelegate pipeline = () => handler.Handle((dynamic)request, cancellationToken); + + foreach (dynamic behavior in behaviors) + { + var next = pipeline; + pipeline = () => behavior.Handle((dynamic)request, next, cancellationToken); + } + + return pipeline(); + } +} diff --git a/backend/src/PnvPanel.Application/Common/Models/Error.cs b/backend/src/PnvPanel.Application/Common/Models/Error.cs new file mode 100644 index 0000000..2487383 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Models/Error.cs @@ -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); +} diff --git a/backend/src/PnvPanel.Application/Common/Models/PagedList.cs b/backend/src/PnvPanel.Application/Common/Models/PagedList.cs new file mode 100644 index 0000000..58e402d --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Models/PagedList.cs @@ -0,0 +1,3 @@ +namespace PnvPanel.Application.Common.Models; + +public sealed record PagedList(IReadOnlyList Items, int Total, int Page, int PageSize); diff --git a/backend/src/PnvPanel.Application/Common/Models/PagedListExtensions.cs b/backend/src/PnvPanel.Application/Common/Models/PagedListExtensions.cs new file mode 100644 index 0000000..148e53d --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Models/PagedListExtensions.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; + +namespace PnvPanel.Application.Common.Models; + +public static class PagedListExtensions +{ + public static async Task> ToPagedListAsync( + this IQueryable 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(items, total, page, pageSize); + } +} diff --git a/backend/src/PnvPanel.Application/Common/Models/Result.cs b/backend/src/PnvPanel.Application/Common/Models/Result.cs new file mode 100644 index 0000000..533c306 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Models/Result.cs @@ -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 Success(T value) => new(value, true, Error.None); + public static Result Failure(Error error) => new(default, false, error); +} + +public class Result : 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 value) => Success(value); +} diff --git a/backend/src/PnvPanel.Application/Common/Models/RoleQuota.cs b/backend/src/PnvPanel.Application/Common/Models/RoleQuota.cs new file mode 100644 index 0000000..9f9a183 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Models/RoleQuota.cs @@ -0,0 +1,6 @@ +namespace PnvPanel.Application.Common.Models; + +public static class RoleQuota +{ + public const int Unlimited = -1; +} diff --git a/backend/src/PnvPanel.Application/Configs/ConfigErrors.cs b/backend/src/PnvPanel.Application/Configs/ConfigErrors.cs new file mode 100644 index 0000000..8a733e3 --- /dev/null +++ b/backend/src/PnvPanel.Application/Configs/ConfigErrors.cs @@ -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", "Конфиг не найден."); +} diff --git a/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommand.cs b/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommand.cs new file mode 100644 index 0000000..ae2bf6c --- /dev/null +++ b/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommand.cs @@ -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>; diff --git a/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs b/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs new file mode 100644 index 0000000..4b4e6d3 --- /dev/null +++ b/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs @@ -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> +{ + public async Task> Handle(CreateVpnConfigCommand command, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return Result.Failure(AuthErrors.Unauthorized); + + var profile = await identityService.GetProfileAsync(userId, cancellationToken); + if (profile is null) + return Result.Failure(AuthErrors.Unauthorized); + + if (!profile.IsActivated) + return Result.Failure(ConfigErrors.NotActivated); + + var inbound = await dbContext.Inbounds.AsNoTracking() + .FirstOrDefaultAsync(i => i.Id == command.InboundId, cancellationToken); + + if (inbound is null || !inbound.IsPublished) + return Result.Failure(ConfigErrors.InboundNotAvailable); + + if (!inbound.AllowedRoleIds.Contains(profile.RoleId)) + return Result.Failure(ConfigErrors.InboundNotAllowedForRole); + + var node = await dbContext.Nodes.AsNoTracking().FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken); + if (node is null || !node.IsEnabled) + return Result.Failure(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(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(addResult.Error); + } + + config.AssignRemoteClient(addResult.Value); + await dbContext.SaveChangesAsync(cancellationToken); + + return Result.Success(VpnConfigDto.FromDomain(config, inbound)); + } + + /// + /// Проверка квоты + резервирование строки — под pg_advisory_xact_lock (гонки параллельных + /// созданий, см. CLAUDE.md). Лок держится только на время короткой транзакции count+insert, + /// НЕ на время внешнего HTTP-вызова к 3x-ui — иначе рискуем держать соединение к БД открытым + /// на секунды под внешним I/O. + /// + private async Task 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(); + } +} diff --git a/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandValidator.cs b/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandValidator.cs new file mode 100644 index 0000000..2b62b36 --- /dev/null +++ b/backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace PnvPanel.Application.Configs.Create; + +public sealed class CreateVpnConfigCommandValidator : AbstractValidator +{ + public CreateVpnConfigCommandValidator() + { + RuleFor(x => x.InboundId).NotEmpty(); + RuleFor(x => x.Label).MaximumLength(100); + RuleFor(x => x.DeviceLimit).GreaterThanOrEqualTo(0).When(x => x.DeviceLimit.HasValue); + } +} diff --git a/backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommand.cs b/backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommand.cs new file mode 100644 index 0000000..0a44716 --- /dev/null +++ b/backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommand.cs @@ -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>; diff --git a/backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommandHandler.cs b/backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommandHandler.cs new file mode 100644 index 0000000..4a00e6b --- /dev/null +++ b/backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommandHandler.cs @@ -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> +{ + public async Task> Handle(EditVpnConfigCommand 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); + + var inbound = await dbContext.Inbounds.AsNoTracking() + .FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken); + if (inbound is null) + return Result.Failure(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)); + } +} diff --git a/backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommandValidator.cs b/backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommandValidator.cs new file mode 100644 index 0000000..48d5735 --- /dev/null +++ b/backend/src/PnvPanel.Application/Configs/Edit/EditVpnConfigCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace PnvPanel.Application.Configs.Edit; + +public sealed class EditVpnConfigCommandValidator : AbstractValidator +{ + public EditVpnConfigCommandValidator() + { + RuleFor(x => x.Label).MaximumLength(100); + RuleFor(x => x.DeviceLimit).GreaterThanOrEqualTo(0).When(x => x.DeviceLimit.HasValue); + } +} diff --git a/backend/src/PnvPanel.Application/Configs/GetConfigLink/GetConfigLinkQuery.cs b/backend/src/PnvPanel.Application/Configs/GetConfigLink/GetConfigLinkQuery.cs new file mode 100644 index 0000000..b73ecc1 --- /dev/null +++ b/backend/src/PnvPanel.Application/Configs/GetConfigLink/GetConfigLinkQuery.cs @@ -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>; + +/// SubscriptionToken — Api-слой строит из него абсолютный URL (знает scheme/host запроса). +public sealed record ConfigLinkDto(string ConnectionString, string SubscriptionToken); diff --git a/backend/src/PnvPanel.Application/Configs/GetConfigLink/GetConfigLinkQueryHandler.cs b/backend/src/PnvPanel.Application/Configs/GetConfigLink/GetConfigLinkQueryHandler.cs new file mode 100644 index 0000000..542ddeb --- /dev/null +++ b/backend/src/PnvPanel.Application/Configs/GetConfigLink/GetConfigLinkQueryHandler.cs @@ -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> +{ + public async Task> Handle(GetConfigLinkQuery query, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return Result.Failure(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(ConfigErrors.NotFound); + + var inbound = await dbContext.Inbounds.AsNoTracking().FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken); + if (inbound is null) + return Result.Failure(ConfigErrors.InboundNotAvailable); + + var node = await dbContext.Nodes.AsNoTracking().FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken); + if (node is null) + return Result.Failure(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(linkResult.Error); + + return Result.Success(new ConfigLinkDto(linkResult.Value, config.SubscriptionToken)); + } +} diff --git a/backend/src/PnvPanel.Application/Configs/GetMyConfigs/GetMyConfigsQuery.cs b/backend/src/PnvPanel.Application/Configs/GetMyConfigs/GetMyConfigsQuery.cs new file mode 100644 index 0000000..7098eb6 --- /dev/null +++ b/backend/src/PnvPanel.Application/Configs/GetMyConfigs/GetMyConfigsQuery.cs @@ -0,0 +1,8 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Configs.GetMyConfigs; + +public sealed record GetMyConfigsQuery : IQuery>; + +public sealed record GetMyConfigsResult(IReadOnlyList Configs, int MaxConfigs); diff --git a/backend/src/PnvPanel.Application/Configs/GetMyConfigs/GetMyConfigsQueryHandler.cs b/backend/src/PnvPanel.Application/Configs/GetMyConfigs/GetMyConfigsQueryHandler.cs new file mode 100644 index 0000000..d97b66e --- /dev/null +++ b/backend/src/PnvPanel.Application/Configs/GetMyConfigs/GetMyConfigsQueryHandler.cs @@ -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> +{ + public async Task> Handle(GetMyConfigsQuery query, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return Result.Failure(AuthErrors.Unauthorized); + + var profile = await identityService.GetProfileAsync(userId, cancellationToken); + if (profile is null) + return Result.Failure(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)); + } +} diff --git a/backend/src/PnvPanel.Application/Configs/ListAvailableInbounds/ListAvailableInboundsQuery.cs b/backend/src/PnvPanel.Application/Configs/ListAvailableInbounds/ListAvailableInboundsQuery.cs new file mode 100644 index 0000000..d531332 --- /dev/null +++ b/backend/src/PnvPanel.Application/Configs/ListAvailableInbounds/ListAvailableInboundsQuery.cs @@ -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>>; + +/// Витринная карточка инбаунда для выбора при создании конфига — без деталей 3x-ui. +public sealed record AvailableInboundDto(Guid InboundId, string DisplayName, VpnProtocol Protocol); diff --git a/backend/src/PnvPanel.Application/Configs/ListAvailableInbounds/ListAvailableInboundsQueryHandler.cs b/backend/src/PnvPanel.Application/Configs/ListAvailableInbounds/ListAvailableInboundsQueryHandler.cs new file mode 100644 index 0000000..387841c --- /dev/null +++ b/backend/src/PnvPanel.Application/Configs/ListAvailableInbounds/ListAvailableInboundsQueryHandler.cs @@ -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>> +{ + public async Task>> Handle(ListAvailableInboundsQuery query, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return Result.Failure>(AuthErrors.Unauthorized); + + var profile = await identityService.GetProfileAsync(userId, cancellationToken); + if (profile is null) + return Result.Failure>(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>(inbounds); + } +} diff --git a/backend/src/PnvPanel.Application/Configs/Revoke/RevokeVpnConfigCommand.cs b/backend/src/PnvPanel.Application/Configs/Revoke/RevokeVpnConfigCommand.cs new file mode 100644 index 0000000..28b87bf --- /dev/null +++ b/backend/src/PnvPanel.Application/Configs/Revoke/RevokeVpnConfigCommand.cs @@ -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; diff --git a/backend/src/PnvPanel.Application/Configs/Revoke/RevokeVpnConfigCommandHandler.cs b/backend/src/PnvPanel.Application/Configs/Revoke/RevokeVpnConfigCommandHandler.cs new file mode 100644 index 0000000..4e1270a --- /dev/null +++ b/backend/src/PnvPanel.Application/Configs/Revoke/RevokeVpnConfigCommandHandler.cs @@ -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 +{ + public async Task 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(); + } +} diff --git a/backend/src/PnvPanel.Application/Configs/Rotate/RotateVpnConfigCommand.cs b/backend/src/PnvPanel.Application/Configs/Rotate/RotateVpnConfigCommand.cs new file mode 100644 index 0000000..68327fb --- /dev/null +++ b/backend/src/PnvPanel.Application/Configs/Rotate/RotateVpnConfigCommand.cs @@ -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>; diff --git a/backend/src/PnvPanel.Application/Configs/Rotate/RotateVpnConfigCommandHandler.cs b/backend/src/PnvPanel.Application/Configs/Rotate/RotateVpnConfigCommandHandler.cs new file mode 100644 index 0000000..d3aa912 --- /dev/null +++ b/backend/src/PnvPanel.Application/Configs/Rotate/RotateVpnConfigCommandHandler.cs @@ -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> +{ + public async Task> Handle(RotateVpnConfigCommand 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.Active) + return Result.Failure(ConfigErrors.NotFound); + + var inbound = await dbContext.Inbounds.AsNoTracking() + .FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken); + if (inbound is null) + return Result.Failure(ConfigErrors.InboundNotAvailable); + + var node = await dbContext.Nodes.AsNoTracking().FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken); + if (node is null) + return Result.Failure(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(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)); + } +} diff --git a/backend/src/PnvPanel.Application/Configs/VpnConfigDto.cs b/backend/src/PnvPanel.Application/Configs/VpnConfigDto.cs new file mode 100644 index 0000000..05dec67 --- /dev/null +++ b/backend/src/PnvPanel.Application/Configs/VpnConfigDto.cs @@ -0,0 +1,17 @@ +using PnvPanel.Domain.Configs; +using PnvPanel.Domain.Inbounds; + +namespace PnvPanel.Application.Configs; + +/// +/// Пользователю показываем только DisplayName + протокол инбаунда — адрес/хост ноды и прочие +/// детали 3x-ui в этот DTO не попадают (см. domain-model.md). +/// +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); +} diff --git a/backend/src/PnvPanel.Application/DependencyInjection.cs b/backend/src/PnvPanel.Application/DependencyInjection.cs new file mode 100644 index 0000000..2c88066 --- /dev/null +++ b/backend/src/PnvPanel.Application/DependencyInjection.cs @@ -0,0 +1,44 @@ +using System.Reflection; +using FluentValidation; +using Microsoft.Extensions.DependencyInjection; +using PnvPanel.Application.Common.Behaviors; +using PnvPanel.Application.Common.Messaging; + +namespace PnvPanel.Application; + +/// +/// Точка регистрации сервисов слоя Application: собственный CQRS-диспетчер (ISender), +/// хендлеры/валидаторы (авто-сканирование сборки) и pipeline behaviors — в порядке выполнения. +/// +public static class DependencyInjection +{ + public static IServiceCollection AddApplication(this IServiceCollection services) + { + var assembly = typeof(DependencyInjection).Assembly; + + services.AddScoped(); + + RegisterClosedGeneric(services, assembly, typeof(ICommandHandler<,>)); + RegisterClosedGeneric(services, assembly, typeof(IQueryHandler<,>)); + RegisterClosedGeneric(services, assembly, typeof(IValidator<>)); + + // Порядок важен: Logging (снаружи) -> Validation -> UnitOfWork (ближе к хендлеру). + services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>)); + services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); + services.AddScoped(typeof(IPipelineBehavior<,>), typeof(UnitOfWorkBehavior<,>)); + + return services; + } + + private static void RegisterClosedGeneric(IServiceCollection services, Assembly assembly, Type openInterface) + { + var implementations = assembly.GetTypes() + .Where(t => t is { IsClass: true, IsAbstract: false }) + .SelectMany(t => t.GetInterfaces() + .Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == openInterface) + .Select(i => (Service: i, Implementation: t))); + + foreach (var (service, implementation) in implementations) + services.AddScoped(service, implementation); + } +} diff --git a/backend/src/PnvPanel.Application/PnvPanel.Application.csproj b/backend/src/PnvPanel.Application/PnvPanel.Application.csproj new file mode 100644 index 0000000..80636e6 --- /dev/null +++ b/backend/src/PnvPanel.Application/PnvPanel.Application.csproj @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/backend/src/PnvPanel.Application/Subscriptions/GetConfigSubscriptionQuery.cs b/backend/src/PnvPanel.Application/Subscriptions/GetConfigSubscriptionQuery.cs new file mode 100644 index 0000000..7749653 --- /dev/null +++ b/backend/src/PnvPanel.Application/Subscriptions/GetConfigSubscriptionQuery.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Subscriptions; + +public sealed record GetConfigSubscriptionQuery(string Token) : IQuery>; diff --git a/backend/src/PnvPanel.Application/Subscriptions/GetConfigSubscriptionQueryHandler.cs b/backend/src/PnvPanel.Application/Subscriptions/GetConfigSubscriptionQueryHandler.cs new file mode 100644 index 0000000..eaf9da1 --- /dev/null +++ b/backend/src/PnvPanel.Application/Subscriptions/GetConfigSubscriptionQueryHandler.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Configs; + +namespace PnvPanel.Application.Subscriptions; + +public sealed class GetConfigSubscriptionQueryHandler(IAppDbContext dbContext, IXuiPanelGateway gateway) + : IQueryHandler> +{ + public async Task> Handle(GetConfigSubscriptionQuery query, CancellationToken cancellationToken) + { + var config = await dbContext.VpnConfigs.AsNoTracking() + .FirstOrDefaultAsync(c => c.SubscriptionToken == query.Token && c.Status == ConfigStatus.Active, cancellationToken); + + if (config is null) + return Result.Failure(SubscriptionErrors.NotFound); + + return await SubscriptionAssembler.BuildAsync([config], dbContext, gateway, cancellationToken); + } +} diff --git a/backend/src/PnvPanel.Application/Subscriptions/GetUserSubscriptionQuery.cs b/backend/src/PnvPanel.Application/Subscriptions/GetUserSubscriptionQuery.cs new file mode 100644 index 0000000..612f620 --- /dev/null +++ b/backend/src/PnvPanel.Application/Subscriptions/GetUserSubscriptionQuery.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Subscriptions; + +public sealed record GetUserSubscriptionQuery(string Token) : IQuery>; diff --git a/backend/src/PnvPanel.Application/Subscriptions/GetUserSubscriptionQueryHandler.cs b/backend/src/PnvPanel.Application/Subscriptions/GetUserSubscriptionQueryHandler.cs new file mode 100644 index 0000000..bd64411 --- /dev/null +++ b/backend/src/PnvPanel.Application/Subscriptions/GetUserSubscriptionQueryHandler.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Configs; + +namespace PnvPanel.Application.Subscriptions; + +public sealed class GetUserSubscriptionQueryHandler(IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway) + : IQueryHandler> +{ + public async Task> Handle(GetUserSubscriptionQuery query, CancellationToken cancellationToken) + { + var userId = await identityService.FindUserIdBySubscriptionTokenAsync(query.Token, cancellationToken); + if (userId is null) + return Result.Failure(SubscriptionErrors.NotFound); + + var configs = await dbContext.VpnConfigs.AsNoTracking() + .Where(c => c.UserId == userId && c.Status == ConfigStatus.Active) + .ToListAsync(cancellationToken); + + return await SubscriptionAssembler.BuildAsync(configs, dbContext, gateway, cancellationToken); + } +} diff --git a/backend/src/PnvPanel.Application/Subscriptions/SubscriptionAssembler.cs b/backend/src/PnvPanel.Application/Subscriptions/SubscriptionAssembler.cs new file mode 100644 index 0000000..211658f --- /dev/null +++ b/backend/src/PnvPanel.Application/Subscriptions/SubscriptionAssembler.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Configs; + +namespace PnvPanel.Application.Subscriptions; + +/// Общая сборка подписки: и агрегированная (все конфиги юзера), и по одному конфигу. +internal static class SubscriptionAssembler +{ + public static async Task> BuildAsync( + IReadOnlyList configs, IAppDbContext dbContext, IXuiPanelGateway gateway, CancellationToken cancellationToken) + { + var lines = new List(); + long usedUp = 0; + long usedDown = 0; + DateTimeOffset? expiresAt = null; + + foreach (var config in configs) + { + var inbound = await dbContext.Inbounds.AsNoTracking() + .FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken); + if (inbound is null) + continue; + + var node = await dbContext.Nodes.AsNoTracking() + .FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken); + if (node is null || !node.IsEnabled) + continue; + + var linkResult = await gateway.BuildConnectionStringAsync( + node, inbound, config.ClientExternalId, config.Label ?? config.ClientEmail, node.BaseAddress.Host, cancellationToken); + + if (linkResult.IsSuccess) + lines.Add(linkResult.Value); + + usedUp += config.UsedUpBytes; + usedDown += config.UsedDownBytes; + if (config.ExpiresAt is { } exp && (expiresAt is null || exp > expiresAt)) + expiresAt = exp; + } + + return Result.Success(new SubscriptionDto(lines, usedUp, usedDown, expiresAt)); + } +} diff --git a/backend/src/PnvPanel.Application/Subscriptions/SubscriptionDto.cs b/backend/src/PnvPanel.Application/Subscriptions/SubscriptionDto.cs new file mode 100644 index 0000000..cbe3743 --- /dev/null +++ b/backend/src/PnvPanel.Application/Subscriptions/SubscriptionDto.cs @@ -0,0 +1,4 @@ +namespace PnvPanel.Application.Subscriptions; + +public sealed record SubscriptionDto( + IReadOnlyList ConnectionStrings, long UsedUpBytes, long UsedDownBytes, DateTimeOffset? ExpiresAt); diff --git a/backend/src/PnvPanel.Application/Subscriptions/SubscriptionErrors.cs b/backend/src/PnvPanel.Application/Subscriptions/SubscriptionErrors.cs new file mode 100644 index 0000000..ee80330 --- /dev/null +++ b/backend/src/PnvPanel.Application/Subscriptions/SubscriptionErrors.cs @@ -0,0 +1,8 @@ +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Subscriptions; + +public static class SubscriptionErrors +{ + public static readonly Error NotFound = Error.NotFound("Subscription.NotFound", "Подписка не найдена."); +} diff --git a/backend/src/PnvPanel.Domain/Activation/ActivationRequest.cs b/backend/src/PnvPanel.Domain/Activation/ActivationRequest.cs new file mode 100644 index 0000000..e1a037f --- /dev/null +++ b/backend/src/PnvPanel.Domain/Activation/ActivationRequest.cs @@ -0,0 +1,58 @@ +using PnvPanel.Domain.Common; +using PnvPanel.Domain.Exceptions; + +namespace PnvPanel.Domain.Activation; + +/// +/// Запрос пользователя на активацию (с комментарием), решение принимает админ на сайте или в Telegram. +/// Одновременно не более одного Pending-запроса на пользователя (инвариант проверяется на уровне Application). +/// +public sealed class ActivationRequest : Entity +{ + public Guid UserId { get; private set; } + public string? Comment { get; private set; } + public ActivationStatus Status { get; private set; } + public Guid? DecidedBy { get; private set; } + public DateTimeOffset? DecidedAt { get; private set; } + public string? RejectionReason { get; private set; } + public DateTimeOffset CreatedAt { get; private set; } + + private ActivationRequest() + { + } + + public static ActivationRequest Create(Guid userId, string? comment) + { + return new ActivationRequest + { + Id = Guid.NewGuid(), + UserId = userId, + Comment = comment, + Status = ActivationStatus.Pending, + CreatedAt = DateTimeOffset.UtcNow, + }; + } + + public void Approve(Guid decidedBy) + { + EnsurePending(); + Status = ActivationStatus.Approved; + DecidedBy = decidedBy; + DecidedAt = DateTimeOffset.UtcNow; + } + + public void Reject(Guid decidedBy, string? reason) + { + EnsurePending(); + Status = ActivationStatus.Rejected; + DecidedBy = decidedBy; + DecidedAt = DateTimeOffset.UtcNow; + RejectionReason = reason; + } + + private void EnsurePending() + { + if (Status != ActivationStatus.Pending) + throw new DomainException("Запрос на активацию уже обработан."); + } +} diff --git a/backend/src/PnvPanel.Domain/Activation/ActivationStatus.cs b/backend/src/PnvPanel.Domain/Activation/ActivationStatus.cs new file mode 100644 index 0000000..5cd92bf --- /dev/null +++ b/backend/src/PnvPanel.Domain/Activation/ActivationStatus.cs @@ -0,0 +1,8 @@ +namespace PnvPanel.Domain.Activation; + +public enum ActivationStatus +{ + Pending, + Approved, + Rejected, +} diff --git a/backend/src/PnvPanel.Domain/Apps/ClientApp.cs b/backend/src/PnvPanel.Domain/Apps/ClientApp.cs new file mode 100644 index 0000000..b9d8182 --- /dev/null +++ b/backend/src/PnvPanel.Domain/Apps/ClientApp.cs @@ -0,0 +1,45 @@ +using PnvPanel.Domain.Common; + +namespace PnvPanel.Domain.Apps; + +/// Каталог рекомендуемых приложений-клиентов для подключения, ведёт админ. +public sealed class ClientApp : Entity +{ + public string Name { get; private set; } = string.Empty; + public Uri DownloadUrl { get; private set; } = null!; + public OsPlatform OperatingSystem { get; private set; } + public string? Description { get; private set; } + public string? IconUrl { get; private set; } + public int SortOrder { get; private set; } + public bool IsEnabled { get; private set; } + + private ClientApp() + { + } + + public static ClientApp Create(string name, Uri downloadUrl, OsPlatform operatingSystem, string? description, string? iconUrl, int sortOrder) + { + return new ClientApp + { + Id = Guid.NewGuid(), + Name = name, + DownloadUrl = downloadUrl, + OperatingSystem = operatingSystem, + Description = description, + IconUrl = iconUrl, + SortOrder = sortOrder, + IsEnabled = true, + }; + } + + public void Update(string name, Uri downloadUrl, OsPlatform operatingSystem, string? description, string? iconUrl, int sortOrder, bool isEnabled) + { + Name = name; + DownloadUrl = downloadUrl; + OperatingSystem = operatingSystem; + Description = description; + IconUrl = iconUrl; + SortOrder = sortOrder; + IsEnabled = isEnabled; + } +} diff --git a/backend/src/PnvPanel.Domain/Apps/OsPlatform.cs b/backend/src/PnvPanel.Domain/Apps/OsPlatform.cs new file mode 100644 index 0000000..fabb7d1 --- /dev/null +++ b/backend/src/PnvPanel.Domain/Apps/OsPlatform.cs @@ -0,0 +1,10 @@ +namespace PnvPanel.Domain.Apps; + +public enum OsPlatform +{ + IOS, + Android, + Windows, + MacOS, + Linux, +} diff --git a/backend/src/PnvPanel.Domain/Common/Entity.cs b/backend/src/PnvPanel.Domain/Common/Entity.cs new file mode 100644 index 0000000..01ede97 --- /dev/null +++ b/backend/src/PnvPanel.Domain/Common/Entity.cs @@ -0,0 +1,14 @@ +namespace PnvPanel.Domain.Common; + +public abstract class Entity +{ + public Guid Id { get; protected init; } + + public override bool Equals(object? obj) => obj is Entity other && other.GetType() == GetType() && other.Id == Id; + + public override int GetHashCode() => HashCode.Combine(GetType(), Id); + + public static bool operator ==(Entity? left, Entity? right) => Equals(left, right); + + public static bool operator !=(Entity? left, Entity? right) => !Equals(left, right); +} diff --git a/backend/src/PnvPanel.Domain/Configs/ConfigStatus.cs b/backend/src/PnvPanel.Domain/Configs/ConfigStatus.cs new file mode 100644 index 0000000..161c09a --- /dev/null +++ b/backend/src/PnvPanel.Domain/Configs/ConfigStatus.cs @@ -0,0 +1,10 @@ +namespace PnvPanel.Domain.Configs; + +public enum ConfigStatus +{ + Active, + Disabled, + Expired, + LimitReached, + Revoked, +} diff --git a/backend/src/PnvPanel.Domain/Configs/VpnConfig.cs b/backend/src/PnvPanel.Domain/Configs/VpnConfig.cs new file mode 100644 index 0000000..1bee4f9 --- /dev/null +++ b/backend/src/PnvPanel.Domain/Configs/VpnConfig.cs @@ -0,0 +1,89 @@ +using System.Security.Cryptography; +using PnvPanel.Domain.Common; +using PnvPanel.Domain.Exceptions; +using PnvPanel.Domain.Inbounds; + +namespace PnvPanel.Domain.Configs; + +/// +/// Один конфиг = один клиент в 3x-ui, привязанный к пользователю. ClientExternalId — то, что +/// вернула панель при создании клиента (ThreeXui.Net отдаёт его как string — формат зависит от +/// протокола: UUID для VLESS/VMess, пароль для Trojan/Shadowsocks). +/// +public sealed class VpnConfig : Entity +{ + public Guid UserId { get; private set; } + public Guid InboundId { get; private set; } + public string? Label { get; private set; } + public string ClientEmail { get; private set; } = string.Empty; + public string ClientExternalId { get; private set; } = string.Empty; + public VpnProtocol Protocol { get; private set; } + public int DeviceLimit { get; private set; } + public long UsedUpBytes { get; private set; } + public long UsedDownBytes { get; private set; } + public DateTimeOffset? ExpiresAt { get; private set; } + public ConfigStatus Status { get; private set; } + public string SubscriptionToken { get; private set; } = string.Empty; + public DateTimeOffset? LastSyncAt { get; private set; } + public DateTimeOffset CreatedAt { get; private set; } + + private VpnConfig() + { + } + + public static VpnConfig Create(Guid userId, Guid inboundId, VpnProtocol protocol, string? label, int deviceLimit) + { + return new VpnConfig + { + Id = Guid.NewGuid(), + UserId = userId, + InboundId = inboundId, + Protocol = protocol, + ClientEmail = GenerateClientEmail(userId), + ClientExternalId = string.Empty, + Label = label, + DeviceLimit = deviceLimit, + Status = ConfigStatus.Active, + SubscriptionToken = GenerateToken(), + CreatedAt = DateTimeOffset.UtcNow, + }; + } + + /// Проставляется после успешного ответа от 3x-ui (см. IXuiPanelGateway.AddClientAsync). + public void AssignRemoteClient(string clientExternalId) => ClientExternalId = clientExternalId; + + public void Rename(string? label) => Label = label; + + public void SetDeviceLimit(int deviceLimit) => DeviceLimit = deviceLimit; + + public void Rotate(string newClientEmail, string newClientExternalId) + { + EnsureActive("перевыпустить"); + ClientEmail = newClientEmail; + ClientExternalId = newClientExternalId; + SubscriptionToken = GenerateToken(); + } + + public void Revoke() + { + if (Status == ConfigStatus.Revoked) + throw new DomainException("Конфиг уже отозван."); + + Status = ConfigStatus.Revoked; + } + + private void EnsureActive(string action) + { + if (Status != ConfigStatus.Active) + throw new DomainException($"Нельзя {action} конфиг в статусе {Status}."); + } + + public static string GenerateClientEmail(Guid userId) + { + var shortId = userId.ToString("N")[..8]; + var rand = Convert.ToHexString(RandomNumberGenerator.GetBytes(4)).ToLowerInvariant(); + return $"pnv_{shortId}_{rand}"; + } + + private static string GenerateToken() => Convert.ToHexString(RandomNumberGenerator.GetBytes(32)); +} diff --git a/backend/src/PnvPanel.Domain/Exceptions/DomainException.cs b/backend/src/PnvPanel.Domain/Exceptions/DomainException.cs new file mode 100644 index 0000000..55c6e3d --- /dev/null +++ b/backend/src/PnvPanel.Domain/Exceptions/DomainException.cs @@ -0,0 +1,4 @@ +namespace PnvPanel.Domain.Exceptions; + +/// Нарушение инварианта домена. На границе Application транслируется в Result-ошибку. +public class DomainException(string message) : Exception(message); diff --git a/backend/src/PnvPanel.Domain/Inbounds/Inbound.cs b/backend/src/PnvPanel.Domain/Inbounds/Inbound.cs new file mode 100644 index 0000000..ac372ae --- /dev/null +++ b/backend/src/PnvPanel.Domain/Inbounds/Inbound.cs @@ -0,0 +1,60 @@ +using PnvPanel.Domain.Common; + +namespace PnvPanel.Domain.Inbounds; + +/// +/// Проекция inbound из 3x-ui. — идентификатор на стороне панели +/// (в ThreeXui.Net это string, а не число — так отдаёт API 3x-ui). +/// AllowedRoleIds хранит только Guid ролей (не навигацию на AppRole — тот живёт в Infrastructure/Identity, +/// Domain не должен на него ссылаться). +/// +public sealed class Inbound : Entity +{ + public Guid NodeId { get; private set; } + public string RemoteInboundId { get; private set; } = string.Empty; + public VpnProtocol Protocol { get; private set; } + public string Remark { get; private set; } = string.Empty; + 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; } + + private Inbound() + { + } + + public static Inbound FromRemote(Guid nodeId, string remoteInboundId, VpnProtocol protocol, string remark, int port) + { + return new Inbound + { + Id = Guid.NewGuid(), + NodeId = nodeId, + RemoteInboundId = remoteInboundId, + Protocol = protocol, + Remark = remark, + Port = port, + IsPublished = false, + LastSyncAt = DateTimeOffset.UtcNow, + }; + } + + public void UpdateFromRemote(VpnProtocol protocol, string remark, int port) + { + Protocol = protocol; + Remark = remark; + Port = port; + LastSyncAt = DateTimeOffset.UtcNow; + } + + public void Publish(string? displayName, IReadOnlyCollection allowedRoleIds, int? maxClients) + { + IsPublished = true; + DisplayName = displayName; + AllowedRoleIds = allowedRoleIds.Distinct().ToList(); + MaxClients = maxClients; + } + + public void Unpublish() => IsPublished = false; +} diff --git a/backend/src/PnvPanel.Domain/Inbounds/VpnProtocol.cs b/backend/src/PnvPanel.Domain/Inbounds/VpnProtocol.cs new file mode 100644 index 0000000..4965388 --- /dev/null +++ b/backend/src/PnvPanel.Domain/Inbounds/VpnProtocol.cs @@ -0,0 +1,9 @@ +namespace PnvPanel.Domain.Inbounds; + +public enum VpnProtocol +{ + Vless, + Vmess, + Trojan, + Shadowsocks, +} diff --git a/backend/src/PnvPanel.Domain/Nodes/Node.cs b/backend/src/PnvPanel.Domain/Nodes/Node.cs new file mode 100644 index 0000000..50f40c2 --- /dev/null +++ b/backend/src/PnvPanel.Domain/Nodes/Node.cs @@ -0,0 +1,58 @@ +using PnvPanel.Domain.Common; +using PnvPanel.Domain.Exceptions; + +namespace PnvPanel.Domain.Nodes; + +/// +/// Подключённая администратором панель 3x-ui. Недоступность/выключение ноды блокирует только +/// новые конфиги — существующие клиенты в 3x-ui не трогаем. +/// +public sealed class Node : Entity +{ + public string Name { get; private set; } = string.Empty; + public Uri BaseAddress { get; private set; } = null!; + public NodeCredentials Credentials { get; private set; } = null!; + public string? Location { get; private set; } + public NodeStatus Status { get; private set; } + public bool IsEnabled { get; private set; } + public DateTimeOffset? LastSyncAt { get; private set; } + public DateTimeOffset CreatedAt { get; private set; } + + private Node() + { + } + + public static Node Register(string name, Uri baseAddress, NodeCredentials credentials, string? location) + { + if (!baseAddress.IsAbsoluteUri) + throw new DomainException("Адрес ноды должен быть абсолютным URI."); + + return new Node + { + Id = Guid.NewGuid(), + Name = name, + BaseAddress = baseAddress, + Credentials = credentials, + Location = location, + Status = NodeStatus.Unknown, + IsEnabled = true, + CreatedAt = DateTimeOffset.UtcNow, + }; + } + + public void UpdateDetails(string name, string? location) + { + Name = name; + Location = location; + } + + public void UpdateCredentials(NodeCredentials credentials) => Credentials = credentials; + + public void Enable() => IsEnabled = true; + + public void Disable() => IsEnabled = false; + + public void UpdateStatus(NodeStatus status) => Status = status; + + public void MarkSynced() => LastSyncAt = DateTimeOffset.UtcNow; +} diff --git a/backend/src/PnvPanel.Domain/Nodes/NodeCredentials.cs b/backend/src/PnvPanel.Domain/Nodes/NodeCredentials.cs new file mode 100644 index 0000000..6dfefeb --- /dev/null +++ b/backend/src/PnvPanel.Domain/Nodes/NodeCredentials.cs @@ -0,0 +1,10 @@ +namespace PnvPanel.Domain.Nodes; + +/// +/// Логин + зашифрованный пароль панели. Domain не знает о механизме шифрования (ISecretProtector — +/// порт Infrastructure); здесь хранится уже готовый шифротекст. +/// +public sealed record NodeCredentials(string Username, string ProtectedPassword) +{ + public override string ToString() => $"NodeCredentials {{ Username = {Username}, ProtectedPassword = [REDACTED] }}"; +} diff --git a/backend/src/PnvPanel.Domain/Nodes/NodeStatus.cs b/backend/src/PnvPanel.Domain/Nodes/NodeStatus.cs new file mode 100644 index 0000000..adcdace --- /dev/null +++ b/backend/src/PnvPanel.Domain/Nodes/NodeStatus.cs @@ -0,0 +1,8 @@ +namespace PnvPanel.Domain.Nodes; + +public enum NodeStatus +{ + Unknown, + Online, + Offline, +} diff --git a/backend/src/PnvPanel.Domain/PnvPanel.Domain.csproj b/backend/src/PnvPanel.Domain/PnvPanel.Domain.csproj new file mode 100644 index 0000000..6d36c6d --- /dev/null +++ b/backend/src/PnvPanel.Domain/PnvPanel.Domain.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/backend/src/PnvPanel.Infrastructure/DependencyInjection.cs b/backend/src/PnvPanel.Infrastructure/DependencyInjection.cs new file mode 100644 index 0000000..73fb77e --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/DependencyInjection.cs @@ -0,0 +1,102 @@ +using System.Text; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Infrastructure.Identity; +using PnvPanel.Infrastructure.Persistence; +using PnvPanel.Infrastructure.Security; +using PnvPanel.Infrastructure.Xui; +using ThreeXui.ConnectionStrings; +using ThreeXui.Http; + +namespace PnvPanel.Infrastructure; + +/// +/// Регистрация сервисов инфраструктуры: EF Core (PostgreSQL), Identity/JWT-аутентификация, +/// XuiPanelGateway, шифрование секретов, (в будущем) SignalR-пуш, фоновые сервисы. +/// +public static class DependencyInjection +{ + public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) + { + var connectionString = configuration["ConnectionStrings:Default"] + ?? throw new InvalidOperationException("Строка подключения 'ConnectionStrings:Default' не сконфигурирована."); + + services.AddDbContext(options => options.UseNpgsql(connectionString)); + services.AddScoped(sp => sp.GetRequiredService()); + + services + .AddIdentityCore(options => + { + options.User.RequireUniqueEmail = false; + options.Password.RequiredLength = 8; + options.Lockout.MaxFailedAccessAttempts = 5; + options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5); + options.Lockout.AllowedForNewUsers = true; + }) + .AddRoles() + .AddEntityFrameworkStores() + .AddSignInManager() + .AddDefaultTokenProviders(); + + services.Configure(configuration.GetSection(JwtOptions.SectionName)); + services.Configure(configuration.GetSection(AdminSeedOptions.SectionName)); + services.Configure(configuration.GetSection(RolesOptions.SectionName)); + + var jwtOptions = configuration.GetSection(JwtOptions.SectionName).Get() + ?? throw new InvalidOperationException("Секция конфигурации 'Jwt' не задана."); + + services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = jwtOptions.Issuer, + ValidateAudience = true, + ValidAudience = jwtOptions.Audience, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SigningKey)), + ValidateLifetime = true, + ClockSkew = TimeSpan.FromSeconds(30), + }; + }); + + services.AddAuthorization(); + + // Шифрование секретов нод at-rest (ASP.NET Core Data Protection). Key-ring — на постоянном + // томе (DataProtection__KeyRingPath), иначе секреты станут нечитаемы при пересоздании контейнера. + var keyRingPath = configuration["DataProtection:KeyRingPath"]; + var dataProtectionBuilder = services.AddDataProtection(); + if (!string.IsNullOrWhiteSpace(keyRingPath)) + dataProtectionBuilder.PersistKeysToFileSystem(new DirectoryInfo(keyRingPath)); + + services.AddSingleton(); + + // Построители connection string (по одному на протокол) + резолвер по имени протокола. + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + // Singleton: держит кэш per-node XUI-клиентов между запросами (см. XuiPanelGateway). + services.AddSingleton(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Identity/AdminSeedOptions.cs b/backend/src/PnvPanel.Infrastructure/Identity/AdminSeedOptions.cs new file mode 100644 index 0000000..78b439f --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Identity/AdminSeedOptions.cs @@ -0,0 +1,9 @@ +namespace PnvPanel.Infrastructure.Identity; + +public sealed class AdminSeedOptions +{ + public const string SectionName = "AdminSeed"; + + public string? Username { get; init; } + public string? Password { get; init; } +} diff --git a/backend/src/PnvPanel.Infrastructure/Identity/AppRole.cs b/backend/src/PnvPanel.Infrastructure/Identity/AppRole.cs new file mode 100644 index 0000000..c083e7d --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Identity/AppRole.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Identity; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Infrastructure.Identity; + +/// Роль с квотой на число конфигов. У пользователя ровно одна роль. +public class AppRole : IdentityRole +{ + public const int UnlimitedMaxConfigs = RoleQuota.Unlimited; + + public int MaxConfigs { get; set; } + public bool IsSystem { get; set; } + + public AppRole() + { + } + + public AppRole(string name) : base(name) + { + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Identity/AppUser.cs b/backend/src/PnvPanel.Infrastructure/Identity/AppUser.cs new file mode 100644 index 0000000..cd9f566 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Identity/AppUser.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Identity; + +namespace PnvPanel.Infrastructure.Identity; + +/// +/// Пользователь. Вход — по UserName; Email в системе не используется. +/// Активация ("может создавать конфиги") — отдельный флаг, назначаемый админом (см. M2). +/// +public class AppUser : IdentityUser +{ + public bool IsActivated { get; set; } + public DateTimeOffset? ActivatedAt { get; set; } + public Guid? ActivatedBy { get; set; } + + /// Секрет для агрегированной подписки /sub/{token} (все активные конфиги пользователя). + public string SubscriptionToken { get; set; } = string.Empty; +} diff --git a/backend/src/PnvPanel.Infrastructure/Identity/CurrentUser.cs b/backend/src/PnvPanel.Infrastructure/Identity/CurrentUser.cs new file mode 100644 index 0000000..e0e85d1 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Identity/CurrentUser.cs @@ -0,0 +1,23 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using PnvPanel.Application.Common.Interfaces; + +namespace PnvPanel.Infrastructure.Identity; + +internal sealed class CurrentUser(IHttpContextAccessor httpContextAccessor) : ICurrentUser +{ + private ClaimsPrincipal? Principal => httpContextAccessor.HttpContext?.User; + + public Guid? UserId + { + get + { + var value = Principal?.FindFirstValue(ClaimTypes.NameIdentifier); + return Guid.TryParse(value, out var id) ? id : null; + } + } + + public string? UserName => Principal?.FindFirstValue(ClaimTypes.Name); + + public bool IsAuthenticated => Principal?.Identity?.IsAuthenticated ?? false; +} diff --git a/backend/src/PnvPanel.Infrastructure/Identity/DbInitializer.cs b/backend/src/PnvPanel.Infrastructure/Identity/DbInitializer.cs new file mode 100644 index 0000000..1ca3e38 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Identity/DbInitializer.cs @@ -0,0 +1,112 @@ +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using PnvPanel.Domain.Apps; +using PnvPanel.Infrastructure.Persistence; + +namespace PnvPanel.Infrastructure.Identity; + +/// Идемпотентный сидинг: системные роли, учётка администратора из env, каталог приложений. +public sealed class DbInitializer( + RoleManager roleManager, + UserManager userManager, + AppDbContext dbContext, + IOptions adminSeedOptions, + IOptions rolesOptions, + ILogger logger) +{ + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; + + public async Task SeedAsync(CancellationToken cancellationToken = default) + { + await EnsureRoleAsync(RoleNames.Admin, AppRole.UnlimitedMaxConfigs, isSystem: true); + await EnsureRoleAsync(RoleNames.User, rolesOptions.Value.DefaultUserMaxConfigs, isSystem: true); + await SeedAdminAsync(); + await SeedClientAppsAsync(cancellationToken); + } + + private async Task EnsureRoleAsync(string name, int maxConfigs, bool isSystem) + { + if (await roleManager.RoleExistsAsync(name)) + return; + + var role = new AppRole(name) { MaxConfigs = maxConfigs, IsSystem = isSystem }; + var result = await roleManager.CreateAsync(role); + if (!result.Succeeded) + { + throw new InvalidOperationException( + $"Не удалось создать роль '{name}': {string.Join(", ", result.Errors.Select(e => e.Description))}"); + } + + logger.LogInformation("Создана системная роль {RoleName}", name); + } + + private async Task SeedAdminAsync() + { + var options = adminSeedOptions.Value; + if (string.IsNullOrWhiteSpace(options.Username) || string.IsNullOrWhiteSpace(options.Password)) + { + logger.LogWarning("AdminSeed__Username/AdminSeed__Password не заданы — учётка администратора не создана"); + return; + } + + if (await userManager.FindByNameAsync(options.Username) is not null) + return; + + var admin = new AppUser + { + UserName = options.Username, + IsActivated = true, + ActivatedAt = DateTimeOffset.UtcNow, + SubscriptionToken = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)), + }; + + var createResult = await userManager.CreateAsync(admin, options.Password); + if (!createResult.Succeeded) + { + throw new InvalidOperationException( + $"Не удалось создать администратора: {string.Join(", ", createResult.Errors.Select(e => e.Description))}"); + } + + await userManager.AddToRoleAsync(admin, RoleNames.Admin); + logger.LogInformation("Создана учётка администратора {Username}", options.Username); + } + + private async Task SeedClientAppsAsync(CancellationToken cancellationToken) + { + if (await dbContext.ClientApps.AnyAsync(cancellationToken)) + return; + + var path = Path.Combine(AppContext.BaseDirectory, "seed", "client-apps.json"); + if (!File.Exists(path)) + { + logger.LogWarning("Файл сида каталога приложений не найден: {Path}", path); + return; + } + + var json = await File.ReadAllTextAsync(path, cancellationToken); + var entries = JsonSerializer.Deserialize>(json, JsonOptions) ?? []; + + foreach (var entry in entries) + { + if (!Enum.TryParse(entry.OperatingSystem, ignoreCase: true, out var os)) + { + logger.LogWarning("Неизвестная ОС '{Os}' в сиде каталога приложений — пропущено", entry.OperatingSystem); + continue; + } + + var app = ClientApp.Create( + entry.Name, new Uri(entry.DownloadUrl, UriKind.Absolute), os, entry.Description, iconUrl: null, entry.SortOrder); + dbContext.ClientApps.Add(app); + } + + await dbContext.SaveChangesAsync(cancellationToken); + logger.LogInformation("Засеян каталог приложений: {Count} записей", entries.Count); + } + + private sealed record ClientAppSeedEntry( + string Name, string OperatingSystem, string DownloadUrl, string? Description, int SortOrder, bool IsEnabled); +} diff --git a/backend/src/PnvPanel.Infrastructure/Identity/DbInitializerExtensions.cs b/backend/src/PnvPanel.Infrastructure/Identity/DbInitializerExtensions.cs new file mode 100644 index 0000000..e1ed44a --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Identity/DbInitializerExtensions.cs @@ -0,0 +1,13 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace PnvPanel.Infrastructure.Identity; + +public static class DbInitializerExtensions +{ + public static async Task SeedDataAsync(this IServiceProvider services, CancellationToken cancellationToken = default) + { + await using var scope = services.CreateAsyncScope(); + var initializer = scope.ServiceProvider.GetRequiredService(); + await initializer.SeedAsync(cancellationToken); + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs b/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs new file mode 100644 index 0000000..ff9a8f3 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Identity/IdentityService.cs @@ -0,0 +1,135 @@ +using System.Security.Cryptography; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Auth; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Infrastructure.Identity; + +internal sealed class IdentityService(UserManager userManager, SignInManager signInManager, RoleManager roleManager) + : IIdentityService +{ + public async Task> CreateUserAsync(string userName, string password, CancellationToken cancellationToken) + { + var user = new AppUser + { + UserName = userName, + IsActivated = false, + SubscriptionToken = GenerateSubscriptionToken(), + }; + var createResult = await userManager.CreateAsync(user, password); + + if (!createResult.Succeeded) + { + return createResult.Errors.Any(e => e.Code == nameof(IdentityErrorDescriber.DuplicateUserName)) + ? Result.Failure(AuthErrors.DuplicateUserName) + : Result.Failure(Error.Validation( + "Auth.RegistrationFailed", + string.Join("; ", createResult.Errors.Select(e => e.Description)))); + } + + await userManager.AddToRoleAsync(user, RoleNames.User); + return Result.Success(user.Id); + } + + public async Task> ValidateCredentialsAsync(string userName, string password, CancellationToken cancellationToken) + { + var user = await userManager.FindByNameAsync(userName); + if (user is null) + return Result.Failure(AuthErrors.InvalidCredentials); + + var checkResult = await signInManager.CheckPasswordSignInAsync(user, password, lockoutOnFailure: true); + if (checkResult.IsLockedOut) + return Result.Failure(AuthErrors.LockedOut); + if (!checkResult.Succeeded) + return Result.Failure(AuthErrors.InvalidCredentials); + + var roleName = await GetPrimaryRoleNameAsync(user); + return Result.Success(new AuthenticatedUser(user.Id, user.UserName!, roleName)); + } + + public async Task GetProfileAsync(Guid userId, CancellationToken cancellationToken) + { + var user = await userManager.FindByIdAsync(userId.ToString()); + if (user is null) + return null; + + var role = await GetPrimaryRoleAsync(user); + return new CurrentUserProfile(user.Id, user.UserName!, role.Id, role.Name!, user.IsActivated, role.MaxConfigs); + } + + public async Task ChangePasswordAsync(Guid userId, string currentPassword, string newPassword, CancellationToken cancellationToken) + { + var user = await userManager.FindByIdAsync(userId.ToString()); + if (user is null) + return Result.Failure(AuthErrors.Unauthorized); + + var result = await userManager.ChangePasswordAsync(user, currentPassword, newPassword); + return result.Succeeded + ? Result.Success() + : Result.Failure(Error.Validation( + "Auth.PasswordChangeFailed", + string.Join("; ", result.Errors.Select(e => e.Description)))); + } + + public async Task ActivateUserAsync(Guid userId, Guid activatedBy, CancellationToken cancellationToken) + { + var user = await userManager.FindByIdAsync(userId.ToString()); + if (user is null) + return Result.Failure(AuthErrors.Unauthorized); + + // Мутируем уже отслеживаемую EF-сущность напрямую, без UserManager.UpdateAsync (который + // закоммитил бы немедленно) — изменение попадёт в общий SaveChanges вместе с ActivationRequest. + user.IsActivated = true; + user.ActivatedAt = DateTimeOffset.UtcNow; + user.ActivatedBy = activatedBy; + + return Result.Success(); + } + + public async Task> GetUserNamesAsync(IReadOnlyCollection userIds, CancellationToken cancellationToken) + { + if (userIds.Count == 0) + return new Dictionary(); + + return await userManager.Users + .Where(u => userIds.Contains(u.Id)) + .ToDictionaryAsync(u => u.Id, u => u.UserName!, cancellationToken); + } + + public async Task DeleteUserAsync(Guid userId, CancellationToken cancellationToken) + { + var user = await userManager.FindByIdAsync(userId.ToString()); + if (user is null) + return Result.Failure(AuthErrors.Unauthorized); + + var result = await userManager.DeleteAsync(user); + return result.Succeeded + ? Result.Success() + : Result.Failure(Error.Failure( + "Auth.DeleteAccountFailed", string.Join("; ", result.Errors.Select(e => e.Description)))); + } + + public async Task FindUserIdBySubscriptionTokenAsync(string token, CancellationToken cancellationToken) + { + var user = await userManager.Users.AsNoTracking() + .FirstOrDefaultAsync(u => u.SubscriptionToken == token, cancellationToken); + return user?.Id; + } + + private async Task GetPrimaryRoleNameAsync(AppUser user) + { + var roles = await userManager.GetRolesAsync(user); + return roles.FirstOrDefault() ?? RoleNames.User; + } + + private async Task GetPrimaryRoleAsync(AppUser user) + { + var roleName = await GetPrimaryRoleNameAsync(user); + return await roleManager.FindByNameAsync(roleName) + ?? throw new InvalidOperationException($"Роль '{roleName}' не найдена."); + } + + private static string GenerateSubscriptionToken() => Convert.ToHexString(RandomNumberGenerator.GetBytes(32)); +} diff --git a/backend/src/PnvPanel.Infrastructure/Identity/JwtOptions.cs b/backend/src/PnvPanel.Infrastructure/Identity/JwtOptions.cs new file mode 100644 index 0000000..f8ce3e2 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Identity/JwtOptions.cs @@ -0,0 +1,12 @@ +namespace PnvPanel.Infrastructure.Identity; + +public sealed class JwtOptions +{ + public const string SectionName = "Jwt"; + + public string Issuer { get; init; } = string.Empty; + public string Audience { get; init; } = string.Empty; + public string SigningKey { get; init; } = string.Empty; + public int AccessTokenMinutes { get; init; } = 15; + public int RefreshTokenDays { get; init; } = 30; +} diff --git a/backend/src/PnvPanel.Infrastructure/Identity/JwtTokenService.cs b/backend/src/PnvPanel.Infrastructure/Identity/JwtTokenService.cs new file mode 100644 index 0000000..4d03e40 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Identity/JwtTokenService.cs @@ -0,0 +1,39 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using PnvPanel.Application.Common.Interfaces; + +namespace PnvPanel.Infrastructure.Identity; + +internal sealed class JwtTokenService(IOptions options) : IJwtTokenService +{ + private readonly JwtOptions _options = options.Value; + + public (string AccessToken, DateTimeOffset ExpiresAt) GenerateAccessToken(AuthenticatedUser user) + { + var expiresAt = DateTimeOffset.UtcNow.AddMinutes(_options.AccessTokenMinutes); + + Claim[] claims = + [ + new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()), + new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), + new Claim(ClaimTypes.Name, user.UserName), + new Claim(ClaimTypes.Role, user.Role), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + ]; + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SigningKey)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: _options.Issuer, + audience: _options.Audience, + claims: claims, + expires: expiresAt.UtcDateTime, + signingCredentials: credentials); + + return (new JwtSecurityTokenHandler().WriteToken(token), expiresAt); + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Identity/RefreshToken.cs b/backend/src/PnvPanel.Infrastructure/Identity/RefreshToken.cs new file mode 100644 index 0000000..bc4c313 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Identity/RefreshToken.cs @@ -0,0 +1,16 @@ +namespace PnvPanel.Infrastructure.Identity; + +/// +/// Хранится только хэш токена (см. RefreshTokenService). Ротация при использовании: +/// старый токен помечается RevokedAt + ReplacedByTokenHash, выпускается новый. +/// +public class RefreshToken +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + public string TokenHash { get; set; } = string.Empty; + public DateTimeOffset ExpiresAt { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset? RevokedAt { get; set; } + public string? ReplacedByTokenHash { get; set; } +} diff --git a/backend/src/PnvPanel.Infrastructure/Identity/RefreshTokenService.cs b/backend/src/PnvPanel.Infrastructure/Identity/RefreshTokenService.cs new file mode 100644 index 0000000..48a1097 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Identity/RefreshTokenService.cs @@ -0,0 +1,99 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using PnvPanel.Application.Auth; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Models; +using PnvPanel.Infrastructure.Persistence; + +namespace PnvPanel.Infrastructure.Identity; + +internal sealed class RefreshTokenService(AppDbContext dbContext, IOptions options) : IRefreshTokenService +{ + private readonly JwtOptions _options = options.Value; + + public async Task IssueAsync(Guid userId, CancellationToken cancellationToken) + { + var rawToken = GenerateRawToken(); + var expiresAt = DateTimeOffset.UtcNow.AddDays(_options.RefreshTokenDays); + + dbContext.RefreshTokens.Add(new RefreshToken + { + Id = Guid.NewGuid(), + UserId = userId, + TokenHash = Hash(rawToken), + ExpiresAt = expiresAt, + CreatedAt = DateTimeOffset.UtcNow, + }); + await dbContext.SaveChangesAsync(cancellationToken); + + return new IssuedRefreshToken(rawToken, expiresAt); + } + + public async Task> RotateAsync(string rawToken, CancellationToken cancellationToken) + { + var hash = Hash(rawToken); + var existing = await dbContext.RefreshTokens.SingleOrDefaultAsync(t => t.TokenHash == hash, cancellationToken); + + if (existing is null) + return Result.Failure(AuthErrors.InvalidRefreshToken); + + if (existing.RevokedAt is not null) + { + // Повторное использование уже отозванного токена — признак компрометации: гасим все токены пользователя. + await RevokeAllForUserAsync(existing.UserId, cancellationToken); + return Result.Failure(AuthErrors.InvalidRefreshToken); + } + + if (existing.ExpiresAt <= DateTimeOffset.UtcNow) + return Result.Failure(AuthErrors.InvalidRefreshToken); + + var newRawToken = GenerateRawToken(); + var newExpiresAt = DateTimeOffset.UtcNow.AddDays(_options.RefreshTokenDays); + var newHash = Hash(newRawToken); + + existing.RevokedAt = DateTimeOffset.UtcNow; + existing.ReplacedByTokenHash = newHash; + + dbContext.RefreshTokens.Add(new RefreshToken + { + Id = Guid.NewGuid(), + UserId = existing.UserId, + TokenHash = newHash, + ExpiresAt = newExpiresAt, + CreatedAt = DateTimeOffset.UtcNow, + }); + + await dbContext.SaveChangesAsync(cancellationToken); + + return Result.Success(new RotatedRefreshToken(existing.UserId, newRawToken, newExpiresAt)); + } + + public async Task RevokeAsync(string rawToken, CancellationToken cancellationToken) + { + var hash = Hash(rawToken); + var existing = await dbContext.RefreshTokens.SingleOrDefaultAsync(t => t.TokenHash == hash, cancellationToken); + if (existing is null || existing.RevokedAt is not null) + return; + + existing.RevokedAt = DateTimeOffset.UtcNow; + await dbContext.SaveChangesAsync(cancellationToken); + } + + private async Task RevokeAllForUserAsync(Guid userId, CancellationToken cancellationToken) + { + var activeTokens = await dbContext.RefreshTokens + .Where(t => t.UserId == userId && t.RevokedAt == null) + .ToListAsync(cancellationToken); + + foreach (var token in activeTokens) + token.RevokedAt = DateTimeOffset.UtcNow; + + await dbContext.SaveChangesAsync(cancellationToken); + } + + private static string GenerateRawToken() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(64)); + + private static string Hash(string rawToken) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(rawToken))); +} diff --git a/backend/src/PnvPanel.Infrastructure/Identity/RoleNames.cs b/backend/src/PnvPanel.Infrastructure/Identity/RoleNames.cs new file mode 100644 index 0000000..713be26 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Identity/RoleNames.cs @@ -0,0 +1,8 @@ +namespace PnvPanel.Infrastructure.Identity; + +/// Системные роли — не удаляются и не переименовываются. +public static class RoleNames +{ + public const string Admin = "admin"; + public const string User = "user"; +} diff --git a/backend/src/PnvPanel.Infrastructure/Identity/RoleService.cs b/backend/src/PnvPanel.Infrastructure/Identity/RoleService.cs new file mode 100644 index 0000000..5eef3af --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Identity/RoleService.cs @@ -0,0 +1,84 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Admin.Roles; +using PnvPanel.Application.Admin.Users; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Infrastructure.Identity; + +internal sealed class RoleService(RoleManager roleManager, UserManager userManager) : IRoleService +{ + public async Task> CreateRoleAsync(string name, int maxConfigs, CancellationToken cancellationToken) + { + if (await roleManager.RoleExistsAsync(name)) + return Result.Failure(RoleErrors.DuplicateName); + + var role = new AppRole(name) { MaxConfigs = maxConfigs, IsSystem = false }; + var result = await roleManager.CreateAsync(role); + if (!result.Succeeded) + { + return Result.Failure(Error.Validation( + "Roles.CreateFailed", string.Join("; ", result.Errors.Select(e => e.Description)))); + } + + return Result.Success(ToDto(role)); + } + + public async Task> UpdateRoleAsync(Guid roleId, int maxConfigs, CancellationToken cancellationToken) + { + var role = await roleManager.FindByIdAsync(roleId.ToString()); + if (role is null) + return Result.Failure(RoleErrors.NotFound); + + role.MaxConfigs = maxConfigs; + await roleManager.UpdateAsync(role); + + return Result.Success(ToDto(role)); + } + + public async Task DeleteRoleAsync(Guid roleId, CancellationToken cancellationToken) + { + var role = await roleManager.FindByIdAsync(roleId.ToString()); + if (role is null) + return Result.Failure(RoleErrors.NotFound); + + if (role.IsSystem) + return Result.Failure(RoleErrors.CannotModifySystemRole); + + var usersInRole = await userManager.GetUsersInRoleAsync(role.Name!); + if (usersInRole.Count > 0) + return Result.Failure(RoleErrors.RoleInUse); + + await roleManager.DeleteAsync(role); + return Result.Success(); + } + + public async Task> ListRolesAsync(CancellationToken cancellationToken) + { + return await roleManager.Roles + .OrderBy(r => r.Name) + .Select(r => new RoleDto(r.Id, r.Name!, r.MaxConfigs, r.IsSystem)) + .ToListAsync(cancellationToken); + } + + public async Task ChangeUserRoleAsync(Guid userId, Guid roleId, CancellationToken cancellationToken) + { + var user = await userManager.FindByIdAsync(userId.ToString()); + if (user is null) + return Result.Failure(UserErrors.NotFound); + + var role = await roleManager.FindByIdAsync(roleId.ToString()); + if (role is null) + return Result.Failure(RoleErrors.NotFound); + + var currentRoles = await userManager.GetRolesAsync(user); + if (currentRoles.Count > 0) + await userManager.RemoveFromRolesAsync(user, currentRoles); + + await userManager.AddToRoleAsync(user, role.Name!); + return Result.Success(); + } + + private static RoleDto ToDto(AppRole role) => new(role.Id, role.Name!, role.MaxConfigs, role.IsSystem); +} diff --git a/backend/src/PnvPanel.Infrastructure/Identity/RolesOptions.cs b/backend/src/PnvPanel.Infrastructure/Identity/RolesOptions.cs new file mode 100644 index 0000000..f44c205 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Identity/RolesOptions.cs @@ -0,0 +1,8 @@ +namespace PnvPanel.Infrastructure.Identity; + +public sealed class RolesOptions +{ + public const string SectionName = "Roles"; + + public int DefaultUserMaxConfigs { get; init; } = 3; +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs b/backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs new file mode 100644 index 0000000..4e18d9a --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Domain.Activation; +using PnvPanel.Domain.Apps; +using PnvPanel.Domain.Configs; +using PnvPanel.Domain.Inbounds; +using PnvPanel.Domain.Nodes; +using PnvPanel.Infrastructure.Identity; + +namespace PnvPanel.Infrastructure.Persistence; + +/// +/// Корневой DbContext приложения: Identity-схема (пользователи/роли) + сущности домена +/// (добавляются по мере реализации фич, см. docs/roadmap.md). +/// +public class AppDbContext(DbContextOptions options) + : IdentityDbContext(options), IAppDbContext +{ + public DbSet RefreshTokens => Set(); + + public DbSet ActivationRequests => Set(); + + public DbSet Nodes => Set(); + + public DbSet Inbounds => Set(); + + public DbSet VpnConfigs => Set(); + + public DbSet ClientApps => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/ActivationRequestConfiguration.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/ActivationRequestConfiguration.cs new file mode 100644 index 0000000..81ea595 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/ActivationRequestConfiguration.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PnvPanel.Domain.Activation; + +namespace PnvPanel.Infrastructure.Persistence.Configurations; + +public class ActivationRequestConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("ActivationRequests"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.Comment).HasMaxLength(500); + builder.Property(x => x.RejectionReason).HasMaxLength(500); + builder.Property(x => x.Status).HasConversion().HasMaxLength(32); + + builder.HasIndex(x => new { x.UserId, x.Status }); + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/ClientAppConfiguration.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/ClientAppConfiguration.cs new file mode 100644 index 0000000..01b28bf --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/ClientAppConfiguration.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PnvPanel.Domain.Apps; + +namespace PnvPanel.Infrastructure.Persistence.Configurations; + +public class ClientAppConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("ClientApps"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(100); + + builder.Property(x => x.DownloadUrl) + .IsRequired() + .HasMaxLength(500) + .HasConversion(uri => uri.ToString(), s => new Uri(s, UriKind.Absolute)); + + builder.Property(x => x.OperatingSystem).HasConversion().HasMaxLength(32); + builder.Property(x => x.Description).HasMaxLength(300); + builder.Property(x => x.IconUrl).HasMaxLength(500); + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/InboundConfiguration.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/InboundConfiguration.cs new file mode 100644 index 0000000..551a50c --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/InboundConfiguration.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PnvPanel.Domain.Inbounds; + +namespace PnvPanel.Infrastructure.Persistence.Configurations; + +public class InboundConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Inbounds"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.RemoteInboundId).IsRequired().HasMaxLength(64); + builder.Property(x => x.Protocol).HasConversion().HasMaxLength(32); + builder.Property(x => x.Remark).IsRequired().HasMaxLength(200); + builder.Property(x => x.DisplayName).HasMaxLength(100); + + builder.Property(x => x.AllowedRoleIds).HasColumnType("uuid[]"); + + builder.HasIndex(x => new { x.NodeId, x.RemoteInboundId }).IsUnique(); + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/NodeConfiguration.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/NodeConfiguration.cs new file mode 100644 index 0000000..d02ddd8 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/NodeConfiguration.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PnvPanel.Domain.Nodes; + +namespace PnvPanel.Infrastructure.Persistence.Configurations; + +public class NodeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Nodes"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.Name).IsRequired().HasMaxLength(100); + + builder.Property(x => x.BaseAddress) + .IsRequired() + .HasMaxLength(500) + .HasConversion(uri => uri.ToString(), s => new Uri(s, UriKind.Absolute)); + + builder.Property(x => x.Location).HasMaxLength(100); + builder.Property(x => x.Status).HasConversion().HasMaxLength(32); + + builder.OwnsOne(x => x.Credentials, credentials => + { + credentials.Property(c => c.Username).HasColumnName("CredentialsUsername").IsRequired().HasMaxLength(200); + credentials.Property(c => c.ProtectedPassword).HasColumnName("CredentialsProtectedPassword").IsRequired(); + }); + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs new file mode 100644 index 0000000..ad88d83 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PnvPanel.Infrastructure.Identity; + +namespace PnvPanel.Infrastructure.Persistence.Configurations; + +public class RefreshTokenConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("RefreshTokens"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.TokenHash) + .IsRequired() + .HasMaxLength(128); + + builder.HasIndex(x => x.TokenHash).IsUnique(); + builder.HasIndex(x => x.UserId); + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/VpnConfigConfiguration.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/VpnConfigConfiguration.cs new file mode 100644 index 0000000..8220bf2 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/VpnConfigConfiguration.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PnvPanel.Domain.Configs; + +namespace PnvPanel.Infrastructure.Persistence.Configurations; + +public class VpnConfigConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("VpnConfigs"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.Label).HasMaxLength(100); + builder.Property(x => x.ClientEmail).IsRequired().HasMaxLength(200); + builder.Property(x => x.ClientExternalId).IsRequired().HasMaxLength(200); + builder.Property(x => x.Protocol).HasConversion().HasMaxLength(32); + builder.Property(x => x.Status).HasConversion().HasMaxLength(32); + builder.Property(x => x.SubscriptionToken).IsRequired().HasMaxLength(128); + + builder.HasIndex(x => x.SubscriptionToken).IsUnique(); + builder.HasIndex(x => new { x.UserId, x.Status }); + builder.HasIndex(x => x.InboundId); + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/MigrationExtensions.cs b/backend/src/PnvPanel.Infrastructure/Persistence/MigrationExtensions.cs new file mode 100644 index 0000000..dc86a3e --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/MigrationExtensions.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace PnvPanel.Infrastructure.Persistence; + +/// +/// Применение EF Core-миграций при старте приложения (стратегия MVP — авто-миграции). +/// +public static class MigrationExtensions +{ + public static async Task ApplyMigrationsAsync(this IServiceProvider services, CancellationToken cancellationToken = default) + { + await using var scope = services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + await dbContext.Database.MigrateAsync(cancellationToken); + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701161345_InitialCreate.Designer.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701161345_InitialCreate.Designer.cs new file mode 100644 index 0000000..ad92a9d --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701161345_InitialCreate.Designer.cs @@ -0,0 +1,29 @@ +// +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("20260701161345_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701161345_InitialCreate.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701161345_InitialCreate.cs new file mode 100644 index 0000000..3152b3c --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701161345_InitialCreate.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701171444_AddIdentity.Designer.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701171444_AddIdentity.Designer.cs new file mode 100644 index 0000000..4c0c624 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701171444_AddIdentity.Designer.cs @@ -0,0 +1,327 @@ +// +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("20260701171444_AddIdentity")] + partial class AddIdentity + { + /// + 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.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("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("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("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.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(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701171444_AddIdentity.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701171444_AddIdentity.cs new file mode 100644 index 0000000..53bbbdc --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701171444_AddIdentity.cs @@ -0,0 +1,259 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddIdentity : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AspNetRoles", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + MaxConfigs = table.Column(type: "integer", nullable: false), + IsSystem = table.Column(type: "boolean", nullable: false), + Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetUsers", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + IsActivated = table.Column(type: "boolean", nullable: false), + ActivatedAt = table.Column(type: "timestamp with time zone", nullable: true), + ActivatedBy = table.Column(type: "uuid", nullable: true), + UserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + Email = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "boolean", nullable: false), + PasswordHash = table.Column(type: "text", nullable: true), + SecurityStamp = table.Column(type: "text", nullable: true), + ConcurrencyStamp = table.Column(type: "text", nullable: true), + PhoneNumber = table.Column(type: "text", nullable: true), + PhoneNumberConfirmed = table.Column(type: "boolean", nullable: false), + TwoFactorEnabled = table.Column(type: "boolean", nullable: false), + LockoutEnd = table.Column(type: "timestamp with time zone", nullable: true), + LockoutEnabled = table.Column(type: "boolean", nullable: false), + AccessFailedCount = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUsers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "RefreshTokens", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + TokenHash = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + RevokedAt = table.Column(type: "timestamp with time zone", nullable: true), + ReplacedByTokenHash = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RefreshTokens", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetRoleClaims", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + RoleId = table.Column(type: "uuid", nullable: false), + ClaimType = table.Column(type: "text", nullable: true), + ClaimValue = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserClaims", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "uuid", nullable: false), + ClaimType = table.Column(type: "text", nullable: true), + ClaimValue = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetUserClaims_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserLogins", + columns: table => new + { + LoginProvider = table.Column(type: "text", nullable: false), + ProviderKey = table.Column(type: "text", nullable: false), + ProviderDisplayName = table.Column(type: "text", nullable: true), + UserId = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_AspNetUserLogins_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserRoles", + columns: table => new + { + UserId = table.Column(type: "uuid", nullable: false), + RoleId = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserTokens", + columns: table => new + { + UserId = table.Column(type: "uuid", nullable: false), + LoginProvider = table.Column(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + Value = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_AspNetUserTokens_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AspNetRoleClaims_RoleId", + table: "AspNetRoleClaims", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "AspNetRoles", + column: "NormalizedName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserClaims_UserId", + table: "AspNetUserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserLogins_UserId", + table: "AspNetUserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserRoles_RoleId", + table: "AspNetUserRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "AspNetUsers", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "AspNetUsers", + column: "NormalizedUserName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_TokenHash", + table: "RefreshTokens", + column: "TokenHash", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_UserId", + table: "RefreshTokens", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AspNetRoleClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserLogins"); + + migrationBuilder.DropTable( + name: "AspNetUserRoles"); + + migrationBuilder.DropTable( + name: "AspNetUserTokens"); + + migrationBuilder.DropTable( + name: "RefreshTokens"); + + migrationBuilder.DropTable( + name: "AspNetRoles"); + + migrationBuilder.DropTable( + name: "AspNetUsers"); + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701175024_AddActivationRequests.Designer.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701175024_AddActivationRequests.Designer.cs new file mode 100644 index 0000000..cbc6546 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701175024_AddActivationRequests.Designer.cs @@ -0,0 +1,365 @@ +// +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("20260701175024_AddActivationRequests")] + partial class AddActivationRequests + { + /// + 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.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("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("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("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.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(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701175024_AddActivationRequests.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701175024_AddActivationRequests.cs new file mode 100644 index 0000000..b3fe79d --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701175024_AddActivationRequests.cs @@ -0,0 +1,45 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddActivationRequests : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ActivationRequests", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + Comment = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + Status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + DecidedBy = table.Column(type: "uuid", nullable: true), + DecidedAt = table.Column(type: "timestamp with time zone", nullable: true), + RejectionReason = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ActivationRequests", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_ActivationRequests_UserId_Status", + table: "ActivationRequests", + columns: new[] { "UserId", "Status" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ActivationRequests"); + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701181642_AddNodesAndInbounds.Designer.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701181642_AddNodesAndInbounds.Designer.cs new file mode 100644 index 0000000..d777554 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701181642_AddNodesAndInbounds.Designer.cs @@ -0,0 +1,486 @@ +// +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("20260701181642_AddNodesAndInbounds")] + partial class AddNodesAndInbounds + { + /// + 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.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("MaxClients") + .HasColumnType("integer"); + + 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.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.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("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("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("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.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/20260701181642_AddNodesAndInbounds.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701181642_AddNodesAndInbounds.cs new file mode 100644 index 0000000..c72eb3d --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701181642_AddNodesAndInbounds.cs @@ -0,0 +1,72 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddNodesAndInbounds : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Inbounds", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + NodeId = table.Column(type: "uuid", nullable: false), + RemoteInboundId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Protocol = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + Remark = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Port = table.Column(type: "integer", nullable: false), + IsPublished = table.Column(type: "boolean", nullable: false), + DisplayName = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + MaxClients = table.Column(type: "integer", nullable: true), + AllowedRoleIds = table.Column(type: "uuid[]", nullable: false), + LastSyncAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Inbounds", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Nodes", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + BaseAddress = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + CredentialsUsername = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + CredentialsProtectedPassword = table.Column(type: "text", nullable: false), + Location = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + Status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + IsEnabled = table.Column(type: "boolean", nullable: false), + LastSyncAt = table.Column(type: "timestamp with time zone", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Nodes", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Inbounds_NodeId_RemoteInboundId", + table: "Inbounds", + columns: new[] { "NodeId", "RemoteInboundId" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Inbounds"); + + migrationBuilder.DropTable( + name: "Nodes"); + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701185508_AddConfigsAndApps.Designer.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701185508_AddConfigsAndApps.Designer.cs new file mode 100644 index 0000000..8eb6b54 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701185508_AddConfigsAndApps.Designer.cs @@ -0,0 +1,601 @@ +// +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("20260701185508_AddConfigsAndApps")] + partial class AddConfigsAndApps + { + /// + 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("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.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("DeviceLimit") + .HasColumnType("integer"); + + 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("MaxClients") + .HasColumnType("integer"); + + 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.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.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("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("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("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.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/20260701185508_AddConfigsAndApps.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701185508_AddConfigsAndApps.cs new file mode 100644 index 0000000..bc7b92e --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260701185508_AddConfigsAndApps.cs @@ -0,0 +1,95 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddConfigsAndApps : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SubscriptionToken", + table: "AspNetUsers", + type: "text", + nullable: false, + defaultValue: ""); + + migrationBuilder.CreateTable( + name: "ClientApps", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + DownloadUrl = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + OperatingSystem = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + Description = table.Column(type: "character varying(300)", maxLength: 300, nullable: true), + IconUrl = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + SortOrder = table.Column(type: "integer", nullable: false), + IsEnabled = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ClientApps", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "VpnConfigs", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + InboundId = table.Column(type: "uuid", nullable: false), + Label = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + ClientEmail = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + ClientExternalId = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Protocol = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + DeviceLimit = table.Column(type: "integer", nullable: false), + UsedUpBytes = table.Column(type: "bigint", nullable: false), + UsedDownBytes = table.Column(type: "bigint", nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: true), + Status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + SubscriptionToken = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + LastSyncAt = table.Column(type: "timestamp with time zone", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_VpnConfigs", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_VpnConfigs_InboundId", + table: "VpnConfigs", + column: "InboundId"); + + migrationBuilder.CreateIndex( + name: "IX_VpnConfigs_SubscriptionToken", + table: "VpnConfigs", + column: "SubscriptionToken", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_VpnConfigs_UserId_Status", + table: "VpnConfigs", + columns: new[] { "UserId", "Status" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ClientApps"); + + migrationBuilder.DropTable( + name: "VpnConfigs"); + + migrationBuilder.DropColumn( + name: "SubscriptionToken", + table: "AspNetUsers"); + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..6bb2827 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,598 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PnvPanel.Infrastructure.Persistence; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(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("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.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("DeviceLimit") + .HasColumnType("integer"); + + 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("MaxClients") + .HasColumnType("integer"); + + 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.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.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("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("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("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.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/PnvPanel.Infrastructure.csproj b/backend/src/PnvPanel.Infrastructure/PnvPanel.Infrastructure.csproj new file mode 100644 index 0000000..03c5adb --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/PnvPanel.Infrastructure.csproj @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/backend/src/PnvPanel.Infrastructure/Security/DataProtectionSecretProtector.cs b/backend/src/PnvPanel.Infrastructure/Security/DataProtectionSecretProtector.cs new file mode 100644 index 0000000..1d15be1 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Security/DataProtectionSecretProtector.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.DataProtection; +using PnvPanel.Application.Common.Interfaces; + +namespace PnvPanel.Infrastructure.Security; + +internal sealed class DataProtectionSecretProtector : ISecretProtector +{ + private const string Purpose = "PnvPanel.NodeCredentials.v1"; + + private readonly IDataProtector _protector; + + public DataProtectionSecretProtector(IDataProtectionProvider provider) + { + _protector = provider.CreateProtector(Purpose); + } + + public string Protect(string plaintext) => _protector.Protect(plaintext); + + public string Unprotect(string protectedValue) => _protector.Unprotect(protectedValue); +} diff --git a/backend/src/PnvPanel.Infrastructure/Xui/XuiPanelGateway.cs b/backend/src/PnvPanel.Infrastructure/Xui/XuiPanelGateway.cs new file mode 100644 index 0000000..808247c --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Xui/XuiPanelGateway.cs @@ -0,0 +1,197 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Inbounds; +using PnvPanel.Domain.Nodes; +using ThreeXui; +using ThreeXui.ConnectionStrings; +using ThreeXui.Http; + +namespace PnvPanel.Infrastructure.Xui; + +/// +/// ThreeXui.Net настроен на один BaseAddress, а нод у нас много — держим клиента per-node +/// (кэш по NodeId), создавая его из расшифрованных NodeCredentials. Singleton-время жизни +/// (см. регистрацию в DI): кэш должен переживать отдельные HTTP-запросы, чтобы переиспользовать +/// cookie-сессию клиента. +/// +internal sealed class XuiPanelGateway( + IXuiHttpClientFactory httpClientFactory, + IXuiConnectionStringBuilderResolver connectionStringResolver, + ISecretProtector secretProtector, + ILoggerFactory loggerFactory) + : IXuiPanelGateway, IDisposable +{ + private readonly ConcurrentDictionary> _clients = new(); + + public Result ValidateBaseAddress(Uri baseAddress) + { + return XuiBaseUrlValidator.IsAllowed(baseAddress.ToString(), out var reason) + ? Result.Success() + : Result.Failure(Error.Validation("Nodes.BaseAddressNotAllowed", reason ?? "Адрес панели не разрешён.")); + } + + public async Task ProbeAsync(Node node, CancellationToken cancellationToken) + { + try + { + var client = GetClient(node); + var health = await client.CheckHealthAsync(cancellationToken); + return new NodeProbeResult(health.Ok, health.Ok ? null : health.ErrorMessage); + } + catch (Exception ex) + { + return new NodeProbeResult(false, ex.Message); + } + } + + public async Task>> ListInboundsAsync(Node node, CancellationToken cancellationToken) + { + try + { + var client = GetClient(node); + var remoteInbounds = await client.ListInboundsAsync(cancellationToken); + + var mapped = remoteInbounds + .Select(i => (Summary: i, Protocol: TryParseProtocol(i.Protocol))) + .Where(x => x.Protocol is not null) + .Select(x => new RemoteInboundInfo(x.Summary.ExternalId, x.Protocol!.Value, x.Summary.Remark, x.Summary.Port)) + .ToList(); + + return Result.Success>(mapped); + } + catch (Exception ex) + { + return Result.Failure>( + Error.Failure("Xui.Unreachable", $"Нода недоступна: {ex.Message}")); + } + } + + public async Task> AddClientAsync( + Node node, string inboundRemoteId, VpnProtocol protocol, string clientEmail, string clientName, + int deviceLimit, CancellationToken cancellationToken) + { + try + { + var client = GetClient(node); + var request = new AddClientRequest(clientName, clientEmail, ToRemoteProtocol(protocol), deviceLimit, null); + var result = await client.AddClientAsync(inboundRemoteId, request, cancellationToken); + return Result.Success(result.ExternalClientId); + } + catch (Exception ex) + { + return Result.Failure(Error.Failure("Xui.AddClientFailed", $"Не удалось создать клиента: {ex.Message}")); + } + } + + public async Task RemoveClientAsync( + Node node, string inboundRemoteId, string clientExternalId, VpnProtocol protocol, CancellationToken cancellationToken) + { + try + { + var client = GetClient(node); + await client.RemoveClientAsync(inboundRemoteId, clientExternalId, ToRemoteProtocol(protocol), cancellationToken); + return Result.Success(); + } + catch (Exception ex) + { + return Result.Failure(Error.Failure("Xui.RemoveClientFailed", $"Не удалось удалить клиента: {ex.Message}")); + } + } + + public async Task UpdateClientAsync( + Node node, string inboundRemoteId, string clientExternalId, VpnProtocol protocol, + string name, int deviceLimit, bool enable, CancellationToken cancellationToken) + { + try + { + var client = GetClient(node); + var request = new UpdateClientRequest(deviceLimit, null, enable, name); + await client.UpdateClientAsync(inboundRemoteId, clientExternalId, ToRemoteProtocol(protocol), request, cancellationToken); + return Result.Success(); + } + catch (Exception ex) + { + return Result.Failure(Error.Failure("Xui.UpdateClientFailed", $"Не удалось изменить клиента: {ex.Message}")); + } + } + + public async Task> BuildConnectionStringAsync( + Node node, Inbound inbound, string clientExternalId, string clientName, string publicHost, + CancellationToken cancellationToken) + { + try + { + var client = GetClient(node); + var remoteInbound = await client.GetInboundAsync(inbound.RemoteInboundId, cancellationToken); + if (remoteInbound is null) + { + return Result.Failure( + Error.Failure("Xui.ConnectionStringFailed", "Inbound не найден на панели.")); + } + + var builder = connectionStringResolver.Resolve(ToRemoteProtocol(inbound.Protocol)); + if (builder is null) + { + return Result.Failure( + Error.Failure("Xui.ConnectionStringFailed", $"Протокол {inbound.Protocol} не поддерживается.")); + } + + var request = new XuiConnectionStringRequest( + clientExternalId, clientName, inbound.Port, publicHost, node.BaseAddress.ToString(), remoteInbound); + + return Result.Success(builder.Build(request)); + } + catch (Exception ex) + { + return Result.Failure(Error.Failure("Xui.ConnectionStringFailed", $"Не удалось построить ссылку: {ex.Message}")); + } + } + + public void InvalidateClient(Guid nodeId) + { + if (_clients.TryRemove(nodeId, out var lazy) && lazy.IsValueCreated) + (lazy.Value as IDisposable)?.Dispose(); + } + + private IXuiClient GetClient(Node node) + => _clients.GetOrAdd(node.Id, _ => new Lazy(() => CreateClient(node))).Value; + + private IXuiClient CreateClient(Node node) + { + var httpClient = httpClientFactory.Create(node.BaseAddress, allowInsecureTls: false, timeout: TimeSpan.FromSeconds(15)); + var password = secretProtector.Unprotect(node.Credentials.ProtectedPassword); + var logger = loggerFactory.CreateLogger(); + return new XuiClient(httpClient, node.Credentials.Username, password, logger); + } + + private static VpnProtocol? TryParseProtocol(string raw) => raw.ToLowerInvariant() switch + { + "vless" => VpnProtocol.Vless, + "vmess" => VpnProtocol.Vmess, + "trojan" => VpnProtocol.Trojan, + "shadowsocks" => VpnProtocol.Shadowsocks, + _ => null, + }; + + private static string ToRemoteProtocol(VpnProtocol protocol) => protocol switch + { + VpnProtocol.Vless => "vless", + VpnProtocol.Vmess => "vmess", + VpnProtocol.Trojan => "trojan", + VpnProtocol.Shadowsocks => "shadowsocks", + _ => throw new ArgumentOutOfRangeException(nameof(protocol)), + }; + + public void Dispose() + { + foreach (var lazy in _clients.Values) + { + if (lazy.IsValueCreated) + (lazy.Value as IDisposable)?.Dispose(); + } + + _clients.Clear(); + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8c5ad78 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +services: + db: + image: postgres:17-alpine + environment: + POSTGRES_DB: ${POSTGRES_DB:-pnvpanel} + POSTGRES_USER: ${POSTGRES_USER:-pnvpanel} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-pnvpanel} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-pnvpanel} -d ${POSTGRES_DB:-pnvpanel}'] + interval: 5s + timeout: 5s + retries: 10 + restart: unless-stopped + + app: + build: + context: . + dockerfile: Dockerfile + depends_on: + db: + condition: service_healthy + environment: + ASPNETCORE_ENVIRONMENT: Production + ASPNETCORE_HTTP_PORTS: '8080' + ConnectionStrings__Default: 'Host=db;Port=5432;Database=${POSTGRES_DB:-pnvpanel};Username=${POSTGRES_USER:-pnvpanel};Password=${POSTGRES_PASSWORD:-pnvpanel}' + ports: + - '8080:8080' + restart: unless-stopped + +volumes: + pgdata: diff --git a/docs/architecture.md b/docs/architecture.md index d8b1f96..2e48796 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -170,8 +170,11 @@ POST /api/configs сразу активирована и с ролью `admin`. - **Telegram id админов** (`AdminSeed__TelegramUserIds`) — авторизуют админ-действия в боте и адресуют уведомления (например, запросы на активацию). +- **Каталог приложений** (`ClientApp`): если таблица пуста — сидируется из + [`seed/client-apps.json`](../seed/client-apps.json) (стартовый набор клиентов по ОС). Дальше — правки через админ-CRUD. -Сидинг не перезаписывает существующие данные; смена пароля админа после первого старта — через приложение. +Сидинг не перезаписывает существующие данные. Принудительной смены сид-пароля при первом входе +**нет** — задавайте сильный `AdminSeed__Password` сразу; сменить пароль можно в приложении. ## RBAC — динамические роли и активация @@ -228,11 +231,18 @@ PostgreSQL: 2. `dotnet sdk` — `dotnet publish` Api; статика фронта копируется в `wwwroot`. 3. `dotnet aspnet` runtime — финальный образ запускает Api. - **docker-compose**: сервис `app` (этот образ) + сервис `db` (PostgreSQL). Всё приложение — в `app`. -- Миграции применяются на старте (dev) / отдельным шагом (prod). +- **TLS — внешний**: HTTPS терминирует внешний прокси/шлюз (nginx/Traefik/cloud LB администратора), + вне нашего compose; `app` внутри отдаёт HTTP. Приложение доверяет `X-Forwarded-Proto/For` через + `ForwardedHeaders`-middleware — иначе Secure-cookie и определение схемы за прокси работать не будут. + Отдельный nginx/Caddy в compose **не** вводим. +- **Миграции**: применяются **автоматически на старте** приложения (в MVP; при масштабировании на + несколько инстансов — вынести в отдельный шаг/джобу). - Конфигурация через `appsettings.{Env}.json` + переменные окружения / secrets (строка подключения, JWT-ключ, ключ шифрования секретов, `Telegram:BotToken`, `PublicSiteUrl`). ``` + [ внешний прокси/шлюз: TLS termination ] ← HTTPS, вне нашего compose + │ HTTP + X-Forwarded-* ┌────────────────── docker-compose ──────────────────┐ │ app (единый образ) db (postgres) │ │ ├─ REST /api └─ том с данными │ diff --git a/docs/backend-conventions.md b/docs/backend-conventions.md index 8f132a3..efec9cf 100644 --- a/docs/backend-conventions.md +++ b/docs/backend-conventions.md @@ -24,6 +24,7 @@ backend/ Configs/ # CreateVpnConfig, EditVpnConfig, RotateVpnConfig, RevokeVpnConfig, GetMyConfigs, GetConfigLink, GetSubscription, ... Nodes/ # RegisterNode, SyncNode, ListNodes, ... Inbounds/ # PublishInbound, ListInbounds, ... + Apps/ # (admin) CRUD каталога ClientApp; GetApps (по ОС) для юзера Admin/ # ListUsers, BlockUser/UnblockUser, ChangeUserRole, GetStats, Audit, ... PnvPanel.Infrastructure/ Persistence/ diff --git a/docs/domain-model.md b/docs/domain-model.md index 7179cd0..d2cd2b3 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -49,12 +49,12 @@ ClientApp (каталог приложений-клиен | ----------------- | ------------- | ---------------------------------------------------------- | | `Id` | `Guid` | PK (внутренний) | | `NodeId` | `Guid` | FK → Node | -| `RemoteInboundId` | `int` | Id inbound в 3x-ui | +| `RemoteInboundId` | `string` | Id inbound в 3x-ui (ThreeXui.Net отдаёт его как string, не число) | | `Protocol` | `VpnProtocol` | `Vless` / `Vmess` / `Trojan` / `Shadowsocks` | | `Remark` | `string` | Метка из 3x-ui | | `Port` | `int` | | | `IsPublished` | `bool` | Доступен ли для самообслуживания пользователями | -| `AllowedRoles` | `AppRole[]` (M:N) | Роли, которым разрешено создавать конфиги в этом инбаунде | +| `AllowedRoleIds` | `Guid[]` | Id ролей, которым разрешено создавать конфиги (native PostgreSQL `uuid[]`; не навигация на `AppRole` — тот в Infrastructure/Identity, Domain на него не ссылается) | | `DisplayName` | `string?` | Витринное имя для пользователя, напр. «Германия (Trojan)» | | `MaxClients` | `int?` | Лимит клиентов (null = без лимита) | | `LastSyncAt` | `DateTimeOffset?` | | @@ -76,7 +76,7 @@ ClientApp (каталог приложений-клиен | `InboundId` | `Guid` | FK → Inbound | | `Label` | `string?` | Пользовательская метка («Мой телефон»); редактируется юзером | | `ClientEmail` | `string` | Уникальный ключ клиента в 3x-ui; схема `pnv_{userIdShort}_{rand}` (уникален в рамках панели, виден владелец) | -| `ClientUuid` | `Guid` | UUID клиента (VLESS/VMess) | +| `ClientExternalId` | `string` | Идентификатор клиента, который вернула панель (UUID для VLESS/VMess, пароль для Trojan/Shadowsocks — ThreeXui.Net отдаёт его как string) | | `Protocol` | `VpnProtocol` | Денормализовано с inbound | | `DeviceLimit` | `int` | Лимит одновременных устройств/IP (0 = без лимита); задаёт юзер → `limitIp` в 3x-ui | | `TrafficLimit` | `TrafficLimit` (VO) | Лимит в байтах (0 = безлимит) | @@ -146,6 +146,7 @@ ClientApp (каталог приложений-клиен | `IsEnabled` | `bool` | Показывать пользователям | Управляется админом (CRUD). Пользователю отдаётся только `IsEnabled`, сгруппировано по `OperatingSystem`. +Стартовый набор сидируется из [`seed/client-apps.json`](../seed/client-apps.json), если таблица пуста. ### AuditLog — журнал действий Аудит значимых действий (прежде всего админских) для расследований и прозрачности. @@ -218,6 +219,7 @@ UI **настойчиво напоминает** привязать его (ед | `Status` | `ActivationStatus` | `Pending` / `Approved` / `Rejected` | | `DecidedBy` | `Guid?` | Админ, принявший решение | | `DecidedAt` | `DateTimeOffset?` | | +| `RejectionReason` | `string?` | Комментарий админа при отклонении (опционально) | | `CreatedAt` | `DateTimeOffset` | | Инварианты: одновременно не более одного `Pending`-запроса на пользователя; `Approved` → diff --git a/docs/frontend.md b/docs/frontend.md index bdeb145..ddd821d 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -33,12 +33,15 @@ SPA на **React 19 + Vite + TypeScript**. Общается с бэком по R frontend/ src/ app/ # провайдеры (Query, Router, Auth, Theme), корневой layout - routes/ # маршруты TanStack Router (login, dashboard, configs, admin/*) + routes/ # маршруты TanStack Router (login, dashboard, configs, instructions, admin/*) features/ auth/ # формы, хуки useLogin/useRegister, стор авторизации - configs/ # список/создание/детали конфигов, QR, ссылка-подписка + configs/ # список/создание/редактирование/детали конфигов, QR, подписка + instructions/ # страница инструкций + каталог приложений по ОС nodes/ # (admin) управление нодами - admin/ # пользователи, статистика + apps/ # (admin) CRUD каталога приложений + admin/ # пользователи, роли, аудит, статистика + theme/ # провайдер темы (light/dark/system) + переключатель shared/ api/ # http-клиент (fetch + JWT/refresh), сгенерированные типы, query-хуки realtime/ # инициализация SignalR, подписки → инвалидация Query-кэша @@ -60,12 +63,15 @@ frontend/ быстрые действия (копировать ссылку, показать QR, перевыпустить, отозвать) + карточка «Общая подписка» (агрегированная ссылка/QR со всеми конфигами). Для неактивированного — экран «запросить активацию». - **Создание конфига**: выбор локации/inbound (по `DisplayName`) + метка + лимит устройств → - мгновенная выдача ссылки + QR + краткие инструкции по подключению (iOS/Android/Windows). + мгновенная выдача ссылки + QR + ссылка на страницу инструкций. +- **Страница инструкций** (`/instructions`): общие шаги «как импортировать ссылку/QR» + каталог + приложений (`GET /api/apps`), **сгруппированный по ОС**; клик по приложению открывает ссылку на + скачивание. Данные ведёт админ (каталог `ClientApp`). - **Редактирование конфига**: изменить метку и лимит устройств. - **Настройки аккаунта**: смена пароля, привязка/отвязка Telegram, **удаление аккаунта** (с подтверждением). - **Админка**: таблицы (TanStack Table) с пагинацией/фильтрами для нод, пользователей, конфигов, ролей, - журнала аудита; очередь запросов активации; графики трафика (Recharts). Блокировка пользователя — с - подтверждением (гасит VPN). + каталога приложений, журнала аудита; очередь запросов активации; графики трафика (Recharts). + Блокировка пользователя — с подтверждением (гасит VPN). Управление приложениями (название, ссылка, ОС, вкл/выкл). - **Состояния**: скелетоны при загрузке, аккуратные пустые состояния и toasts на ошибки/успех. ## Работа с API diff --git a/docs/roadmap.md b/docs/roadmap.md index 4aa40bd..2817a26 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -6,11 +6,13 @@ - Solution + 4 проекта (Domain/Application/Infrastructure/Api), ссылки по Clean Architecture. - `Directory.Build.props`, `.editorconfig`, nullable + анализаторы, `dotnet format` в CI. - EF Core + Npgsql, первая миграция. -- Scaffolding фронта: Vite + React + TS + Tailwind + shadcn/ui + TanStack Query/Router; dev-прокси `/api`,`/hubs` на бэк. +- Scaffolding фронта: Vite + React + TS + Tailwind + shadcn/ui + TanStack Query/Router; **тема light/dark/system** (провайдер + переключатель); i18n (RU/EN); dev-прокси `/api`,`/hubs` на бэк. - **Единый контейнер**: multi-stage Dockerfile (node → dotnet publish → aspnet), Api раздаёт SPA из - `wwwroot` (fallback на `index.html`); docker-compose `app` + `db` (PostgreSQL). + `wwwroot` (fallback на `index.html`); docker-compose `app` + `db` (PostgreSQL); `ForwardedHeaders` + (TLS — внешним прокси); авто-применение миграций на старте. - Health-check `/health`, Serilog, OpenAPI + Scalar. -- **Готово, когда**: единый образ поднимается в docker-compose рядом с postgres, отдаёт заглушку SPA и `/health`, есть базовая миграция. +- **CI (GitHub Actions)**: `dotnet build/test` + `pnpm build/lint/typecheck` (без деплоя). +- **Готово, когда**: единый образ поднимается в docker-compose рядом с postgres, отдаёт заглушку SPA и `/health`, есть базовая миграция, CI зелёный. ## M1 — Аутентификация и сидинг - ASP.NET Core Identity (`AppUser`/`AppRole` c `MaxConfigs`); `DbInitializer`: системные роли @@ -44,7 +46,8 @@ - Подписка: агрегированная `/sub/{userToken}` (все конфиги) + по конфигу `/sub/{configToken}`; заголовки `Subscription-Userinfo` / `profile-update-interval`. - Самоудаление аккаунта (`DELETE /api/auth/me`): отзыв всех конфигов + удаление данных. -- Фронт: дашборд (метки, лимит устройств), создание/редактирование, инструкции подключения, копирование, QR, отзыв, перевыпуск, настройки аккаунта. +- Каталог приложений `ClientApp` (домен + `GET /api/apps` по ОС; сид из `seed/client-apps.json`) + **страница инструкций** на фронте. +- Фронт: дашборд (метки, лимит устройств), создание/редактирование, страница инструкций, копирование, QR, отзыв, перевыпуск, настройки аккаунта. - **Готово, когда**: активированный юзер создаёт рабочий конфиг в доступном инбаунде в пределах квоты; работает агрегированная подписка. ## M5 — Синхронизация трафика и realtime @@ -57,6 +60,7 @@ ## M6 — Админ-статистика, управление пользователями, аудит - ListUsers, BlockUser (→ отключение конфигов в 3x-ui) / UnblockUser, ChangeUserRole, ResetUserPassword (без привязки TG), GetUserConfigs, force-revoke, GetStats. - `AuditLog`: запись значимых действий (Web/Telegram/System) + эндпоинт `/api/admin/audit`. +- Каталог приложений: админ-CRUD `ClientApp` (`/api/admin/apps`) — название, ссылка, ОС, порядок, вкл/выкл. - Фронт: таблицы пользователей/конфигов/ролей, журнал аудита, графики трафика (Recharts), сводки. - **Готово, когда**: админ видит статистику и журнал, управляет пользователями/ролями/конфигами; блокировка гасит VPN. @@ -75,7 +79,7 @@ ## M8 — Закалка (hardening) - Полный набор тестов (Domain/Application/Integration с Testcontainers). - Rate-limiting, аудит-лог действий, единообразные ProblemDetails, ретеншн `TrafficSample`. -- Прод-конфиг docker-compose (secrets, миграции отдельным шагом, опц. reverse-proxy для TLS). +- Прод-конфиг docker-compose (secrets, том для key-ring Data Protection, healthchecks); TLS — внешним прокси. - **Готово, когда**: зелёный CI, покрытие ключевых сценариев, готовность к деплою. ## Backlog (после MVP) diff --git a/docs/tech-stack.md b/docs/tech-stack.md index 773721e..7b8d953 100644 --- a/docs/tech-stack.md +++ b/docs/tech-stack.md @@ -125,8 +125,12 @@ MVP (не нужен публичный webhook, проще в одиночно + fallback на `index.html`), фронт и бек — один origin. - **docker-compose**: `app` (единый образ) + `db` (PostgreSQL) с томом. - Почему не отдельный nginx: единый origin упрощает CORS/куки/деплой и укладывается в требование - «фронт+бек в одном контейнере». Nginx/reverse-proxy — опция для прод (TLS-терминация) поверх, но не обязателен. -- **CI**: сборка/тесты бэка (`dotnet test`), линт/сборка фронта (`pnpm build`), сборка единого образа. + «фронт+бек в одном контейнере». +- **TLS — внешний** (решение): HTTPS терминирует внешний прокси/шлюз (nginx/Traefik/cloud LB) вне + compose; `app` отдаёт HTTP и доверяет `X-Forwarded-*` через `ForwardedHeaders`. Свой nginx/Caddy не вводим. +- **Миграции** — авто на старте приложения (MVP). +- **CI** — GitHub Actions, **только сборка/тесты**: `dotnet build`/`test`, `pnpm build`/`lint`/`typecheck`. + Публикация образа и деплой — вручную/позже (в MVP не автоматизируем). - **Пакетный менеджер фронта**: pnpm (быстрый, экономный по диску). ## Принятые решения (по открытым вопросам) @@ -166,6 +170,8 @@ MVP (не нужен публичный webhook, проще в одиночно | Самоудаление аккаунта | Разрешено: отзыв всех конфигов + удаление данных, аудит анонимизируется | | Версионирование API | Без версий в MVP (`/api` без `v1`) | | Подписка (заголовки) | `Subscription-Userinfo` (used/total/expire) + `profile-update-interval` | +| Тема сайта | Светлая + тёмная (+ системная); Tailwind `dark`, выбор в localStorage | +| Инструкции/приложения | Отдельная страница инструкций + каталог `ClientApp` (админ CRUD, юзер — по ОС); стартовый сид из `seed/client-apps.json` | | Реконсиляция с 3x-ui | На синхронизации сверяем проекцию с панелью, помечаем дрейф, не «воскрешаем» молча | Также заложены: CSRF-защита refresh-cookie + Identity lockout; проверка квоты в транзакции; схема diff --git a/docs/vision.md b/docs/vision.md index 68bb27d..a73bbeb 100644 --- a/docs/vision.md +++ b/docs/vision.md @@ -25,7 +25,7 @@ PnvPanel **не заменяет** Xray/3x-ui — он оркестрирует | ----------- | -------------------------------------------------------------------------------------------- | | **Guest** | Регистрация, вход, публичный эндпоинт подписки (`/sub/{token}`). | | **User** | После **активации** — CRUD своих конфигов (в рамках квоты роли и доступных инбаундов), просмотр трафика/срока, ссылка/QR, отзыв. | -| **Admin** | Всё выше без лимитов + ноды, публикация inbounds с выбором ролей, роли/квоты, активация пользователей, стата. | +| **Admin** | Всё выше без лимитов + ноды, публикация inbounds с выбором ролей, роли/квоты, активация пользователей, каталог приложений, стата. | | *(кастомные)* | Админ создаёт роли (напр. `vip`) со своей квотой конфигов и назначает их пользователям. | ### RBAC — динамические роли с квотой @@ -107,6 +107,8 @@ PnvPanel **не заменяет** Xray/3x-ui — он оркестрирует - Синхронизация трафика (фоновая) + realtime-обновления по SignalR. - Базовая статистика для админа. - Telegram-бот: ссылка на сайт, просмотр конфигов, привязка Telegram и passwordless-вход. +- Светлая/тёмная тема сайта. +- Страница инструкций по подключению + каталог приложений по ОС (админ ведёт, юзер видит сгруппировано). - Единый Docker-образ (фронт+бек) + PostgreSQL в docker-compose. **За рамками MVP (backlog):** diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/frontend/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d6af7e3 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,32 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the Oxlint configuration + +If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`: + +```json +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "options": { + "typeAware": true + }, + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} +``` + +See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories. diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..0fca6f0 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + frontend + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..b9777dd --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,31 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "typecheck": "tsc -b", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.101.2", + "i18next": "^26.3.4", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-i18next": "^17.0.8" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.2", + "@types/node": "^24.13.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "oxlint": "^1.71.0", + "tailwindcss": "^4.3.2", + "typescript": "~6.0.2", + "vite": "^8.1.1" + } +} diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 0000000..43bf01b --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,1147 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@tanstack/react-query': + specifier: ^5.101.2 + version: 5.101.2(react@19.2.7) + i18next: + specifier: ^26.3.4 + version: 26.3.4(typescript@6.0.3) + react: + specifier: ^19.2.7 + version: 19.2.7 + react-dom: + specifier: ^19.2.7 + version: 19.2.7(react@19.2.7) + react-i18next: + specifier: ^17.0.8 + version: 17.0.8(i18next@26.3.4(typescript@6.0.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3) + devDependencies: + '@tailwindcss/vite': + specifier: ^4.3.2 + version: 4.3.2(vite@8.1.1(@types/node@24.13.2)(jiti@2.7.0)) + '@types/node': + specifier: ^24.13.2 + version: 24.13.2 + '@types/react': + specifier: ^19.2.17 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: ^6.0.3 + version: 6.0.3(vite@8.1.1(@types/node@24.13.2)(jiti@2.7.0)) + oxlint: + specifier: ^1.71.0 + version: 1.72.0 + tailwindcss: + specifier: ^4.3.2 + version: 4.3.2 + typescript: + specifier: ~6.0.2 + version: 6.0.3 + vite: + specifier: ^8.1.1 + version: 8.1.1(@types/node@24.13.2)(jiti@2.7.0) + +packages: + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.137.0': + resolution: {integrity: sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==} + + '@oxlint/binding-android-arm-eabi@1.72.0': + resolution: {integrity: sha512-zhCmvn+1Mj3UchAc/90i99S0t7jJUsHmFVSPg4UWrjO8b8eaSGwscgO6QAUtvHBstkjQwBttQNswEnAF1mIQdA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.72.0': + resolution: {integrity: sha512-mtH+aY/ozv1eZoCUC2owjFAtyNBKHpJHygKeEu9zXXnQGW1Q2/qOpvx+I+Lf23+TvTz66F4iiXUbl2cGvoLPCQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.72.0': + resolution: {integrity: sha512-EvnajNPDtfknB3ZieeOOyDTwJn9QXDiwfnF4ZDQqART6RG6hjY4WigQcZdGoK2dkB3e1vrmEzN9aYbQCUkh/gQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.72.0': + resolution: {integrity: sha512-ZkCdEa/G80A7vEHfeCDz/+L3m33DE73v32mDKhgOIgz8Uwf0DFcK7+uu6qC+7LEhmz5fpOe1osWKyjSNMydFIQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.72.0': + resolution: {integrity: sha512-NroXv2vh+sxVY1uya/rM5pjhx1hm8BzlYpx9q67QP0Xhw5MH2bf5GJylpvLEC+781p1Xli/317EoV9AlGwViag==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.72.0': + resolution: {integrity: sha512-0NDywYgfj279Ou/BcQuCYSj7NJwBfmWn5qc5uGO/Ny7fUWmXyIpvawqX/8acQlWG6IXelJsJhj+JAy6sjsKj0A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.72.0': + resolution: {integrity: sha512-4vpXB06h65Ezsy4hRyrGjGrfa1SkVPii09yaajiYhmVpgsFiLD+KNxIx/BNAY+XiO+i1yqp9HHdwqM8VTqa5XQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.72.0': + resolution: {integrity: sha512-immaN4g2ZGFiOkKrvRX9LvzZdd2GkQM5wR+UyzYyUuyhUTXGQ4HKUJH18xp4G8OfhCVaVAJfKZxwE1r8+4hhaQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-arm64-musl@1.72.0': + resolution: {integrity: sha512-JGHS9Mnr7iWyyLDxgCv1MhzVpAckgptg00F2gnxt/GD7lQ2SW1BRcxHqhSTaSdDpjWRrBkBxMMh4+Hn3aVtExg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-ppc64-gnu@1.72.0': + resolution: {integrity: sha512-AOYgBZqxNshrg83P9v0RYv+m8s10Cqkj4/PxXFDhcS3k7FqsIG5+CxErshZCIN7G8iy4Y+VGfAsuEdar8AcbBg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-gnu@1.72.0': + resolution: {integrity: sha512-QMybPS5ij3/vrKG67mqzHwW++91sYxK/PPUVi6SBtNCEzW4niS52fVBdXbQ6nou0wWbUPEpx8Sl/ZjtgE3clXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-musl@1.72.0': + resolution: {integrity: sha512-gOc3W7JV0PXRpIL7stUlLe3Wa9Gp0Kdlup87IT3gHDvPKck2xNgMIl/Gs2lldYY2lyXZDC4rWi3hmoLUobkgbQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-s390x-gnu@1.72.0': + resolution: {integrity: sha512-rpGxph+FjjHcYI5q6uxB3Az+tnfmEnDbSA8+PK9ZE/VzyUAkvBOMeuY7ZQMhu5mpZH7YQDsTdW6Cx4kV/msc6w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-gnu@1.72.0': + resolution: {integrity: sha512-WND+uhf/Ko13SLqQMWQUgsZuLvYYEvL0ZKgg0tgGYfLqxG7l8Ju123fHDMJyYSDl5E3bUbpFUuii/OvMreFQzw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-musl@1.72.0': + resolution: {integrity: sha512-SrpbrUL70nG9vh6zP4/oKHWgLuHquwsr7MW9XOn0olBVgh10Uqr8qscKhQoBGEn6olK/IUpn5GSKcdQ5AjUhGA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxlint/binding-openharmony-arm64@1.72.0': + resolution: {integrity: sha512-qkrsEn6NmgFKr7U/QnezQMb+q/vzAy0Dd9Y95gQGQTyjzDLN+HRZMuM5u70iyH4nBLCfKBzhjMsYCehKay2jyg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.72.0': + resolution: {integrity: sha512-LWR6ZlFZph+KPjXv8opgZsXRDCdrdQe8VL8Cg9zxCoBS73h6znzZpydVgmdnwj8mB9AuSM5jxEgDJDpQkjboeg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.72.0': + resolution: {integrity: sha512-yt6HEh7IsHvtjRWtmeZRX134eaXKHq5Gnqlf1xBJdJl1JtdoRUEJw3nAxpZoUDS860cX/foKbztO441anVBtVQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.72.0': + resolution: {integrity: sha512-b2eKFD2hX7tIwmo/cyH6TDq8vzWRZ2qNHrzoGntUTmq0h3zQh/uX3eTSHCwI8OB/ADQfJCRelLItK8BsxuucDA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/binding-android-arm64@1.1.3': + resolution: {integrity: sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.1.3': + resolution: {integrity: sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.3': + resolution: {integrity: sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.1.3': + resolution: {integrity: sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.1.3': + resolution: {integrity: sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.1.3': + resolution: {integrity: sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.1.3': + resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.1.3': + resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.1.3': + resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.1.3': + resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.1.3': + resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.1.3': + resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.1.3': + resolution: {integrity: sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.1.3': + resolution: {integrity: sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.1.3': + resolution: {integrity: sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@tailwindcss/node@4.3.2': + resolution: {integrity: sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==} + + '@tailwindcss/oxide-android-arm64@4.3.2': + resolution: {integrity: sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.2': + resolution: {integrity: sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.2': + resolution: {integrity: sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.2': + resolution: {integrity: sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2': + resolution: {integrity: sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.2': + resolution: {integrity: sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.2': + resolution: {integrity: sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.2': + resolution: {integrity: sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.2': + resolution: {integrity: sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.2': + resolution: {integrity: sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.2': + resolution: {integrity: sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.2': + resolution: {integrity: sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.2': + resolution: {integrity: sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.2': + resolution: {integrity: sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@tanstack/query-core@5.101.2': + resolution: {integrity: sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==} + + '@tanstack/react-query@5.101.2': + resolution: {integrity: sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==} + peerDependencies: + react: ^18 || ^19 + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/node@24.13.2': + resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + + '@vitejs/plugin-react@6.0.3': + resolution: {integrity: sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + enhanced-resolve@5.21.6: + resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} + engines: {node: '>=10.13.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + + i18next@26.3.4: + resolution: {integrity: sha512-pa7m0d7pBDqGHZxljT+WPFeyFgQ7P7SciPPo1tTqYuO0z4sqADYhwnBESmmGp/wEof1inwdls/k8ZgTg8rxFHA==} + peerDependencies: + typescript: ^5 || ^6 + peerDependenciesMeta: + typescript: + optional: true + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + oxlint@1.72.0: + resolution: {integrity: sha512-1rhdZIP/EvoI91ABIwNU5Q8+bWf8mjrS5UzIOZld4d4bXxJvtlUhlQvaoTogIGin/qdErMOrwaIJvCSIAKTLhA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=0.22.1' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.16: + resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} + engines: {node: ^10 || ^12 || >=14} + + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react-i18next@17.0.8: + resolution: {integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==} + peerDependencies: + i18next: '>= 26.2.0' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 || ^6 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + + rolldown@1.1.3: + resolution: {integrity: sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tailwindcss@4.3.2: + resolution: {integrity: sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + vite@8.1.1: + resolution: {integrity: sha512-X/05/cT+VITy2AeDc1der6smvGWWREtL4hPbPTaVbjSBuuWkmNOjR6HP3NzqcQA2nF6VHGUPaFRJyft/2AE9Kg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + +snapshots: + + '@babel/runtime@7.29.7': {} + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oxc-project/types@0.137.0': {} + + '@oxlint/binding-android-arm-eabi@1.72.0': + optional: true + + '@oxlint/binding-android-arm64@1.72.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.72.0': + optional: true + + '@oxlint/binding-darwin-x64@1.72.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.72.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.72.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.72.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.72.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.72.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.72.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.72.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.72.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.72.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.72.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.72.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.72.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.72.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.72.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.72.0': + optional: true + + '@rolldown/binding-android-arm64@1.1.3': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.3': + optional: true + + '@rolldown/binding-darwin-x64@1.1.3': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.3': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.3': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.3': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.3': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.3': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.3': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.3': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.3': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.3': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.3': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.1.3': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.1.3': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@tailwindcss/node@4.3.2': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.21.6 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.2 + + '@tailwindcss/oxide-android-arm64@4.3.2': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.2': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.2': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.2': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.2': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.2': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.2': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.2': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.2': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.2': + optional: true + + '@tailwindcss/oxide@4.3.2': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.2 + '@tailwindcss/oxide-darwin-arm64': 4.3.2 + '@tailwindcss/oxide-darwin-x64': 4.3.2 + '@tailwindcss/oxide-freebsd-x64': 4.3.2 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.2 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.2 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.2 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.2 + '@tailwindcss/oxide-linux-x64-musl': 4.3.2 + '@tailwindcss/oxide-wasm32-wasi': 4.3.2 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.2 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.2 + + '@tailwindcss/vite@4.3.2(vite@8.1.1(@types/node@24.13.2)(jiti@2.7.0))': + dependencies: + '@tailwindcss/node': 4.3.2 + '@tailwindcss/oxide': 4.3.2 + tailwindcss: 4.3.2 + vite: 8.1.1(@types/node@24.13.2)(jiti@2.7.0) + + '@tanstack/query-core@5.101.2': {} + + '@tanstack/react-query@5.101.2(react@19.2.7)': + dependencies: + '@tanstack/query-core': 5.101.2 + react: 19.2.7 + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/node@24.13.2': + dependencies: + undici-types: 7.18.2 + + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + + '@vitejs/plugin-react@6.0.3(vite@8.1.1(@types/node@24.13.2)(jiti@2.7.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.1.1(@types/node@24.13.2)(jiti@2.7.0) + + csstype@3.2.3: {} + + detect-libc@2.1.2: {} + + enhanced-resolve@5.21.6: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fsevents@2.3.3: + optional: true + + graceful-fs@4.2.11: {} + + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + + i18next@26.3.4(typescript@6.0.3): + optionalDependencies: + typescript: 6.0.3 + + jiti@2.7.0: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + nanoid@3.3.15: {} + + oxlint@1.72.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.72.0 + '@oxlint/binding-android-arm64': 1.72.0 + '@oxlint/binding-darwin-arm64': 1.72.0 + '@oxlint/binding-darwin-x64': 1.72.0 + '@oxlint/binding-freebsd-x64': 1.72.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.72.0 + '@oxlint/binding-linux-arm-musleabihf': 1.72.0 + '@oxlint/binding-linux-arm64-gnu': 1.72.0 + '@oxlint/binding-linux-arm64-musl': 1.72.0 + '@oxlint/binding-linux-ppc64-gnu': 1.72.0 + '@oxlint/binding-linux-riscv64-gnu': 1.72.0 + '@oxlint/binding-linux-riscv64-musl': 1.72.0 + '@oxlint/binding-linux-s390x-gnu': 1.72.0 + '@oxlint/binding-linux-x64-gnu': 1.72.0 + '@oxlint/binding-linux-x64-musl': 1.72.0 + '@oxlint/binding-openharmony-arm64': 1.72.0 + '@oxlint/binding-win32-arm64-msvc': 1.72.0 + '@oxlint/binding-win32-ia32-msvc': 1.72.0 + '@oxlint/binding-win32-x64-msvc': 1.72.0 + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.16: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react-i18next@17.0.8(i18next@26.3.4(typescript@6.0.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3): + dependencies: + '@babel/runtime': 7.29.7 + html-parse-stringify: 3.0.1 + i18next: 26.3.4(typescript@6.0.3) + react: 19.2.7 + use-sync-external-store: 1.6.0(react@19.2.7) + optionalDependencies: + react-dom: 19.2.7(react@19.2.7) + typescript: 6.0.3 + + react@19.2.7: {} + + rolldown@1.1.3: + dependencies: + '@oxc-project/types': 0.137.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.3 + '@rolldown/binding-darwin-arm64': 1.1.3 + '@rolldown/binding-darwin-x64': 1.1.3 + '@rolldown/binding-freebsd-x64': 1.1.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.3 + '@rolldown/binding-linux-arm64-gnu': 1.1.3 + '@rolldown/binding-linux-arm64-musl': 1.1.3 + '@rolldown/binding-linux-ppc64-gnu': 1.1.3 + '@rolldown/binding-linux-s390x-gnu': 1.1.3 + '@rolldown/binding-linux-x64-gnu': 1.1.3 + '@rolldown/binding-linux-x64-musl': 1.1.3 + '@rolldown/binding-openharmony-arm64': 1.1.3 + '@rolldown/binding-wasm32-wasi': 1.1.3 + '@rolldown/binding-win32-arm64-msvc': 1.1.3 + '@rolldown/binding-win32-x64-msvc': 1.1.3 + + scheduler@0.27.0: {} + + source-map-js@1.2.1: {} + + tailwindcss@4.3.2: {} + + tapable@2.3.3: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tslib@2.8.1: + optional: true + + typescript@6.0.3: {} + + undici-types@7.18.2: {} + + use-sync-external-store@1.6.0(react@19.2.7): + dependencies: + react: 19.2.7 + + vite@8.1.1(@types/node@24.13.2)(jiti@2.7.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.16 + rolldown: 1.1.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.2 + fsevents: 2.3.3 + jiti: 2.7.0 + + void-elements@3.1.0: {} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..8c8867c --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,68 @@ +import { useTranslation } from 'react-i18next' +import { useTheme, type Theme } from './lib/theme' +import { setLanguage } from './lib/i18n' + +function App() { + const { t, i18n } = useTranslation() + const { theme, setTheme } = useTheme() + + const themes: Theme[] = ['light', 'dark', 'system'] + const langs = ['ru', 'en'] + + return ( +
+
+ {t('appName')} +
+ + +
+
+ +
+

{t('appName')}

+

{t('tagline')}

+

{t('scaffoldNote')}

+ +
+
+ ) +} + +export default App diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/frontend/src/assets/hero.png differ diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..35a416b --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,46 @@ +@import 'tailwindcss'; + +/* Класс-стратегия тёмной темы: .dark на (см. theme.tsx). */ +@custom-variant dark (&:where(.dark, .dark *)); + +:root { + --background: #ffffff; + --foreground: #0a0a0f; + --muted: #f4f4f5; + --muted-foreground: #6b7280; + --border: #e5e7eb; + --primary: #7c3aed; + --primary-foreground: #ffffff; +} + +.dark { + --background: #0b0c10; + --foreground: #e5e7eb; + --muted: #17181f; + --muted-foreground: #9ca3af; + --border: #2a2c37; + --primary: #a78bfa; + --primary-foreground: #0b0c10; +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-border: var(--border); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --font-sans: system-ui, 'Segoe UI', Roboto, sans-serif; +} + +@layer base { + html { + color-scheme: light dark; + } + body { + @apply bg-background text-foreground font-sans antialiased; + margin: 0; + min-height: 100svh; + } +} diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts new file mode 100644 index 0000000..c7a20d9 --- /dev/null +++ b/frontend/src/lib/i18n.ts @@ -0,0 +1,46 @@ +import i18n from 'i18next' +import { initReactI18next } from 'react-i18next' + +const resources = { + ru: { + translation: { + appName: 'PnvPanel', + tagline: 'Self-service портал для VPN-конфигураций', + scaffoldNote: 'Каркас приложения (M0). Далее — аутентификация, ноды, конфиги (см. roadmap).', + theme: 'Тема', + language: 'Язык', + light: 'Светлая', + dark: 'Тёмная', + system: 'Системная', + }, + }, + en: { + translation: { + appName: 'PnvPanel', + tagline: 'Self-service portal for VPN configurations', + scaffoldNote: 'Application scaffold (M0). Next: authentication, nodes, configs (see roadmap).', + theme: 'Theme', + language: 'Language', + light: 'Light', + dark: 'Dark', + system: 'System', + }, + }, +} + +const STORAGE_KEY = 'pnv-lang' +const stored = typeof localStorage !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null + +void i18n.use(initReactI18next).init({ + resources, + lng: stored ?? 'ru', + fallbackLng: 'ru', + interpolation: { escapeValue: false }, +}) + +export function setLanguage(lng: string) { + localStorage.setItem(STORAGE_KEY, lng) + void i18n.changeLanguage(lng) +} + +export default i18n diff --git a/frontend/src/lib/theme.tsx b/frontend/src/lib/theme.tsx new file mode 100644 index 0000000..dedcd87 --- /dev/null +++ b/frontend/src/lib/theme.tsx @@ -0,0 +1,51 @@ +import { createContext, useContext, useEffect, useState, type ReactNode } from 'react' + +export type Theme = 'light' | 'dark' | 'system' + +type ThemeContextValue = { + theme: Theme + setTheme: (theme: Theme) => void +} + +const STORAGE_KEY = 'pnv-theme' +const ThemeContext = createContext(undefined) + +function resolve(theme: Theme): 'light' | 'dark' { + if (theme === 'system') { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' + } + return theme +} + +function applyTheme(theme: Theme) { + const root = document.documentElement + root.classList.toggle('dark', resolve(theme) === 'dark') +} + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [theme, setThemeState] = useState( + () => (localStorage.getItem(STORAGE_KEY) as Theme | null) ?? 'system', + ) + + useEffect(() => { + applyTheme(theme) + if (theme !== 'system') return + const media = window.matchMedia('(prefers-color-scheme: dark)') + const onChange = () => applyTheme('system') + media.addEventListener('change', onChange) + return () => media.removeEventListener('change', onChange) + }, [theme]) + + const setTheme = (next: Theme) => { + localStorage.setItem(STORAGE_KEY, next) + setThemeState(next) + } + + return {children} +} + +export function useTheme(): ThemeContextValue { + const ctx = useContext(ThemeContext) + if (!ctx) throw new Error('useTheme must be used within ThemeProvider') + return ctx +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..1cb057b --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,19 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import './index.css' +import './lib/i18n' +import { ThemeProvider } from './lib/theme' +import App from './App.tsx' + +const queryClient = new QueryClient() + +createRoot(document.getElementById('root')!).render( + + + + + + + , +) diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..6830b6f --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "allowArbitraryExtensions": true, + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..8455dcb --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..0989161 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + +// Бэкенд для dev-прокси (Api слушает http://localhost:8080). См. docs/frontend.md. +const apiTarget = process.env.VITE_API_TARGET ?? 'http://localhost:8080' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react(), tailwindcss()], + server: { + proxy: { + '/api': { target: apiTarget, changeOrigin: true }, + '/hubs': { target: apiTarget, changeOrigin: true, ws: true }, + }, + }, + build: { + outDir: 'dist', + }, +}) diff --git a/seed/client-apps.json b/seed/client-apps.json new file mode 100644 index 0000000..b879327 --- /dev/null +++ b/seed/client-apps.json @@ -0,0 +1,182 @@ +[ + { + "name": "Happ", + "operatingSystem": "iOS", + "downloadUrl": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215", + "description": "App Store (Global)", + "sortOrder": 10, + "isEnabled": true + }, + { + "name": "Karing", + "operatingSystem": "iOS", + "downloadUrl": "https://apps.apple.com/ru/app/karing/id6472431552", + "description": "App Store (iOS & Mac)", + "sortOrder": 20, + "isEnabled": true + }, + { + "name": "AmneziaVPN", + "operatingSystem": "iOS", + "downloadUrl": "https://apps.apple.com/us/app/amneziavpn/id1600529900", + "description": "App Store", + "sortOrder": 30, + "isEnabled": true + }, + { + "name": "OneXray", + "operatingSystem": "iOS", + "downloadUrl": "https://apps.apple.com/us/app/onexray/id6745748773", + "description": "App Store", + "sortOrder": 40, + "isEnabled": true + }, + + { + "name": "v2rayNG", + "operatingSystem": "Android", + "downloadUrl": "https://github.com/2dust/v2rayNG/releases/download/2.0.18/v2rayNG_2.0.18_universal.apk", + "description": "APK (GitHub)", + "sortOrder": 10, + "isEnabled": true + }, + { + "name": "Happ", + "operatingSystem": "Android", + "downloadUrl": "https://play.google.com/store/apps/details?id=com.happproxy", + "description": "Google Play", + "sortOrder": 20, + "isEnabled": true + }, + { + "name": "V2Box", + "operatingSystem": "Android", + "downloadUrl": "https://play.google.com/store/apps/details?id=dev.hexasoftware.v2box", + "description": "Google Play", + "sortOrder": 30, + "isEnabled": true + }, + { + "name": "v2RayTun", + "operatingSystem": "Android", + "downloadUrl": "https://play.google.com/store/apps/details?id=com.v2raytun.android", + "description": "Google Play", + "sortOrder": 40, + "isEnabled": true + }, + { + "name": "AmneziaVPN", + "operatingSystem": "Android", + "downloadUrl": "https://play.google.com/store/apps/details?id=org.amnezia.vpn", + "description": "Google Play", + "sortOrder": 50, + "isEnabled": true + }, + + { + "name": "v2rayN", + "operatingSystem": "Windows", + "downloadUrl": "https://github.com/2dust/v2rayN/releases/download/7.13.2/v2rayN-windows-64-desktop.zip", + "description": "Windows 10/11 (ZIP)", + "sortOrder": 10, + "isEnabled": true + }, + { + "name": "Throne", + "operatingSystem": "Windows", + "downloadUrl": "https://github.com/throneproj/Throne/releases/download/1.1.1/Throne-1.1.1-windows64-installer.exe", + "description": "Windows 10/11 (Installer)", + "sortOrder": 20, + "isEnabled": true + }, + { + "name": "Happ", + "operatingSystem": "Windows", + "downloadUrl": "https://github.com/Happ-proxy/happ-desktop/releases/latest/download/setup-Happ.x64.exe", + "description": "Windows 10/11 (Installer)", + "sortOrder": 30, + "isEnabled": true + }, + { + "name": "NekoBox (NekoRay)", + "operatingSystem": "Windows", + "downloadUrl": "https://github.com/MatsuriDayo/nekoray/releases/download/4.0.1/nekoray-4.0.1-2024-12-12-windows64.zip", + "description": "Windows 10/11 (Portable)", + "sortOrder": 40, + "isEnabled": true + }, + { + "name": "Karing", + "operatingSystem": "Windows", + "downloadUrl": "https://github.com/KaringX/karing/releases/download/v1.2.1.825/karing_1.2.1.825_windows_x64.exe", + "description": "Windows 10/11 (Installer)", + "sortOrder": 50, + "isEnabled": true + }, + + { + "name": "v2rayN", + "operatingSystem": "MacOS", + "downloadUrl": "https://github.com/2dust/v2rayN/releases/download/7.12.5/v2rayN-macos-arm64.dmg", + "description": "macOS ARM (DMG)", + "sortOrder": 10, + "isEnabled": true + }, + { + "name": "Karing", + "operatingSystem": "MacOS", + "downloadUrl": "https://apps.apple.com/ru/app/karing/id6472431552", + "description": "App Store (iOS & Mac)", + "sortOrder": 20, + "isEnabled": true + }, + { + "name": "AmneziaVPN", + "operatingSystem": "MacOS", + "downloadUrl": "https://github.com/amnezia-vpn/amnezia-client/releases/download/4.8.7.2/AmneziaVPN_4.8.7.2_macos.dmg", + "description": "macOS (DMG)", + "sortOrder": 30, + "isEnabled": true + }, + { + "name": "Furious", + "operatingSystem": "MacOS", + "downloadUrl": "https://github.com/LorenEteval/Furious/releases/download/0.5.0/Furious-0.5.0-macOS-12.0-arm64.dmg", + "description": "macOS ARM (DMG)", + "sortOrder": 40, + "isEnabled": true + }, + + { + "name": "NekoBox (NekoRay)", + "operatingSystem": "Linux", + "downloadUrl": "https://github.com/MatsuriDayo/nekoray/releases/download/4.0.1/nekoray-4.0.1-2024-12-12-debian-x64.deb", + "description": "Debian/Ubuntu (DEB)", + "sortOrder": 10, + "isEnabled": true + }, + { + "name": "Throne", + "operatingSystem": "Linux", + "downloadUrl": "https://github.com/throneproj/Throne/releases/download/1.1.1/Throne-1.1.1-debian-x64.deb", + "description": "Debian/Ubuntu (DEB)", + "sortOrder": 20, + "isEnabled": true + }, + { + "name": "OneXray", + "operatingSystem": "Linux", + "downloadUrl": "https://github.com/OneXray/OneXray/releases/latest/download/OneXray-linux-x86_64.deb", + "description": "Debian/Ubuntu (DEB)", + "sortOrder": 30, + "isEnabled": true + }, + { + "name": "v2rayA", + "operatingSystem": "Linux", + "downloadUrl": "https://v2raya.org/en/docs/prologue/installation/debian/", + "description": "Установка (docs)", + "sortOrder": 40, + "isEnabled": true + } +]