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

This commit is contained in:
Leonid Pershin
2026-07-01 22:38:01 +03:00
parent d8930409fe
commit 1a8d33efa3
229 changed files with 9226 additions and 20 deletions
@@ -0,0 +1,6 @@
namespace PnvPanel.Api.Common;
public static class RateLimiting
{
public const string AuthPolicy = "auth";
}
@@ -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<T>(this Result<T> 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);
}
}
@@ -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<IResult> GetStatus(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new GetActivationStatusQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> RequestActivation(RequestActivationCommand command, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> 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<IResult> Approve(Guid id, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new ApproveActivationCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> Reject(
Guid id, RejectActivationBody body, ISender sender, CancellationToken cancellationToken)
{
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);
@@ -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<IResult> ListApps(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new ListAppsQuery(), cancellationToken);
return result.ToHttpResult();
}
}
@@ -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<IResult> Register(RegisterCommand command, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> Login(LoginCommand command, ISender sender, HttpResponse response, CancellationToken cancellationToken)
{
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<IResult> 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<IResult> 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<IResult> ChangePassword(ChangePasswordCommand command, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> Me(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new GetCurrentUserQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> 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",
};
}
@@ -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<IResult> ListAvailableInbounds(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new ListAvailableInboundsQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetMyConfigs(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new GetMyConfigsQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> 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<IResult> 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<IResult> RotateConfig(Guid id, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new RotateVpnConfigCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> RevokeConfig(Guid id, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new RevokeVpnConfigCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetConfigLink(Guid id, HttpRequest request, ISender sender, CancellationToken cancellationToken)
{
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);
@@ -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<IResult> ListInbounds(Guid? nodeId, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new ListInboundsQuery(nodeId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> PublishInbound(
Guid id, PublishInboundBody body, ISender sender, CancellationToken cancellationToken)
{
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<Guid>? AllowedRoleIds, int? MaxClients);
@@ -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<IResult> ListNodes(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new ListNodesQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> RegisterNode(RegisterNodeCommand command, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateNode(Guid id, UpdateNodeBody body, ISender sender, CancellationToken cancellationToken)
{
var command = new UpdateNodeCommand(id, body.Name, body.Location, body.IsEnabled, body.Username, body.Password);
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteNode(Guid id, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new DeleteNodeCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> SyncNode(Guid id, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new SyncNodeCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ProbeNode(Guid id, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new ProbeNodeCommand(id), cancellationToken);
return result.ToHttpResult();
}
}
public sealed record UpdateNodeBody(string Name, string? Location, bool IsEnabled, string? Username, string? Password);
@@ -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<IResult> ListRoles(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new ListRolesQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateRole(CreateRoleCommand command, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateRole(Guid id, UpdateRoleBody body, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new UpdateRoleCommand(id, body.MaxConfigs), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteRole(Guid id, ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new DeleteRoleCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ChangeUserRole(Guid id, ChangeUserRoleBody body, ISender sender, CancellationToken cancellationToken)
{
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);
@@ -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<IResult> GetSubscription(string token, HttpResponse response, ISender sender, CancellationToken cancellationToken)
{
// Токен — либо AppUser.SubscriptionToken (агрегированная подписка), либо VpnConfig.SubscriptionToken
// (один конфиг). Пробуем пользовательский токен первым.
var userResult = await sender.Send(new GetUserSubscriptionQuery(token), cancellationToken);
var result = userResult.IsSuccess ? userResult : await sender.Send(new GetConfigSubscriptionQuery(token), cancellationToken);
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");
}
}
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<ProjectReference Include="..\PnvPanel.Infrastructure\PnvPanel.Infrastructure.csproj" />
<ProjectReference Include="..\PnvPanel.Application\PnvPanel.Application.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="..\..\..\seed\client-apps.json" Link="seed\client-apps.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" />
<PackageReference Include="Microsoft.OpenApi" />
<PackageReference Include="Scalar.AspNetCore" />
<PackageReference Include="Serilog.AspNetCore" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
+83
View File
@@ -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<ForwardedHeadersOptions>(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<AppDbContext>();
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();
@@ -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"
}
}
}
}
@@ -0,0 +1,12 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AdminSeed": {
"Username": "admin",
"Password": "Passw0rd!Dev"
}
}
+32
View File
@@ -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": "*"
}
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PnvPanel</title>
</head>
<body>
<main style="font-family: system-ui, sans-serif; max-width: 40rem; margin: 4rem auto; padding: 0 1rem;">
<h1>PnvPanel</h1>
<p>Каркас приложения (M0). Здесь будет собранное SPA (React + Vite).</p>
<p>
API: <a href="/scalar">/scalar</a> · Health: <a href="/health">/health</a>
</p>
</main>
</body>
</html>