Add Telegram bot integration and enhance user management features
- Introduced Telegram.Bot package for bot functionality. - Updated user management to include Telegram linking and blocking features. - Enhanced activation request handling with notifications via Telegram. - Added new database entities for Telegram link tokens and login requests. - Implemented traffic synchronization for client stats in the XuiPanelGateway. - Updated application structure to support new test projects and improved dependency injection for Telegram services.
This commit is contained in:
@@ -24,6 +24,17 @@
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
|
||||
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.19.1" />
|
||||
<PackageVersion Include="Telegram.Bot" Version="22.10.1" />
|
||||
<PackageVersion Include="ThreeXui.Net" Version="1.0.0" />
|
||||
<!-- Тестирование (M8) -->
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
<PackageVersion Include="NSubstitute" Version="5.3.0" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
|
||||
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.2.0" />
|
||||
<!-- InMemory, не Sqlite/Npgsql — модель использует Postgres-специфичные типы (uuid[], jsonb),
|
||||
не имеющие реляционных аналогов; InMemory игнорирует HasColumnType и не требует их маппинга. -->
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -5,4 +5,9 @@
|
||||
<Project Path="src/PnvPanel.Domain/PnvPanel.Domain.csproj" />
|
||||
<Project Path="src/PnvPanel.Infrastructure/PnvPanel.Infrastructure.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/PnvPanel.Domain.Tests/PnvPanel.Domain.Tests.csproj" />
|
||||
<Project Path="tests/PnvPanel.Application.Tests/PnvPanel.Application.Tests.csproj" />
|
||||
<Project Path="tests/PnvPanel.IntegrationTests/PnvPanel.IntegrationTests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using PnvPanel.Api.Common;
|
||||
using PnvPanel.Application.Admin.Apps;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Domain.Apps;
|
||||
using PnvPanel.Infrastructure.Identity;
|
||||
|
||||
namespace PnvPanel.Api.Endpoints;
|
||||
|
||||
public static class AdminAppEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapAdminAppEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var admin = app.MapGroup("/api/admin/apps")
|
||||
.WithTags("Admin.Apps")
|
||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||
|
||||
admin.MapGet("", ListApps);
|
||||
admin.MapPost("", CreateApp);
|
||||
admin.MapPut("/{id:guid}", UpdateApp);
|
||||
admin.MapDelete("/{id:guid}", DeleteApp);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListApps(ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await sender.Send(new ListAdminAppsQuery(), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateApp(CreateAppCommand command, ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateApp(Guid id, UpdateAppBody body, ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var command = new UpdateAppCommand(
|
||||
id, body.Name, body.DownloadUrl, body.OperatingSystem, body.Description, body.IconUrl, body.SortOrder, body.IsEnabled);
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteApp(Guid id, ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await sender.Send(new DeleteAppCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record UpdateAppBody(
|
||||
string Name, string DownloadUrl, OsPlatform OperatingSystem, string? Description, string? IconUrl,
|
||||
int SortOrder, bool IsEnabled);
|
||||
@@ -0,0 +1,35 @@
|
||||
using PnvPanel.Api.Common;
|
||||
using PnvPanel.Application.Admin.Audit;
|
||||
using PnvPanel.Application.Admin.Stats;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Infrastructure.Identity;
|
||||
|
||||
namespace PnvPanel.Api.Endpoints;
|
||||
|
||||
public static class AdminStatsEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapAdminStatsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var admin = app.MapGroup("/api/admin")
|
||||
.WithTags("Admin.Stats")
|
||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||
|
||||
admin.MapGet("/stats", GetStats);
|
||||
admin.MapGet("/audit", GetAudit);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetStats(ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await sender.Send(new GetStatsQuery(), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAudit(int page, int pageSize, ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = new ListAuditLogsQuery(page <= 0 ? 1 : page, pageSize <= 0 ? 50 : pageSize);
|
||||
var result = await sender.Send(query, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using PnvPanel.Api.Common;
|
||||
using PnvPanel.Application.Admin.Users;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Infrastructure.Identity;
|
||||
|
||||
namespace PnvPanel.Api.Endpoints;
|
||||
|
||||
public static class AdminUserEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapAdminUserEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var admin = app.MapGroup("/api/admin")
|
||||
.WithTags("Admin.Users")
|
||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||
|
||||
admin.MapGet("/users", ListUsers);
|
||||
admin.MapPatch("/users/{id:guid}/block", BlockUser);
|
||||
admin.MapPatch("/users/{id:guid}/unblock", UnblockUser);
|
||||
admin.MapPost("/users/{id:guid}/reset-password", ResetPassword);
|
||||
admin.MapGet("/users/{id:guid}/configs", GetUserConfigs);
|
||||
admin.MapDelete("/configs/{id:guid}", ForceRevokeConfig);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListUsers(
|
||||
int page, int pageSize, string? search, ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = new ListUsersQuery(page <= 0 ? 1 : page, pageSize <= 0 ? 20 : pageSize, search);
|
||||
var result = await sender.Send(query, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> BlockUser(Guid id, ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await sender.Send(new BlockUserCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> UnblockUser(Guid id, ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await sender.Send(new UnblockUserCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ResetPassword(Guid id, ResetPasswordBody body, ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await sender.Send(new ResetUserPasswordCommand(id, body.NewPassword), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetUserConfigs(Guid id, ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await sender.Send(new GetUserConfigsQuery(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ForceRevokeConfig(Guid id, ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await sender.Send(new ForceRevokeConfigCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ResetPasswordBody(string NewPassword);
|
||||
@@ -0,0 +1,91 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using PnvPanel.Api.Common;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Telegram;
|
||||
using PnvPanel.Infrastructure.Telegram;
|
||||
|
||||
namespace PnvPanel.Api.Endpoints;
|
||||
|
||||
public static class TelegramEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapTelegramEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/auth/telegram")
|
||||
.WithTags("Auth.Telegram")
|
||||
.RequireRateLimiting(RateLimiting.AuthPolicy);
|
||||
|
||||
group.MapPost("/link-token", CreateLinkToken).RequireAuthorization();
|
||||
group.MapPost("/unlink", Unlink).RequireAuthorization();
|
||||
group.MapPost("/login-request", CreateLoginRequest);
|
||||
group.MapGet("/login-request/{id:guid}", GetLoginRequestStatus);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateLinkToken(
|
||||
ISender sender, IOptions<TelegramOptions> options, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await sender.Send(new CreateLinkTokenCommand(), cancellationToken);
|
||||
if (!result.IsSuccess)
|
||||
return result.ToHttpResult();
|
||||
|
||||
var botUsername = options.Value.BotUsername;
|
||||
var deepLink = string.IsNullOrWhiteSpace(botUsername)
|
||||
? null
|
||||
: $"https://t.me/{botUsername}?start=link_{result.Value.Token}";
|
||||
|
||||
return Results.Ok(new { deepLink, expiresAt = result.Value.ExpiresAt });
|
||||
}
|
||||
|
||||
private static async Task<IResult> Unlink(ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await sender.Send(new UnlinkTelegramCommand(), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateLoginRequest(
|
||||
HttpRequest request, ISender sender, IOptions<TelegramOptions> options, CancellationToken cancellationToken)
|
||||
{
|
||||
var context = request.HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
var result = await sender.Send(new CreateLoginRequestCommand(context), cancellationToken);
|
||||
if (!result.IsSuccess)
|
||||
return result.ToHttpResult();
|
||||
|
||||
var botUsername = options.Value.BotUsername;
|
||||
var deepLink = string.IsNullOrWhiteSpace(botUsername)
|
||||
? null
|
||||
: $"https://t.me/{botUsername}?start=login_{result.Value.RequestId}";
|
||||
|
||||
return Results.Ok(new { requestId = result.Value.RequestId, deepLink, expiresAt = result.Value.ExpiresAt });
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetLoginRequestStatus(Guid id, HttpResponse response, ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await sender.Send(new GetLoginRequestStatusQuery(id), cancellationToken);
|
||||
if (!result.IsSuccess)
|
||||
return result.ToHttpResult();
|
||||
|
||||
if (result.Value.Auth is { } auth)
|
||||
{
|
||||
var cookieOptions = new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = true,
|
||||
SameSite = SameSiteMode.Strict,
|
||||
Path = "/api/auth",
|
||||
Expires = auth.RefreshTokenExpiresAt,
|
||||
};
|
||||
response.Cookies.Append("pnv_refresh_token", auth.RefreshToken, cookieOptions);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
status = result.Value.Status.ToString(),
|
||||
accessToken = auth.AccessToken,
|
||||
expiresAt = auth.AccessTokenExpiresAt,
|
||||
user = auth.User,
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(new { status = result.Value.Status.ToString() });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using PnvPanel.Infrastructure.Identity;
|
||||
|
||||
namespace PnvPanel.Api.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// Группы: user:{userId} (личные события — трафик/статус конфига), admins (статусы нод,
|
||||
/// запросы активации). UserIdentifier берётся из claim NameIdentifier — того же, что кладём в JWT.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
public sealed class PanelHub : Hub
|
||||
{
|
||||
public override async Task OnConnectedAsync()
|
||||
{
|
||||
if (Context.UserIdentifier is { } userId)
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, GroupNames.User(userId));
|
||||
|
||||
if (Context.User?.IsInRole(RoleNames.Admin) == true)
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, GroupNames.Admins);
|
||||
|
||||
await base.OnConnectedAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public static class GroupNames
|
||||
{
|
||||
public const string Admins = "admins";
|
||||
|
||||
public static string User(string userId) => $"user:{userId}";
|
||||
|
||||
public static string User(Guid userId) => User(userId.ToString());
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Domain.Configs;
|
||||
using PnvPanel.Domain.Nodes;
|
||||
|
||||
namespace PnvPanel.Api.Hubs;
|
||||
|
||||
internal sealed class SignalRRealtimeNotifier(IHubContext<PanelHub> hubContext) : IRealtimeNotifier
|
||||
{
|
||||
public Task NotifyConfigTrafficUpdatedAsync(
|
||||
Guid userId, Guid configId, long usedUpBytes, long usedDownBytes, CancellationToken cancellationToken)
|
||||
{
|
||||
return hubContext.Clients.Group(GroupNames.User(userId)).SendAsync(
|
||||
"configTrafficUpdated",
|
||||
new { configId, usedUpBytes, usedDownBytes },
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task NotifyConfigStatusChangedAsync(Guid userId, Guid configId, ConfigStatus status, CancellationToken cancellationToken)
|
||||
{
|
||||
return hubContext.Clients.Group(GroupNames.User(userId)).SendAsync(
|
||||
"configStatusChanged",
|
||||
new { configId, status = status.ToString() },
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task NotifyNodeStatusChangedAsync(Guid nodeId, NodeStatus status, DateTimeOffset? lastSyncAt, CancellationToken cancellationToken)
|
||||
{
|
||||
return hubContext.Clients.Group(GroupNames.Admins).SendAsync(
|
||||
"nodeStatusChanged",
|
||||
new { nodeId, status = status.ToString(), lastSyncAt },
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task NotifyActivationRequestedAsync(
|
||||
Guid requestId, Guid userId, string userName, string? comment, DateTimeOffset createdAt, CancellationToken cancellationToken)
|
||||
{
|
||||
return hubContext.Clients.Group(GroupNames.Admins).SendAsync(
|
||||
"activationRequested",
|
||||
new { requestId, userId, userName, comment, createdAt },
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task NotifyUserActivatedAsync(Guid userId, CancellationToken cancellationToken)
|
||||
{
|
||||
return hubContext.Clients.Group(GroupNames.User(userId)).SendAsync(
|
||||
"userActivated",
|
||||
new { userId },
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
<PackageReference Include="Microsoft.OpenApi" />
|
||||
<PackageReference Include="Scalar.AspNetCore" />
|
||||
<PackageReference Include="Serilog.AspNetCore" />
|
||||
<PackageReference Include="Telegram.Bot" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using PnvPanel.Api.Common;
|
||||
using PnvPanel.Api.Endpoints;
|
||||
using PnvPanel.Api.Hubs;
|
||||
using PnvPanel.Api.Telegram;
|
||||
using PnvPanel.Application;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Infrastructure;
|
||||
using PnvPanel.Infrastructure.Identity;
|
||||
using PnvPanel.Infrastructure.Persistence;
|
||||
using PnvPanel.Infrastructure.Telegram;
|
||||
using Scalar.AspNetCore;
|
||||
using Serilog;
|
||||
using Telegram.Bot;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -29,6 +35,22 @@ builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddApplication();
|
||||
builder.Services.AddInfrastructure(builder.Configuration);
|
||||
|
||||
builder.Services.AddSignalR();
|
||||
// В Api, не в Infrastructure — реализации нужен IHubContext<PanelHub>, а Hub определён здесь же.
|
||||
builder.Services.AddSingleton<IRealtimeNotifier, SignalRRealtimeNotifier>();
|
||||
|
||||
// Telegram-бот: presentation-адаптер, хостится в процессе Api (long polling). Клиент регистрируем
|
||||
// всегда (даже с пустым токеном) — TelegramBotHostedService сам решает не стартовать без токена.
|
||||
builder.Services.AddSingleton<ITelegramBotClient>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<TelegramOptions>>();
|
||||
return new TelegramBotClient(options.Value.BotToken ?? string.Empty);
|
||||
});
|
||||
// Scoped — зависит от IIdentityService (scoped), не Singleton.
|
||||
builder.Services.AddScoped<ITelegramNotifier, TelegramNotifier>();
|
||||
builder.Services.AddSingleton<PnvBotUpdateHandler>();
|
||||
builder.Services.AddHostedService<TelegramBotHostedService>();
|
||||
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
options.AddFixedWindowLimiter(RateLimiting.AuthPolicy, limiterOptions =>
|
||||
@@ -74,6 +96,12 @@ app.MapInboundEndpoints();
|
||||
app.MapConfigEndpoints();
|
||||
app.MapSubscriptionEndpoints();
|
||||
app.MapAppEndpoints();
|
||||
app.MapAdminUserEndpoints();
|
||||
app.MapAdminStatsEndpoints();
|
||||
app.MapAdminAppEndpoints();
|
||||
app.MapTelegramEndpoints();
|
||||
|
||||
app.MapHub<PanelHub>("/hubs/panel");
|
||||
|
||||
// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов.
|
||||
app.UseDefaultFiles();
|
||||
@@ -81,3 +109,6 @@ app.UseStaticFiles();
|
||||
app.MapFallbackToFile("index.html");
|
||||
|
||||
app.Run();
|
||||
|
||||
/// <summary>Делает неявный класс Program доступным для WebApplicationFactory<Program> в интеграционных тестах.</summary>
|
||||
public partial class Program;
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using PnvPanel.Application.Admin.Activation;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Configs.GetMyConfigs;
|
||||
using PnvPanel.Application.Telegram;
|
||||
using PnvPanel.Application.Telegram.Bot;
|
||||
using PnvPanel.Domain.Activation;
|
||||
using PnvPanel.Infrastructure.Telegram;
|
||||
using Telegram.Bot;
|
||||
using Telegram.Bot.Polling;
|
||||
using Telegram.Bot.Types;
|
||||
using Telegram.Bot.Types.Enums;
|
||||
using Telegram.Bot.Types.ReplyMarkups;
|
||||
|
||||
namespace PnvPanel.Api.Telegram;
|
||||
|
||||
/// <summary>
|
||||
/// Обрабатывает апдейты бота. Каждый апдейт — свой DI-scope (как HTTP-запрос), чтобы получить
|
||||
/// свежие scoped-сервисы (ISender, ICurrentUserSetter, ...). Бот — read-only по конфигам в MVP.
|
||||
/// </summary>
|
||||
public sealed class PnvBotUpdateHandler(
|
||||
IServiceScopeFactory scopeFactory, IOptions<TelegramOptions> options, ILogger<PnvBotUpdateHandler> logger)
|
||||
: IUpdateHandler
|
||||
{
|
||||
public async Task HandleUpdateAsync(ITelegramBotClient botClient, Update update, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
|
||||
try
|
||||
{
|
||||
if (update.Message is { Text: { } text } message)
|
||||
await HandleMessageAsync(botClient, scope.ServiceProvider, message, text, cancellationToken);
|
||||
else if (update.CallbackQuery is { } callback)
|
||||
await HandleCallbackAsync(botClient, scope.ServiceProvider, callback, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Ошибка обработки Telegram-апдейта {UpdateId}", update.Id);
|
||||
}
|
||||
}
|
||||
|
||||
public Task HandleErrorAsync(ITelegramBotClient botClient, Exception exception, HandleErrorSource source, CancellationToken cancellationToken)
|
||||
{
|
||||
logger.LogError(exception, "Ошибка Telegram-бота (источник {Source})", source);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task HandleMessageAsync(
|
||||
ITelegramBotClient botClient, IServiceProvider services, Message message, string text, CancellationToken cancellationToken)
|
||||
{
|
||||
var chatId = message.Chat.Id;
|
||||
var fromId = message.From?.Id;
|
||||
if (fromId is null)
|
||||
return;
|
||||
|
||||
if (text.StartsWith("/start", StringComparison.Ordinal))
|
||||
{
|
||||
var payload = text.Length > 7 ? text[7..].Trim() : string.Empty;
|
||||
|
||||
if (payload.StartsWith("link_", StringComparison.Ordinal))
|
||||
await HandleLinkAsync(botClient, services, chatId, fromId.Value, message.From?.Username, payload[5..], cancellationToken);
|
||||
else if (payload.StartsWith("login_", StringComparison.Ordinal))
|
||||
await HandleLoginPromptAsync(botClient, services, chatId, fromId.Value, payload[6..], cancellationToken);
|
||||
else
|
||||
await SendWelcomeAsync(botClient, chatId, cancellationToken);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
switch (text)
|
||||
{
|
||||
case "/configs":
|
||||
await HandleConfigsAsync(botClient, services, chatId, fromId.Value, cancellationToken);
|
||||
break;
|
||||
case "/unlink":
|
||||
await HandleUnlinkAsync(botClient, services, chatId, fromId.Value, cancellationToken);
|
||||
break;
|
||||
case "/requests":
|
||||
await HandleRequestsAsync(botClient, services, chatId, fromId.Value, cancellationToken);
|
||||
break;
|
||||
case "/help":
|
||||
await SendWelcomeAsync(botClient, chatId, cancellationToken);
|
||||
break;
|
||||
default:
|
||||
await botClient.SendMessage(chatId, "Не понимаю эту команду. /help — список команд.", cancellationToken: cancellationToken);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleCallbackAsync(
|
||||
ITelegramBotClient botClient, IServiceProvider services, CallbackQuery callback, CancellationToken cancellationToken)
|
||||
{
|
||||
var data = callback.Data;
|
||||
var chatId = callback.Message?.Chat.Id;
|
||||
var fromId = callback.From.Id;
|
||||
if (data is null || chatId is null)
|
||||
return;
|
||||
|
||||
var parts = data.Split(':');
|
||||
if (parts.Length != 3 || !Guid.TryParse(parts[2], out var requestId))
|
||||
return;
|
||||
|
||||
var sender = services.GetRequiredService<ISender>();
|
||||
|
||||
switch (parts[0])
|
||||
{
|
||||
case "login":
|
||||
{
|
||||
var result = parts[1] == "approve"
|
||||
? await sender.Send(new ApproveTelegramLoginCommand(requestId, fromId), cancellationToken)
|
||||
: await sender.Send(new RejectTelegramLoginCommand(requestId, fromId), cancellationToken);
|
||||
|
||||
await botClient.AnswerCallbackQuery(callback.Id, result.IsSuccess ? "Готово" : result.Error.Message, cancellationToken: cancellationToken);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
var text = parts[1] == "approve" ? "✅ Вход подтверждён." : "❌ Вход отклонён.";
|
||||
await botClient.SendMessage(chatId.Value, text, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "act":
|
||||
{
|
||||
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
|
||||
{
|
||||
await botClient.AnswerCallbackQuery(callback.Id, "Недостаточно прав.", cancellationToken: cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var result = parts[1] == "approve"
|
||||
? await sender.Send(new ApproveActivationCommand(requestId), cancellationToken)
|
||||
: await sender.Send(new RejectActivationCommand(requestId, Reason: null), cancellationToken);
|
||||
|
||||
await botClient.AnswerCallbackQuery(callback.Id, result.IsSuccess ? "Готово" : result.Error.Message, cancellationToken: cancellationToken);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
var text = parts[1] == "approve" ? "✅ Пользователь активирован." : "❌ Запрос отклонён.";
|
||||
await botClient.SendMessage(chatId.Value, text, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleLinkAsync(
|
||||
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, string? username, string token,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sender = services.GetRequiredService<ISender>();
|
||||
var result = await sender.Send(new LinkTelegramCommand(token, fromId, username), cancellationToken);
|
||||
|
||||
var text = result.IsSuccess
|
||||
? "✅ Telegram успешно привязан к вашему аккаунту."
|
||||
: $"❌ Не удалось привязать: {result.Error.Message}";
|
||||
|
||||
await botClient.SendMessage(chatId, text, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
private async Task HandleLoginPromptAsync(
|
||||
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, string requestIdRaw, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Guid.TryParse(requestIdRaw, out var requestId))
|
||||
{
|
||||
await botClient.SendMessage(chatId, "Некорректная ссылка входа.", cancellationToken: cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var identityService = services.GetRequiredService<IIdentityService>();
|
||||
var userId = await identityService.FindUserIdByTelegramUserIdAsync(fromId, cancellationToken);
|
||||
if (userId is null)
|
||||
{
|
||||
await botClient.SendMessage(chatId, "Сначала привяжите Telegram к аккаунту на сайте.", cancellationToken: cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var keyboard = new InlineKeyboardMarkup(new[]
|
||||
{
|
||||
InlineKeyboardButton.WithCallbackData("✅ Подтвердить вход", $"login:approve:{requestId}"),
|
||||
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"login:reject:{requestId}"),
|
||||
});
|
||||
|
||||
await botClient.SendMessage(
|
||||
chatId, "Кто-то пытается войти в PnvPanel через ваш аккаунт. Подтвердить вход?",
|
||||
replyMarkup: keyboard, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
private async Task HandleConfigsAsync(
|
||||
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
|
||||
{
|
||||
await botClient.SendMessage(
|
||||
chatId, "Сначала привяжите Telegram к аккаунту на сайте (Настройки → Привязать Telegram).",
|
||||
cancellationToken: cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var sender = services.GetRequiredService<ISender>();
|
||||
var result = await sender.Send(new GetMyConfigsQuery(), cancellationToken);
|
||||
|
||||
if (!result.IsSuccess || result.Value.Configs.Count == 0)
|
||||
{
|
||||
await botClient.SendMessage(chatId, "У вас пока нет конфигов.", cancellationToken: cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var lines = result.Value.Configs.Select(c =>
|
||||
$"• {c.Label ?? c.Location} ({c.Protocol}) — {c.Status}");
|
||||
await botClient.SendMessage(chatId, "Ваши конфиги:\n" + string.Join('\n', lines), cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
private async Task HandleUnlinkAsync(
|
||||
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
|
||||
{
|
||||
await botClient.SendMessage(chatId, "Telegram не привязан.", cancellationToken: cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var sender = services.GetRequiredService<ISender>();
|
||||
var result = await sender.Send(new UnlinkTelegramCommand(), cancellationToken);
|
||||
|
||||
await botClient.SendMessage(
|
||||
chatId, result.IsSuccess ? "Telegram отвязан от аккаунта." : $"Ошибка: {result.Error.Message}",
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
private async Task HandleRequestsAsync(
|
||||
ITelegramBotClient botClient, IServiceProvider services, long chatId, long fromId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
|
||||
{
|
||||
await botClient.SendMessage(chatId, "Недостаточно прав.", cancellationToken: cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var sender = services.GetRequiredService<ISender>();
|
||||
var result = await sender.Send(new ListActivationRequestsQuery(ActivationStatus.Pending, 1, 10), cancellationToken);
|
||||
|
||||
if (!result.IsSuccess || result.Value.Items.Count == 0)
|
||||
{
|
||||
await botClient.SendMessage(chatId, "Нет ожидающих запросов на активацию.", cancellationToken: cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var item in result.Value.Items)
|
||||
{
|
||||
var text = $"Запрос от <b>{item.UserName}</b>" + (string.IsNullOrWhiteSpace(item.Comment) ? "" : $"\n{item.Comment}");
|
||||
var keyboard = new InlineKeyboardMarkup(new[]
|
||||
{
|
||||
InlineKeyboardButton.WithCallbackData("✅ Активировать", $"act:approve:{item.Id}"),
|
||||
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"act:reject:{item.Id}"),
|
||||
});
|
||||
await botClient.SendMessage(chatId, text, parseMode: ParseMode.Html, replyMarkup: keyboard, cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task SendWelcomeAsync(ITelegramBotClient botClient, long chatId, CancellationToken cancellationToken)
|
||||
{
|
||||
const string text = "Привет! Это бот PnvPanel.\n\n"
|
||||
+ "/configs — мои конфиги\n"
|
||||
+ "/login — войти на сайт без пароля\n"
|
||||
+ "/unlink — отвязать Telegram\n"
|
||||
+ "/help — эта справка";
|
||||
await botClient.SendMessage(chatId, text, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<bool> TrySetCurrentUserAsync(IServiceProvider services, long telegramUserId, CancellationToken cancellationToken)
|
||||
{
|
||||
var identityService = services.GetRequiredService<IIdentityService>();
|
||||
var userId = await identityService.FindUserIdByTelegramUserIdAsync(telegramUserId, cancellationToken);
|
||||
if (userId is null)
|
||||
return false;
|
||||
|
||||
var profile = await identityService.GetProfileAsync(userId.Value, cancellationToken);
|
||||
if (profile is null)
|
||||
return false;
|
||||
|
||||
services.GetRequiredService<ICurrentUserSetter>().SetUser(profile.Id, profile.UserName);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<bool> TrySetAdminCurrentUserAsync(IServiceProvider services, long telegramUserId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!options.Value.ParseAdminTelegramUserIds().Contains(telegramUserId))
|
||||
return false;
|
||||
|
||||
return await TrySetCurrentUserAsync(services, telegramUserId, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using PnvPanel.Infrastructure.Telegram;
|
||||
using Telegram.Bot;
|
||||
using Telegram.Bot.Polling;
|
||||
using Telegram.Bot.Types;
|
||||
using Telegram.Bot.Types.Enums;
|
||||
|
||||
namespace PnvPanel.Api.Telegram;
|
||||
|
||||
/// <summary>
|
||||
/// Бот — presentation-адаптер, хостится в процессе Api (long polling). Если BotToken не задан,
|
||||
/// не стартует — панель работает без бота. Апдейты обрабатывает PnvBotUpdateHandler, который
|
||||
/// вызывает те же CQRS-команды, что и веб, через собственный ISender.
|
||||
/// </summary>
|
||||
public sealed class TelegramBotHostedService(
|
||||
ITelegramBotClient botClient, PnvBotUpdateHandler updateHandler, IOptions<TelegramOptions> options,
|
||||
ILogger<TelegramBotHostedService> logger)
|
||||
: BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
|
||||
{
|
||||
logger.LogWarning("Telegram__BotToken не задан — бот не стартует.");
|
||||
return;
|
||||
}
|
||||
|
||||
var receiverOptions = new ReceiverOptions
|
||||
{
|
||||
AllowedUpdates = [UpdateType.Message, UpdateType.CallbackQuery],
|
||||
DropPendingUpdates = true,
|
||||
};
|
||||
|
||||
logger.LogInformation("Telegram-бот запускается (long polling)...");
|
||||
|
||||
try
|
||||
{
|
||||
await botClient.ReceiveAsync(updateHandler, receiverOptions, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
// Штатная остановка вместе с приложением.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Infrastructure.Telegram;
|
||||
using Telegram.Bot;
|
||||
using Telegram.Bot.Types.Enums;
|
||||
using Telegram.Bot.Types.ReplyMarkups;
|
||||
|
||||
namespace PnvPanel.Api.Telegram;
|
||||
|
||||
internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentityService identityService, IOptions<TelegramOptions> options)
|
||||
: ITelegramNotifier
|
||||
{
|
||||
public async Task NotifyAdminsActivationRequestedAsync(
|
||||
Guid requestId, string userName, string? comment, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
|
||||
return;
|
||||
|
||||
var text = $"🆕 Запрос на активацию от <b>{Escape(userName)}</b>"
|
||||
+ (string.IsNullOrWhiteSpace(comment) ? string.Empty : $"\nКомментарий: {Escape(comment)}");
|
||||
|
||||
var keyboard = new InlineKeyboardMarkup(new[]
|
||||
{
|
||||
InlineKeyboardButton.WithCallbackData("✅ Активировать", $"act:approve:{requestId}"),
|
||||
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"act:reject:{requestId}"),
|
||||
});
|
||||
|
||||
foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
|
||||
{
|
||||
try
|
||||
{
|
||||
await botClient.SendMessage(
|
||||
adminId, text, parseMode: ParseMode.Html, replyMarkup: keyboard, cancellationToken: cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Админ мог не запускать бота (нет чата с ботом) — пропускаем, не валим команду.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task NotifyUserAsync(Guid userId, string message, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
|
||||
return;
|
||||
|
||||
var link = await identityService.GetTelegramLinkInfoAsync(userId, cancellationToken);
|
||||
if (!link.IsLinked || link.TelegramUserId is not { } telegramUserId)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await botClient.SendMessage(telegramUserId, message, cancellationToken: cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Пользователь мог заблокировать бота — не критично для основной операции.
|
||||
}
|
||||
}
|
||||
|
||||
private static string Escape(string text) => text.Replace("&", "&").Replace("<", "<").Replace(">", ">");
|
||||
}
|
||||
@@ -7,7 +7,8 @@ using PnvPanel.Domain.Activation;
|
||||
|
||||
namespace PnvPanel.Application.Activation;
|
||||
|
||||
public sealed class RequestActivationCommandHandler(IAppDbContext dbContext, ICurrentUser currentUser)
|
||||
public sealed class RequestActivationCommandHandler(
|
||||
IAppDbContext dbContext, IRealtimeNotifier notifier, ITelegramNotifier telegramNotifier, ICurrentUser currentUser)
|
||||
: ICommandHandler<RequestActivationCommand, Result<ActivationRequestDto>>
|
||||
{
|
||||
public async Task<Result<ActivationRequestDto>> Handle(RequestActivationCommand command, CancellationToken cancellationToken)
|
||||
@@ -24,6 +25,11 @@ public sealed class RequestActivationCommandHandler(IAppDbContext dbContext, ICu
|
||||
var request = ActivationRequest.Create(userId, command.Comment);
|
||||
dbContext.ActivationRequests.Add(request);
|
||||
|
||||
var userName = currentUser.UserName ?? userId.ToString();
|
||||
|
||||
await notifier.NotifyActivationRequestedAsync(request.Id, userId, userName, request.Comment, request.CreatedAt, cancellationToken);
|
||||
await telegramNotifier.NotifyAdminsActivationRequestedAsync(request.Id, userName, request.Comment, cancellationToken);
|
||||
|
||||
return Result.Success(new ActivationRequestDto(request.Id, request.Comment, request.CreatedAt));
|
||||
}
|
||||
}
|
||||
|
||||
+8
-2
@@ -8,7 +8,8 @@ using PnvPanel.Domain.Activation;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Activation;
|
||||
|
||||
public sealed class ApproveActivationCommandHandler(IAppDbContext dbContext, IIdentityService identityService, ICurrentUser currentUser)
|
||||
public sealed class ApproveActivationCommandHandler(
|
||||
IAppDbContext dbContext, IIdentityService identityService, IRealtimeNotifier notifier, ICurrentUser currentUser)
|
||||
: ICommandHandler<ApproveActivationCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(ApproveActivationCommand command, CancellationToken cancellationToken)
|
||||
@@ -27,6 +28,11 @@ public sealed class ApproveActivationCommandHandler(IAppDbContext dbContext, IId
|
||||
|
||||
request.Approve(adminId);
|
||||
|
||||
return await identityService.ActivateUserAsync(request.UserId, adminId, cancellationToken);
|
||||
var activateResult = await identityService.ActivateUserAsync(request.UserId, adminId, cancellationToken);
|
||||
if (!activateResult.IsSuccess)
|
||||
return activateResult;
|
||||
|
||||
await notifier.NotifyUserActivatedAsync(request.UserId, cancellationToken);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using PnvPanel.Domain.Apps;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Apps;
|
||||
|
||||
public sealed record AdminAppDto(
|
||||
Guid Id, string Name, string DownloadUrl, OsPlatform OperatingSystem, string? Description,
|
||||
string? IconUrl, int SortOrder, bool IsEnabled)
|
||||
{
|
||||
public static AdminAppDto FromDomain(ClientApp app) => new(
|
||||
app.Id, app.Name, app.DownloadUrl.ToString(), app.OperatingSystem, app.Description,
|
||||
app.IconUrl, app.SortOrder, app.IsEnabled);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Apps;
|
||||
|
||||
public static class AppErrors
|
||||
{
|
||||
public static readonly Error NotFound = Error.NotFound("Apps.NotFound", "Приложение не найдено.");
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Domain.Apps;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Apps;
|
||||
|
||||
public sealed record CreateAppCommand(
|
||||
string Name, string DownloadUrl, OsPlatform OperatingSystem, string? Description, string? IconUrl, int SortOrder)
|
||||
: ICommand<Result<AdminAppDto>>;
|
||||
@@ -0,0 +1,20 @@
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Domain.Apps;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Apps;
|
||||
|
||||
public sealed class CreateAppCommandHandler(IAppDbContext dbContext) : ICommandHandler<CreateAppCommand, Result<AdminAppDto>>
|
||||
{
|
||||
public Task<Result<AdminAppDto>> Handle(CreateAppCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var app = ClientApp.Create(
|
||||
command.Name, new Uri(command.DownloadUrl, UriKind.Absolute), command.OperatingSystem,
|
||||
command.Description, command.IconUrl, command.SortOrder);
|
||||
|
||||
dbContext.ClientApps.Add(app);
|
||||
|
||||
return Task.FromResult(Result.Success(AdminAppDto.FromDomain(app)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Apps;
|
||||
|
||||
public sealed class CreateAppCommandValidator : AbstractValidator<CreateAppCommand>
|
||||
{
|
||||
public CreateAppCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
|
||||
RuleFor(x => x.DownloadUrl).NotEmpty().MaximumLength(500);
|
||||
RuleFor(x => x.OperatingSystem).IsInEnum();
|
||||
RuleFor(x => x.Description).MaximumLength(300);
|
||||
RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Apps;
|
||||
|
||||
public sealed record DeleteAppCommand(Guid AppId) : ICommand<Result>;
|
||||
@@ -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.Apps;
|
||||
|
||||
public sealed class DeleteAppCommandHandler(IAppDbContext dbContext) : ICommandHandler<DeleteAppCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(DeleteAppCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var app = await dbContext.ClientApps.FirstOrDefaultAsync(a => a.Id == command.AppId, cancellationToken);
|
||||
if (app is null)
|
||||
return Result.Failure(AppErrors.NotFound);
|
||||
|
||||
dbContext.ClientApps.Remove(app);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Apps;
|
||||
|
||||
public sealed record ListAdminAppsQuery : IQuery<Result<IReadOnlyList<AdminAppDto>>>;
|
||||
@@ -0,0 +1,18 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Apps;
|
||||
|
||||
public sealed class ListAdminAppsQueryHandler(IAppDbContext dbContext) : IQueryHandler<ListAdminAppsQuery, Result<IReadOnlyList<AdminAppDto>>>
|
||||
{
|
||||
public async Task<Result<IReadOnlyList<AdminAppDto>>> Handle(ListAdminAppsQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var apps = await dbContext.ClientApps.AsNoTracking()
|
||||
.OrderBy(a => a.OperatingSystem).ThenBy(a => a.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Result.Success<IReadOnlyList<AdminAppDto>>(apps.Select(AdminAppDto.FromDomain).ToList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Domain.Apps;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Apps;
|
||||
|
||||
public sealed record UpdateAppCommand(
|
||||
Guid AppId, string Name, string DownloadUrl, OsPlatform OperatingSystem, string? Description,
|
||||
string? IconUrl, int SortOrder, bool IsEnabled)
|
||||
: ICommand<Result<AdminAppDto>>;
|
||||
@@ -0,0 +1,22 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Apps;
|
||||
|
||||
public sealed class UpdateAppCommandHandler(IAppDbContext dbContext) : ICommandHandler<UpdateAppCommand, Result<AdminAppDto>>
|
||||
{
|
||||
public async Task<Result<AdminAppDto>> Handle(UpdateAppCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var app = await dbContext.ClientApps.FirstOrDefaultAsync(a => a.Id == command.AppId, cancellationToken);
|
||||
if (app is null)
|
||||
return Result.Failure<AdminAppDto>(AppErrors.NotFound);
|
||||
|
||||
app.Update(
|
||||
command.Name, new Uri(command.DownloadUrl, UriKind.Absolute), command.OperatingSystem,
|
||||
command.Description, command.IconUrl, command.SortOrder, command.IsEnabled);
|
||||
|
||||
return Result.Success(AdminAppDto.FromDomain(app));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Apps;
|
||||
|
||||
public sealed class UpdateAppCommandValidator : AbstractValidator<UpdateAppCommand>
|
||||
{
|
||||
public UpdateAppCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
|
||||
RuleFor(x => x.DownloadUrl).NotEmpty().MaximumLength(500);
|
||||
RuleFor(x => x.OperatingSystem).IsInEnum();
|
||||
RuleFor(x => x.Description).MaximumLength(300);
|
||||
RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Domain.Audit;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Audit;
|
||||
|
||||
public sealed record ListAuditLogsQuery(int Page, int PageSize) : IQuery<Result<PagedList<AuditLogDto>>>;
|
||||
|
||||
public sealed record AuditLogDto(
|
||||
long Id, Guid? ActorId, string Action, string TargetType, string TargetId, string? Metadata,
|
||||
AuditSource Source, DateTimeOffset CreatedAt);
|
||||
@@ -0,0 +1,22 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Audit;
|
||||
|
||||
public sealed class ListAuditLogsQueryHandler(IAppDbContext dbContext) : IQueryHandler<ListAuditLogsQuery, Result<PagedList<AuditLogDto>>>
|
||||
{
|
||||
public async Task<Result<PagedList<AuditLogDto>>> Handle(ListAuditLogsQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var page = query.Page <= 0 ? 1 : query.Page;
|
||||
var pageSize = query.PageSize is <= 0 or > 200 ? 50 : query.PageSize;
|
||||
|
||||
var result = await dbContext.AuditLogs.AsNoTracking()
|
||||
.OrderByDescending(a => a.CreatedAt)
|
||||
.Select(a => new AuditLogDto(a.Id, a.ActorId, a.Action, a.TargetType, a.TargetId, a.Metadata, a.Source, a.CreatedAt))
|
||||
.ToPagedListAsync(page, pageSize, cancellationToken);
|
||||
|
||||
return Result.Success(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Stats;
|
||||
|
||||
public sealed record GetStatsQuery : IQuery<Result<StatsDto>>;
|
||||
|
||||
public sealed record StatsDto(
|
||||
int TotalUsers, int ActivatedUsers, int PendingActivationRequests,
|
||||
int TotalNodes, int OnlineNodes, int TotalConfigs, int ActiveConfigs,
|
||||
long TotalUsedUpBytes, long TotalUsedDownBytes);
|
||||
@@ -0,0 +1,37 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Domain.Activation;
|
||||
using PnvPanel.Domain.Configs;
|
||||
using PnvPanel.Domain.Nodes;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Stats;
|
||||
|
||||
public sealed class GetStatsQueryHandler(IAppDbContext dbContext, IIdentityService identityService)
|
||||
: IQueryHandler<GetStatsQuery, Result<StatsDto>>
|
||||
{
|
||||
public async Task<Result<StatsDto>> Handle(GetStatsQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var userStats = await identityService.GetUserStatsAsync(cancellationToken);
|
||||
|
||||
var pendingActivations = await dbContext.ActivationRequests
|
||||
.CountAsync(r => r.Status == ActivationStatus.Pending, cancellationToken);
|
||||
|
||||
var totalNodes = await dbContext.Nodes.CountAsync(cancellationToken);
|
||||
var onlineNodes = await dbContext.Nodes.CountAsync(n => n.Status == NodeStatus.Online, cancellationToken);
|
||||
|
||||
var totalConfigs = await dbContext.VpnConfigs.CountAsync(cancellationToken);
|
||||
var activeConfigs = await dbContext.VpnConfigs.CountAsync(c => c.Status == ConfigStatus.Active, cancellationToken);
|
||||
|
||||
var trafficTotals = await dbContext.VpnConfigs
|
||||
.GroupBy(_ => 1)
|
||||
.Select(g => new { Up = g.Sum(c => c.UsedUpBytes), Down = g.Sum(c => c.UsedDownBytes) })
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
return Result.Success(new StatsDto(
|
||||
userStats.Total, userStats.Activated, pendingActivations,
|
||||
totalNodes, onlineNodes, totalConfigs, activeConfigs,
|
||||
trafficTotals?.Up ?? 0, trafficTotals?.Down ?? 0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Users;
|
||||
|
||||
public sealed record BlockUserCommand(Guid UserId) : ICommand<Result>;
|
||||
@@ -0,0 +1,49 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Domain.Audit;
|
||||
using PnvPanel.Domain.Configs;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Users;
|
||||
|
||||
/// <summary>Блокировка гасит все активные конфиги в 3x-ui (см. architecture.md).</summary>
|
||||
public sealed class BlockUserCommandHandler(
|
||||
IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway,
|
||||
IRealtimeNotifier notifier, ICurrentUser currentUser)
|
||||
: ICommandHandler<BlockUserCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(BlockUserCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var blockResult = await identityService.BlockUserAsync(command.UserId, cancellationToken);
|
||||
if (!blockResult.IsSuccess)
|
||||
return blockResult;
|
||||
|
||||
var configs = await dbContext.VpnConfigs
|
||||
.Where(c => c.UserId == command.UserId && c.Status == ConfigStatus.Active)
|
||||
.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.UpdateClientAsync(
|
||||
node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol,
|
||||
config.Label ?? config.ClientEmail, config.DeviceLimit, enable: false, cancellationToken);
|
||||
}
|
||||
|
||||
config.Disable();
|
||||
await notifier.NotifyConfigStatusChangedAsync(config.UserId, config.Id, config.Status, cancellationToken);
|
||||
}
|
||||
|
||||
dbContext.AuditLogs.Add(AuditLog.Create(
|
||||
currentUser.UserId, "UserBlocked", "User", command.UserId.ToString(), metadata: null, AuditSource.Web));
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Users;
|
||||
|
||||
public sealed record ForceRevokeConfigCommand(Guid ConfigId) : ICommand<Result>;
|
||||
@@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Application.Configs;
|
||||
using PnvPanel.Domain.Audit;
|
||||
using PnvPanel.Domain.Configs;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Users;
|
||||
|
||||
public sealed class ForceRevokeConfigCommandHandler(
|
||||
IAppDbContext dbContext, IXuiPanelGateway gateway, IRealtimeNotifier notifier, ICurrentUser currentUser)
|
||||
: ICommandHandler<ForceRevokeConfigCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(ForceRevokeConfigCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var config = await dbContext.VpnConfigs.FirstOrDefaultAsync(c => c.Id == command.ConfigId, 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();
|
||||
await notifier.NotifyConfigStatusChangedAsync(config.UserId, config.Id, config.Status, cancellationToken);
|
||||
|
||||
dbContext.AuditLogs.Add(AuditLog.Create(
|
||||
currentUser.UserId, "ConfigForceRevoked", "VpnConfig", config.Id.ToString(), metadata: null, AuditSource.Web));
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Application.Configs;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Users;
|
||||
|
||||
public sealed record GetUserConfigsQuery(Guid UserId) : IQuery<Result<IReadOnlyList<VpnConfigDto>>>;
|
||||
@@ -0,0 +1,24 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Application.Configs;
|
||||
using PnvPanel.Domain.Configs;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Users;
|
||||
|
||||
public sealed class GetUserConfigsQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<GetUserConfigsQuery, Result<IReadOnlyList<VpnConfigDto>>>
|
||||
{
|
||||
public async Task<Result<IReadOnlyList<VpnConfigDto>>> Handle(GetUserConfigsQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var rows = await dbContext.VpnConfigs.AsNoTracking()
|
||||
.Where(c => c.UserId == query.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<IReadOnlyList<VpnConfigDto>>(dtos);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Users;
|
||||
|
||||
public sealed record ListUsersQuery(int Page, int PageSize, string? Search) : IQuery<Result<PagedList<UserSummaryDto>>>;
|
||||
@@ -0,0 +1,18 @@
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Users;
|
||||
|
||||
public sealed class ListUsersQueryHandler(IIdentityService identityService)
|
||||
: IQueryHandler<ListUsersQuery, Result<PagedList<UserSummaryDto>>>
|
||||
{
|
||||
public async Task<Result<PagedList<UserSummaryDto>>> Handle(ListUsersQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var page = query.Page <= 0 ? 1 : query.Page;
|
||||
var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize;
|
||||
|
||||
var result = await identityService.ListUsersAsync(page, pageSize, query.Search, cancellationToken);
|
||||
return Result.Success(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Users;
|
||||
|
||||
public sealed record ResetUserPasswordCommand(Guid UserId, string NewPassword) : ICommand<Result>;
|
||||
@@ -0,0 +1,23 @@
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Domain.Audit;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Users;
|
||||
|
||||
public sealed class ResetUserPasswordCommandHandler(IAppDbContext dbContext, IIdentityService identityService, ICurrentUser currentUser)
|
||||
: ICommandHandler<ResetUserPasswordCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(ResetUserPasswordCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await identityService.ResetPasswordAsync(command.UserId, command.NewPassword, cancellationToken);
|
||||
if (!result.IsSuccess)
|
||||
return result;
|
||||
|
||||
dbContext.AuditLogs.Add(AuditLog.Create(
|
||||
currentUser.UserId, "UserPasswordReset", "User", command.UserId.ToString(), metadata: null, AuditSource.Web));
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Users;
|
||||
|
||||
public sealed record UnblockUserCommand(Guid UserId) : ICommand<Result>;
|
||||
@@ -0,0 +1,49 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Domain.Audit;
|
||||
using PnvPanel.Domain.Configs;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Users;
|
||||
|
||||
/// <summary>Разблокировка возвращает в 3x-ui только конфиги, погашенные блокировкой (Disabled).</summary>
|
||||
public sealed class UnblockUserCommandHandler(
|
||||
IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway,
|
||||
IRealtimeNotifier notifier, ICurrentUser currentUser)
|
||||
: ICommandHandler<UnblockUserCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(UnblockUserCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var unblockResult = await identityService.UnblockUserAsync(command.UserId, cancellationToken);
|
||||
if (!unblockResult.IsSuccess)
|
||||
return unblockResult;
|
||||
|
||||
var configs = await dbContext.VpnConfigs
|
||||
.Where(c => c.UserId == command.UserId && c.Status == ConfigStatus.Disabled)
|
||||
.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.UpdateClientAsync(
|
||||
node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol,
|
||||
config.Label ?? config.ClientEmail, config.DeviceLimit, enable: true, cancellationToken);
|
||||
}
|
||||
|
||||
config.Enable();
|
||||
await notifier.NotifyConfigStatusChangedAsync(config.UserId, config.Id, config.Status, cancellationToken);
|
||||
}
|
||||
|
||||
dbContext.AuditLogs.Add(AuditLog.Create(
|
||||
currentUser.UserId, "UserUnblocked", "User", command.UserId.ToString(), metadata: null, AuditSource.Web));
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -18,4 +18,7 @@ public static class AuthErrors
|
||||
|
||||
public static readonly Error Unauthorized =
|
||||
Error.Unauthorized("Auth.Unauthorized", "Требуется аутентификация.");
|
||||
|
||||
public static readonly Error UserBlocked =
|
||||
Error.Forbidden("Auth.UserBlocked", "Аккаунт заблокирован администратором.");
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
namespace PnvPanel.Application.Auth;
|
||||
|
||||
public sealed record CurrentUserDto(Guid Id, string UserName, string Role, bool IsActivated);
|
||||
public sealed record CurrentUserDto(Guid Id, string UserName, string Role, bool IsActivated, bool TelegramLinked);
|
||||
|
||||
@@ -23,7 +23,8 @@ public sealed class LoginCommandHandler(
|
||||
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);
|
||||
var telegramInfo = await identityService.GetTelegramLinkInfoAsync(profile.Id, cancellationToken);
|
||||
var dto = new CurrentUserDto(profile.Id, profile.UserName, profile.Role, profile.IsActivated, telegramInfo.IsLinked);
|
||||
|
||||
return Result.Success(new AuthResult(
|
||||
accessToken,
|
||||
|
||||
@@ -16,6 +16,8 @@ public sealed class GetCurrentUserQueryHandler(IIdentityService identityService,
|
||||
if (profile is null)
|
||||
return Result.Failure<CurrentUserDto>(AuthErrors.Unauthorized);
|
||||
|
||||
return Result.Success(new CurrentUserDto(profile.Id, profile.UserName, profile.Role, profile.IsActivated));
|
||||
var telegramInfo = await identityService.GetTelegramLinkInfoAsync(userId, cancellationToken);
|
||||
|
||||
return Result.Success(new CurrentUserDto(profile.Id, profile.UserName, profile.Role, profile.IsActivated, telegramInfo.IsLinked));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,8 @@ public sealed class RefreshCommandHandler(
|
||||
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);
|
||||
var telegramInfo = await identityService.GetTelegramLinkInfoAsync(profile.Id, cancellationToken);
|
||||
var dto = new CurrentUserDto(profile.Id, profile.UserName, profile.Role, profile.IsActivated, telegramInfo.IsLinked);
|
||||
|
||||
return Result.Success(new AuthResult(
|
||||
accessToken,
|
||||
|
||||
@@ -2,9 +2,11 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using PnvPanel.Domain.Activation;
|
||||
using PnvPanel.Domain.Apps;
|
||||
using PnvPanel.Domain.Audit;
|
||||
using PnvPanel.Domain.Configs;
|
||||
using PnvPanel.Domain.Inbounds;
|
||||
using PnvPanel.Domain.Nodes;
|
||||
using PnvPanel.Domain.Telegram;
|
||||
|
||||
namespace PnvPanel.Application.Common.Interfaces;
|
||||
|
||||
@@ -12,12 +14,20 @@ public interface IAppDbContext
|
||||
{
|
||||
DbSet<ActivationRequest> ActivationRequests { get; }
|
||||
|
||||
DbSet<AuditLog> AuditLogs { get; }
|
||||
|
||||
DbSet<TelegramLinkToken> TelegramLinkTokens { get; }
|
||||
|
||||
DbSet<TelegramLoginRequest> TelegramLoginRequests { get; }
|
||||
|
||||
DbSet<Node> Nodes { get; }
|
||||
|
||||
DbSet<Inbound> Inbounds { get; }
|
||||
|
||||
DbSet<VpnConfig> VpnConfigs { get; }
|
||||
|
||||
DbSet<TrafficSample> TrafficSamples { get; }
|
||||
|
||||
DbSet<ClientApp> ClientApps { get; }
|
||||
|
||||
/// <summary>Нужен для advisory-lock при проверке квоты конфигов (см. CreateVpnConfigCommandHandler).</summary>
|
||||
|
||||
@@ -6,3 +6,13 @@ public interface ICurrentUser
|
||||
string? UserName { get; }
|
||||
bool IsAuthenticated { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Только для фоновых/не-HTTP контекстов (Telegram-бот): задаёт "текущего" пользователя в рамках
|
||||
/// одного DI-scope, чтобы переиспользовать те же команды/запросы, что и веб (которые читают
|
||||
/// ICurrentUser). Реализуется тем же классом, что и ICurrentUser — см. CurrentUser (Infrastructure).
|
||||
/// </summary>
|
||||
public interface ICurrentUserSetter
|
||||
{
|
||||
void SetUser(Guid userId, string userName);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,13 @@ 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 sealed record CurrentUserProfile(Guid Id, string UserName, Guid RoleId, string Role, bool IsActivated, bool IsBlocked, int MaxConfigs);
|
||||
|
||||
public sealed record UserSummaryDto(Guid Id, string UserName, string Role, bool IsActivated, bool IsBlocked, DateTimeOffset? ActivatedAt);
|
||||
|
||||
public sealed record UserStatsDto(int Total, int Activated);
|
||||
|
||||
public sealed record TelegramLinkInfo(bool IsLinked, long? TelegramUserId, string? Username);
|
||||
|
||||
public interface IIdentityService
|
||||
{
|
||||
@@ -30,4 +36,24 @@ public interface IIdentityService
|
||||
|
||||
/// <summary>Для агрегированной подписки /sub/{userToken} (все активные конфиги пользователя).</summary>
|
||||
Task<Guid?> FindUserIdBySubscriptionTokenAsync(string token, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Блокировка: вход запрещён (см. ValidateCredentialsAsync). Конфиги гасит вызывающая сторона.</summary>
|
||||
Task<Result> BlockUserAsync(Guid userId, CancellationToken cancellationToken);
|
||||
|
||||
Task<Result> UnblockUserAsync(Guid userId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Сброс пароля админом — для пользователей без привязанного Telegram (M7).</summary>
|
||||
Task<Result> ResetPasswordAsync(Guid userId, string newPassword, CancellationToken cancellationToken);
|
||||
|
||||
Task<PagedList<UserSummaryDto>> ListUsersAsync(int page, int pageSize, string? search, CancellationToken cancellationToken);
|
||||
|
||||
Task<UserStatsDto> GetUserStatsAsync(CancellationToken cancellationToken);
|
||||
|
||||
Task<Result> LinkTelegramAsync(Guid userId, long telegramUserId, string? telegramUsername, CancellationToken cancellationToken);
|
||||
|
||||
Task<Result> UnlinkTelegramAsync(Guid userId, CancellationToken cancellationToken);
|
||||
|
||||
Task<Guid?> FindUserIdByTelegramUserIdAsync(long telegramUserId, CancellationToken cancellationToken);
|
||||
|
||||
Task<TelegramLinkInfo> GetTelegramLinkInfoAsync(Guid userId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using PnvPanel.Domain.Configs;
|
||||
using PnvPanel.Domain.Nodes;
|
||||
|
||||
namespace PnvPanel.Application.Common.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Пуш событий через SignalR. Реализация (SignalRRealtimeNotifier) живёт в Api (не в Infrastructure) —
|
||||
/// ей нужен IHubContext<PanelHub>, а Hub, будучи транспортным механизмом, определён в Api;
|
||||
/// Infrastructure не может ссылаться на Api (обратное направление зависимостей).
|
||||
/// </summary>
|
||||
public interface IRealtimeNotifier
|
||||
{
|
||||
Task NotifyConfigTrafficUpdatedAsync(
|
||||
Guid userId, Guid configId, long usedUpBytes, long usedDownBytes, CancellationToken cancellationToken);
|
||||
|
||||
Task NotifyConfigStatusChangedAsync(Guid userId, Guid configId, ConfigStatus status, CancellationToken cancellationToken);
|
||||
|
||||
Task NotifyNodeStatusChangedAsync(Guid nodeId, NodeStatus status, DateTimeOffset? lastSyncAt, CancellationToken cancellationToken);
|
||||
|
||||
Task NotifyActivationRequestedAsync(
|
||||
Guid requestId, Guid userId, string userName, string? comment, DateTimeOffset createdAt, CancellationToken cancellationToken);
|
||||
|
||||
Task NotifyUserActivatedAsync(Guid userId, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace PnvPanel.Application.Common.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Проактивные DM-уведомления через бота. Реализация — в Api (нужен ITelegramBotClient,
|
||||
/// который там же и регистрируется), аналогично IRealtimeNotifier/PanelHub. Если BotToken не
|
||||
/// задан — реализация тихо не отправляет ничего (бот работает без Telegram).
|
||||
/// </summary>
|
||||
public interface ITelegramNotifier
|
||||
{
|
||||
Task NotifyAdminsActivationRequestedAsync(
|
||||
Guid requestId, string userName, string? comment, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Личное сообщение пользователю, если у него привязан Telegram (иначе no-op).</summary>
|
||||
Task NotifyUserAsync(Guid userId, string message, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -8,6 +8,8 @@ public sealed record RemoteInboundInfo(string RemoteInboundId, VpnProtocol Proto
|
||||
|
||||
public sealed record NodeProbeResult(bool IsReachable, string? ErrorMessage);
|
||||
|
||||
public sealed record ClientTrafficInfo(long UpBytes, long DownBytes);
|
||||
|
||||
/// <summary>
|
||||
/// Оркестрация панелей 3x-ui через ThreeXui.Net. Один BaseAddress в библиотеке, но нод много —
|
||||
/// реализация держит клиента per-node (кэш по NodeId), см. XuiPanelGateway.
|
||||
@@ -38,4 +40,11 @@ public interface IXuiPanelGateway
|
||||
Task<Result<string>> BuildConnectionStringAsync(
|
||||
Node node, Inbound inbound, string clientExternalId, string clientName, string publicHost,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Трафик по клиентам инбаунда, ключ — ClientEmail. ThreeXui.Net не даёт типизированного метода
|
||||
/// для этого — извлекается из сырого clientStats[] в RawInboundJson (стандартное поле 3x-ui API).
|
||||
/// </summary>
|
||||
Task<Result<IReadOnlyDictionary<string, ClientTrafficInfo>>> GetClientTrafficAsync(
|
||||
Node node, string inboundRemoteId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ using PnvPanel.Domain.Configs;
|
||||
|
||||
namespace PnvPanel.Application.Configs.Revoke;
|
||||
|
||||
public sealed class RevokeVpnConfigCommandHandler(IAppDbContext dbContext, IXuiPanelGateway gateway, ICurrentUser currentUser)
|
||||
public sealed class RevokeVpnConfigCommandHandler(
|
||||
IAppDbContext dbContext, IXuiPanelGateway gateway, IRealtimeNotifier notifier, ICurrentUser currentUser)
|
||||
: ICommandHandler<RevokeVpnConfigCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(RevokeVpnConfigCommand command, CancellationToken cancellationToken)
|
||||
@@ -32,6 +33,8 @@ public sealed class RevokeVpnConfigCommandHandler(IAppDbContext dbContext, IXuiP
|
||||
await gateway.RemoveClientAsync(node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, cancellationToken);
|
||||
|
||||
config.Revoke();
|
||||
await notifier.NotifyConfigStatusChangedAsync(userId, config.Id, config.Status, cancellationToken);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Telegram.Bot;
|
||||
|
||||
public sealed record ApproveTelegramLoginCommand(Guid RequestId, long TelegramUserId) : ICommand<Result>;
|
||||
@@ -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.Exceptions;
|
||||
|
||||
namespace PnvPanel.Application.Telegram.Bot;
|
||||
|
||||
public sealed class ApproveTelegramLoginCommandHandler(IAppDbContext dbContext, IIdentityService identityService)
|
||||
: ICommandHandler<ApproveTelegramLoginCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(ApproveTelegramLoginCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = await identityService.FindUserIdByTelegramUserIdAsync(command.TelegramUserId, cancellationToken);
|
||||
if (userId is null)
|
||||
return Result.Failure(TelegramErrors.NotLinked);
|
||||
|
||||
var request = await dbContext.TelegramLoginRequests.FirstOrDefaultAsync(r => r.Id == command.RequestId, cancellationToken);
|
||||
if (request is null)
|
||||
return Result.Failure(TelegramErrors.LoginRequestNotFound);
|
||||
|
||||
try
|
||||
{
|
||||
request.Approve(userId.Value);
|
||||
}
|
||||
catch (DomainException ex)
|
||||
{
|
||||
return Result.Failure(Error.Conflict("Telegram.LoginRequestInvalid", ex.Message));
|
||||
}
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Telegram.Bot;
|
||||
|
||||
/// <summary>Вызывается только из TelegramBotHostedService (обработка "/start link_<token>").</summary>
|
||||
public sealed record LinkTelegramCommand(string Token, long TelegramUserId, string? TelegramUsername) : ICommand<Result<Guid>>;
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Telegram.Bot;
|
||||
|
||||
public sealed class LinkTelegramCommandHandler(IAppDbContext dbContext, IIdentityService identityService)
|
||||
: ICommandHandler<LinkTelegramCommand, Result<Guid>>
|
||||
{
|
||||
public async Task<Result<Guid>> Handle(LinkTelegramCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var linkToken = await dbContext.TelegramLinkTokens.FirstOrDefaultAsync(t => t.Token == command.Token, cancellationToken);
|
||||
if (linkToken is null || !linkToken.IsValid)
|
||||
return Result.Failure<Guid>(TelegramErrors.LinkTokenNotFound);
|
||||
|
||||
var linkResult = await identityService.LinkTelegramAsync(
|
||||
linkToken.UserId, command.TelegramUserId, command.TelegramUsername, cancellationToken);
|
||||
if (!linkResult.IsSuccess)
|
||||
return Result.Failure<Guid>(linkResult.Error);
|
||||
|
||||
linkToken.Consume();
|
||||
|
||||
return Result.Success(linkToken.UserId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Telegram.Bot;
|
||||
|
||||
public sealed record RejectTelegramLoginCommand(Guid RequestId, long TelegramUserId) : ICommand<Result>;
|
||||
@@ -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.Exceptions;
|
||||
|
||||
namespace PnvPanel.Application.Telegram.Bot;
|
||||
|
||||
public sealed class RejectTelegramLoginCommandHandler(IAppDbContext dbContext, IIdentityService identityService)
|
||||
: ICommandHandler<RejectTelegramLoginCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(RejectTelegramLoginCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = await identityService.FindUserIdByTelegramUserIdAsync(command.TelegramUserId, cancellationToken);
|
||||
if (userId is null)
|
||||
return Result.Failure(TelegramErrors.NotLinked);
|
||||
|
||||
var request = await dbContext.TelegramLoginRequests.FirstOrDefaultAsync(r => r.Id == command.RequestId, cancellationToken);
|
||||
if (request is null)
|
||||
return Result.Failure(TelegramErrors.LoginRequestNotFound);
|
||||
|
||||
try
|
||||
{
|
||||
request.Reject();
|
||||
}
|
||||
catch (DomainException ex)
|
||||
{
|
||||
return Result.Failure(Error.Conflict("Telegram.LoginRequestInvalid", ex.Message));
|
||||
}
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Telegram;
|
||||
|
||||
public sealed record CreateLinkTokenCommand : ICommand<Result<LinkTokenDto>>;
|
||||
|
||||
public sealed record LinkTokenDto(string Token, DateTimeOffset ExpiresAt);
|
||||
@@ -0,0 +1,24 @@
|
||||
using PnvPanel.Application.Auth;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Domain.Telegram;
|
||||
|
||||
namespace PnvPanel.Application.Telegram;
|
||||
|
||||
public sealed class CreateLinkTokenCommandHandler(IAppDbContext dbContext, ICurrentUser currentUser)
|
||||
: ICommandHandler<CreateLinkTokenCommand, Result<LinkTokenDto>>
|
||||
{
|
||||
private static readonly TimeSpan Ttl = TimeSpan.FromMinutes(5);
|
||||
|
||||
public Task<Result<LinkTokenDto>> Handle(CreateLinkTokenCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return Task.FromResult(Result.Failure<LinkTokenDto>(AuthErrors.Unauthorized));
|
||||
|
||||
var token = TelegramLinkToken.Create(userId, Ttl);
|
||||
dbContext.TelegramLinkTokens.Add(token);
|
||||
|
||||
return Task.FromResult(Result.Success(new LinkTokenDto(token.Token, token.ExpiresAt)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Telegram;
|
||||
|
||||
/// <summary>Публичная команда (пользователь ещё не залогинен) — начинает passwordless-вход.</summary>
|
||||
public sealed record CreateLoginRequestCommand(string? Context) : ICommand<Result<LoginRequestDto>>;
|
||||
|
||||
public sealed record LoginRequestDto(Guid RequestId, DateTimeOffset ExpiresAt);
|
||||
@@ -0,0 +1,20 @@
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Domain.Telegram;
|
||||
|
||||
namespace PnvPanel.Application.Telegram;
|
||||
|
||||
public sealed class CreateLoginRequestCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<CreateLoginRequestCommand, Result<LoginRequestDto>>
|
||||
{
|
||||
private static readonly TimeSpan Ttl = TimeSpan.FromMinutes(5);
|
||||
|
||||
public Task<Result<LoginRequestDto>> Handle(CreateLoginRequestCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var request = TelegramLoginRequest.Create(Ttl, command.Context);
|
||||
dbContext.TelegramLoginRequests.Add(request);
|
||||
|
||||
return Task.FromResult(Result.Success(new LoginRequestDto(request.Id, request.ExpiresAt)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using PnvPanel.Application.Auth;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Domain.Telegram;
|
||||
|
||||
namespace PnvPanel.Application.Telegram;
|
||||
|
||||
public sealed record GetLoginRequestStatusQuery(Guid RequestId) : IQuery<Result<LoginRequestStatusDto>>;
|
||||
|
||||
public sealed record LoginRequestStatusDto(TelegramLoginStatus Status, AuthResult? Auth);
|
||||
@@ -0,0 +1,48 @@
|
||||
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.Telegram;
|
||||
|
||||
namespace PnvPanel.Application.Telegram;
|
||||
|
||||
/// <summary>
|
||||
/// Формально Query, но при первом наблюдении Approved-статуса атомарно "забирает" вход:
|
||||
/// выпускает JWT и переводит запрос в Consumed (одноразовый claim), см. api-design.md.
|
||||
/// Осознанное отступление от чистого CQRS ради простого поллинга без отдельного claim-эндпоинта.
|
||||
/// </summary>
|
||||
public sealed class GetLoginRequestStatusQueryHandler(
|
||||
IAppDbContext dbContext, IIdentityService identityService, IJwtTokenService jwtTokenService, IRefreshTokenService refreshTokenService)
|
||||
: IQueryHandler<GetLoginRequestStatusQuery, Result<LoginRequestStatusDto>>
|
||||
{
|
||||
public async Task<Result<LoginRequestStatusDto>> Handle(GetLoginRequestStatusQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var request = await dbContext.TelegramLoginRequests.FirstOrDefaultAsync(r => r.Id == query.RequestId, cancellationToken);
|
||||
if (request is null)
|
||||
return Result.Failure<LoginRequestStatusDto>(TelegramErrors.LoginRequestNotFound);
|
||||
|
||||
if (request.Status == TelegramLoginStatus.Pending && request.IsExpired)
|
||||
return Result.Success(new LoginRequestStatusDto(TelegramLoginStatus.Expired, null));
|
||||
|
||||
if (request.Status != TelegramLoginStatus.Approved)
|
||||
return Result.Success(new LoginRequestStatusDto(request.Status, null));
|
||||
|
||||
var profile = await identityService.GetProfileAsync(request.UserId!.Value, cancellationToken);
|
||||
if (profile is null)
|
||||
return Result.Failure<LoginRequestStatusDto>(AuthErrors.Unauthorized);
|
||||
|
||||
var authUser = new AuthenticatedUser(profile.Id, profile.UserName, profile.Role);
|
||||
var (accessToken, accessExpiresAt) = jwtTokenService.GenerateAccessToken(authUser);
|
||||
|
||||
request.Consume();
|
||||
// IssueAsync сохраняет весь SaveChanges (в т.ч. Consume() выше) — см. RefreshTokenService.
|
||||
var refreshToken = await refreshTokenService.IssueAsync(profile.Id, cancellationToken);
|
||||
|
||||
// Пользователь только что подтвердил вход через бота — Telegram точно привязан.
|
||||
var dto = new CurrentUserDto(profile.Id, profile.UserName, profile.Role, profile.IsActivated, TelegramLinked: true);
|
||||
var auth = new AuthResult(accessToken, accessExpiresAt, refreshToken.RawToken, refreshToken.ExpiresAt, dto);
|
||||
|
||||
return Result.Success(new LoginRequestStatusDto(TelegramLoginStatus.Approved, auth));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Telegram;
|
||||
|
||||
public static class TelegramErrors
|
||||
{
|
||||
public static readonly Error LinkTokenNotFound =
|
||||
Error.NotFound("Telegram.LinkTokenNotFound", "Токен привязки не найден или истёк.");
|
||||
|
||||
public static readonly Error LoginRequestNotFound =
|
||||
Error.NotFound("Telegram.LoginRequestNotFound", "Запрос на вход не найден.");
|
||||
|
||||
public static readonly Error NotLinked =
|
||||
Error.Conflict("Telegram.NotLinked", "Telegram не привязан ни к одному аккаунту.");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Telegram;
|
||||
|
||||
public sealed record UnlinkTelegramCommand : ICommand<Result>;
|
||||
@@ -0,0 +1,18 @@
|
||||
using PnvPanel.Application.Auth;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Telegram;
|
||||
|
||||
public sealed class UnlinkTelegramCommandHandler(IIdentityService identityService, ICurrentUser currentUser)
|
||||
: ICommandHandler<UnlinkTelegramCommand, Result>
|
||||
{
|
||||
public Task<Result> Handle(UnlinkTelegramCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return Task.FromResult(Result.Failure(AuthErrors.Unauthorized));
|
||||
|
||||
return identityService.UnlinkTelegramAsync(userId, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace PnvPanel.Domain.Audit;
|
||||
|
||||
/// <summary>Append-only журнал значимых действий. Id — long (не Guid), см. TrafficSample.</summary>
|
||||
public sealed class AuditLog
|
||||
{
|
||||
public long Id { get; private set; }
|
||||
public Guid? ActorId { get; private set; }
|
||||
public string Action { get; private set; } = string.Empty;
|
||||
public string TargetType { get; private set; } = string.Empty;
|
||||
public string TargetId { get; private set; } = string.Empty;
|
||||
public string? Metadata { get; private set; }
|
||||
public AuditSource Source { get; private set; }
|
||||
public DateTimeOffset CreatedAt { get; private set; }
|
||||
|
||||
private AuditLog()
|
||||
{
|
||||
}
|
||||
|
||||
public static AuditLog Create(
|
||||
Guid? actorId, string action, string targetType, string targetId, string? metadata, AuditSource source)
|
||||
{
|
||||
return new AuditLog
|
||||
{
|
||||
ActorId = actorId,
|
||||
Action = action,
|
||||
TargetType = targetType,
|
||||
TargetId = targetId,
|
||||
Metadata = metadata,
|
||||
Source = source,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace PnvPanel.Domain.Audit;
|
||||
|
||||
public enum AuditSource
|
||||
{
|
||||
Web,
|
||||
Telegram,
|
||||
System,
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace PnvPanel.Domain.Configs;
|
||||
|
||||
/// <summary>
|
||||
/// Точка истории трафика. Id — long (не Guid, как у Entity) — таблица растёт быстро,
|
||||
/// авто-инкремент компактнее для высокочастотной записи. TTL-ретеншн — см. TrafficRetentionService.
|
||||
/// </summary>
|
||||
public sealed class TrafficSample
|
||||
{
|
||||
public long Id { get; private set; }
|
||||
public Guid ConfigId { get; private set; }
|
||||
public DateTimeOffset Timestamp { get; private set; }
|
||||
public long UpBytes { get; private set; }
|
||||
public long DownBytes { get; private set; }
|
||||
|
||||
private TrafficSample()
|
||||
{
|
||||
}
|
||||
|
||||
public static TrafficSample Create(Guid configId, DateTimeOffset timestamp, long upBytes, long downBytes)
|
||||
{
|
||||
return new TrafficSample
|
||||
{
|
||||
ConfigId = configId,
|
||||
Timestamp = timestamp,
|
||||
UpBytes = upBytes,
|
||||
DownBytes = downBytes,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,28 @@ public sealed class VpnConfig : Entity
|
||||
Status = ConfigStatus.Revoked;
|
||||
}
|
||||
|
||||
/// <summary>Синхронизация из 3x-ui (см. TrafficSyncService).</summary>
|
||||
public void UpdateTraffic(long usedUpBytes, long usedDownBytes)
|
||||
{
|
||||
UsedUpBytes = usedUpBytes;
|
||||
UsedDownBytes = usedDownBytes;
|
||||
LastSyncAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>Блокировка пользователя админом — гасит клиента в 3x-ui, но не отзывает запись.</summary>
|
||||
public void Disable()
|
||||
{
|
||||
if (Status == ConfigStatus.Active)
|
||||
Status = ConfigStatus.Disabled;
|
||||
}
|
||||
|
||||
/// <summary>Разблокировка — возвращает в Active только то, что было погашено блокировкой.</summary>
|
||||
public void Enable()
|
||||
{
|
||||
if (Status == ConfigStatus.Disabled)
|
||||
Status = ConfigStatus.Active;
|
||||
}
|
||||
|
||||
private void EnsureActive(string action)
|
||||
{
|
||||
if (Status != ConfigStatus.Active)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Security.Cryptography;
|
||||
using PnvPanel.Domain.Common;
|
||||
using PnvPanel.Domain.Exceptions;
|
||||
|
||||
namespace PnvPanel.Domain.Telegram;
|
||||
|
||||
/// <summary>Короткоживущий одноразовый токен для флоу привязки Telegram (deep-link в бота).</summary>
|
||||
public sealed class TelegramLinkToken : Entity
|
||||
{
|
||||
public string Token { get; private set; } = string.Empty;
|
||||
public Guid UserId { get; private set; }
|
||||
public DateTimeOffset ExpiresAt { get; private set; }
|
||||
public DateTimeOffset? ConsumedAt { get; private set; }
|
||||
|
||||
private TelegramLinkToken()
|
||||
{
|
||||
}
|
||||
|
||||
public static TelegramLinkToken Create(Guid userId, TimeSpan ttl)
|
||||
{
|
||||
return new TelegramLinkToken
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Token = GenerateToken(),
|
||||
UserId = userId,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.Add(ttl),
|
||||
};
|
||||
}
|
||||
|
||||
public bool IsValid => ConsumedAt is null && DateTimeOffset.UtcNow < ExpiresAt;
|
||||
|
||||
public void Consume()
|
||||
{
|
||||
if (!IsValid)
|
||||
throw new DomainException("Токен привязки недействителен или уже использован.");
|
||||
|
||||
ConsumedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
private static string GenerateToken() => Convert.ToHexString(RandomNumberGenerator.GetBytes(24)).ToLowerInvariant();
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using PnvPanel.Domain.Common;
|
||||
using PnvPanel.Domain.Exceptions;
|
||||
|
||||
namespace PnvPanel.Domain.Telegram;
|
||||
|
||||
/// <summary>
|
||||
/// Passwordless-вход: сайт создаёт запрос (Id = nonce в deep-link), пользователь подтверждает
|
||||
/// в боте. Context — IP/устройство инициатора, показывается при подтверждении (защита от фишинга).
|
||||
/// </summary>
|
||||
public sealed class TelegramLoginRequest : Entity
|
||||
{
|
||||
public TelegramLoginStatus Status { get; private set; }
|
||||
public Guid? UserId { get; private set; }
|
||||
public string? Context { get; private set; }
|
||||
public DateTimeOffset CreatedAt { get; private set; }
|
||||
public DateTimeOffset ExpiresAt { get; private set; }
|
||||
|
||||
private TelegramLoginRequest()
|
||||
{
|
||||
}
|
||||
|
||||
public static TelegramLoginRequest Create(TimeSpan ttl, string? context)
|
||||
{
|
||||
return new TelegramLoginRequest
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Status = TelegramLoginStatus.Pending,
|
||||
Context = context,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.Add(ttl),
|
||||
};
|
||||
}
|
||||
|
||||
public bool IsExpired => DateTimeOffset.UtcNow >= ExpiresAt;
|
||||
|
||||
public void Approve(Guid userId)
|
||||
{
|
||||
EnsurePending();
|
||||
Status = TelegramLoginStatus.Approved;
|
||||
UserId = userId;
|
||||
}
|
||||
|
||||
public void Reject()
|
||||
{
|
||||
EnsurePending();
|
||||
Status = TelegramLoginStatus.Rejected;
|
||||
}
|
||||
|
||||
/// <summary>Помечает выданным (после того как сайт забрал JWT по этому запросу).</summary>
|
||||
public void Consume()
|
||||
{
|
||||
if (Status != TelegramLoginStatus.Approved)
|
||||
throw new DomainException("Запрос ещё не подтверждён.");
|
||||
|
||||
Status = TelegramLoginStatus.Consumed;
|
||||
}
|
||||
|
||||
private void EnsurePending()
|
||||
{
|
||||
if (IsExpired)
|
||||
{
|
||||
Status = TelegramLoginStatus.Expired;
|
||||
throw new DomainException("Запрос на вход истёк.");
|
||||
}
|
||||
|
||||
if (Status != TelegramLoginStatus.Pending)
|
||||
throw new DomainException("Запрос на вход уже обработан.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace PnvPanel.Domain.Telegram;
|
||||
|
||||
public enum TelegramLoginStatus
|
||||
{
|
||||
Pending,
|
||||
Approved,
|
||||
Rejected,
|
||||
Expired,
|
||||
Consumed,
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Domain.Nodes;
|
||||
using PnvPanel.Infrastructure.Persistence;
|
||||
|
||||
namespace PnvPanel.Infrastructure.BackgroundJobs;
|
||||
|
||||
public sealed class NodeHealthCheckService(IServiceScopeFactory scopeFactory, ILogger<NodeHealthCheckService> logger) : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan Interval = TimeSpan.FromMinutes(2);
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
using var timer = new PeriodicTimer(Interval);
|
||||
do
|
||||
{
|
||||
try
|
||||
{
|
||||
await CheckAllAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Ошибка health-check нод");
|
||||
}
|
||||
}
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken));
|
||||
}
|
||||
|
||||
private async Task CheckAllAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var gateway = scope.ServiceProvider.GetRequiredService<IXuiPanelGateway>();
|
||||
var notifier = scope.ServiceProvider.GetRequiredService<IRealtimeNotifier>();
|
||||
|
||||
var nodes = await dbContext.Nodes.Where(n => n.IsEnabled).ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
var probe = await gateway.ProbeAsync(node, cancellationToken);
|
||||
var newStatus = probe.IsReachable ? NodeStatus.Online : NodeStatus.Offline;
|
||||
|
||||
if (node.Status != newStatus)
|
||||
{
|
||||
node.UpdateStatus(newStatus);
|
||||
await notifier.NotifyNodeStatusChangedAsync(node.Id, newStatus, node.LastSyncAt, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace PnvPanel.Infrastructure.BackgroundJobs;
|
||||
|
||||
public sealed class TrafficRetentionOptions
|
||||
{
|
||||
public const string SectionName = "TrafficRetention";
|
||||
|
||||
public int RetentionDays { get; init; } = 30;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using PnvPanel.Infrastructure.Persistence;
|
||||
|
||||
namespace PnvPanel.Infrastructure.BackgroundJobs;
|
||||
|
||||
/// <summary>TTL-чистка истории трафика (TrafficRetention__RetentionDays, по умолчанию 30 дней).</summary>
|
||||
public sealed class TrafficRetentionService(
|
||||
IServiceScopeFactory scopeFactory, IOptions<TrafficRetentionOptions> options, ILogger<TrafficRetentionService> logger)
|
||||
: BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan Interval = TimeSpan.FromHours(24);
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
using var timer = new PeriodicTimer(Interval);
|
||||
do
|
||||
{
|
||||
try
|
||||
{
|
||||
await CleanupAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Ошибка ретеншна истории трафика");
|
||||
}
|
||||
}
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken));
|
||||
}
|
||||
|
||||
private async Task CleanupAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var cutoff = DateTimeOffset.UtcNow.AddDays(-options.Value.RetentionDays);
|
||||
var deleted = await dbContext.TrafficSamples.Where(s => s.Timestamp < cutoff).ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
if (deleted > 0)
|
||||
logger.LogInformation("Удалено {Count} устаревших записей истории трафика", deleted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Domain.Configs;
|
||||
using PnvPanel.Infrastructure.Persistence;
|
||||
|
||||
namespace PnvPanel.Infrastructure.BackgroundJobs;
|
||||
|
||||
/// <summary>
|
||||
/// Обходит включённые ноды → инбаунды → активные конфиги, тянет клиентский трафик и обновляет
|
||||
/// VpnConfig + пишет TrafficSample. Реконсиляция дрейфа: если панель недоступна — просто пропускаем
|
||||
/// эту ноду в этом цикле, не роняем весь сервис и не трогаем локальные данные.
|
||||
/// </summary>
|
||||
public sealed class TrafficSyncService(IServiceScopeFactory scopeFactory, ILogger<TrafficSyncService> logger) : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan Interval = TimeSpan.FromMinutes(5);
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
using var timer = new PeriodicTimer(Interval);
|
||||
do
|
||||
{
|
||||
try
|
||||
{
|
||||
await SyncAllAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Ошибка синхронизации трафика");
|
||||
}
|
||||
}
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken));
|
||||
}
|
||||
|
||||
private async Task SyncAllAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var gateway = scope.ServiceProvider.GetRequiredService<IXuiPanelGateway>();
|
||||
var notifier = scope.ServiceProvider.GetRequiredService<IRealtimeNotifier>();
|
||||
|
||||
var nodes = await dbContext.Nodes.Where(n => n.IsEnabled).ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
var inbounds = await dbContext.Inbounds.Where(i => i.NodeId == node.Id).ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var inbound in inbounds)
|
||||
{
|
||||
var configs = await dbContext.VpnConfigs
|
||||
.Where(c => c.InboundId == inbound.Id && c.Status == ConfigStatus.Active)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (configs.Count == 0)
|
||||
continue;
|
||||
|
||||
var trafficResult = await gateway.GetClientTrafficAsync(node, inbound.RemoteInboundId, cancellationToken);
|
||||
if (!trafficResult.IsSuccess)
|
||||
continue;
|
||||
|
||||
foreach (var config in configs)
|
||||
{
|
||||
if (!trafficResult.Value.TryGetValue(config.ClientEmail, out var traffic))
|
||||
continue;
|
||||
|
||||
config.UpdateTraffic(traffic.UpBytes, traffic.DownBytes);
|
||||
dbContext.TrafficSamples.Add(TrafficSample.Create(config.Id, DateTimeOffset.UtcNow, traffic.UpBytes, traffic.DownBytes));
|
||||
|
||||
await notifier.NotifyConfigTrafficUpdatedAsync(
|
||||
config.UserId, config.Id, traffic.UpBytes, traffic.DownBytes, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,11 @@ using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Infrastructure.BackgroundJobs;
|
||||
using PnvPanel.Infrastructure.Identity;
|
||||
using PnvPanel.Infrastructure.Persistence;
|
||||
using PnvPanel.Infrastructure.Security;
|
||||
using PnvPanel.Infrastructure.Telegram;
|
||||
using PnvPanel.Infrastructure.Xui;
|
||||
using ThreeXui.ConnectionStrings;
|
||||
using ThreeXui.Http;
|
||||
@@ -66,6 +68,20 @@ public static class DependencyInjection
|
||||
ValidateLifetime = true,
|
||||
ClockSkew = TimeSpan.FromSeconds(30),
|
||||
};
|
||||
|
||||
// SignalR JS-клиент не может выставить заголовок Authorization на WebSocket-хендшейке —
|
||||
// передаёт токен через query-string (?access_token=...), только для /hubs.
|
||||
options.Events = new JwtBearerEvents
|
||||
{
|
||||
OnMessageReceived = context =>
|
||||
{
|
||||
var accessToken = context.Request.Query["access_token"];
|
||||
if (!string.IsNullOrEmpty(accessToken) && context.HttpContext.Request.Path.StartsWithSegments("/hubs"))
|
||||
context.Token = accessToken;
|
||||
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
services.AddAuthorization();
|
||||
@@ -94,9 +110,20 @@ public static class DependencyInjection
|
||||
services.AddScoped<IJwtTokenService, JwtTokenService>();
|
||||
services.AddScoped<IRefreshTokenService, RefreshTokenService>();
|
||||
services.AddScoped<IRoleService, RoleService>();
|
||||
services.AddScoped<ICurrentUser, CurrentUser>();
|
||||
// Один и тот же экземпляр CurrentUser на scope — и как ICurrentUser (чтение), и как
|
||||
// ICurrentUserSetter (запись, только для Telegram-бота, см. TelegramBotHostedService).
|
||||
services.AddScoped<CurrentUser>();
|
||||
services.AddScoped<ICurrentUser>(sp => sp.GetRequiredService<CurrentUser>());
|
||||
services.AddScoped<ICurrentUserSetter>(sp => sp.GetRequiredService<CurrentUser>());
|
||||
services.AddScoped<DbInitializer>();
|
||||
|
||||
services.Configure<TrafficRetentionOptions>(configuration.GetSection(TrafficRetentionOptions.SectionName));
|
||||
services.AddHostedService<TrafficSyncService>();
|
||||
services.AddHostedService<NodeHealthCheckService>();
|
||||
services.AddHostedService<TrafficRetentionService>();
|
||||
|
||||
services.Configure<TelegramOptions>(configuration.GetSection(TelegramOptions.SectionName));
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,16 @@ public class AppUser : IdentityUser<Guid>
|
||||
public DateTimeOffset? ActivatedAt { get; set; }
|
||||
public Guid? ActivatedBy { get; set; }
|
||||
|
||||
/// <summary>Блокировка админом: вход запрещён, все конфиги отключаются в 3x-ui (см. BlockUserCommandHandler).</summary>
|
||||
public bool IsBlocked { get; set; }
|
||||
|
||||
/// <summary>Секрет для агрегированной подписки /sub/{token} (все активные конфиги пользователя).</summary>
|
||||
public string SubscriptionToken { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Id пользователя Telegram; уникален; null до привязки.</summary>
|
||||
public long? TelegramUserId { get; set; }
|
||||
|
||||
public string? TelegramUsername { get; set; }
|
||||
|
||||
public DateTimeOffset? TelegramLinkedAt { get; set; }
|
||||
}
|
||||
|
||||
@@ -4,20 +4,27 @@ using PnvPanel.Application.Common.Interfaces;
|
||||
|
||||
namespace PnvPanel.Infrastructure.Identity;
|
||||
|
||||
internal sealed class CurrentUser(IHttpContextAccessor httpContextAccessor) : ICurrentUser
|
||||
/// <summary>
|
||||
/// В HTTP-запросах читает JWT-claims. В Telegram-боте (нет HttpContext) вызывающая сторона
|
||||
/// заранее задаёт пользователя через ICurrentUserSetter.SetUser(...) в рамках DI-scope апдейта.
|
||||
/// </summary>
|
||||
internal sealed class CurrentUser(IHttpContextAccessor httpContextAccessor) : ICurrentUser, ICurrentUserSetter
|
||||
{
|
||||
private (Guid Id, string Name)? _override;
|
||||
|
||||
private ClaimsPrincipal? Principal => httpContextAccessor.HttpContext?.User;
|
||||
|
||||
public Guid? UserId
|
||||
{
|
||||
get
|
||||
public Guid? UserId => _override?.Id ?? ParseHttpUserId();
|
||||
|
||||
public string? UserName => _override?.Name ?? Principal?.FindFirstValue(ClaimTypes.Name);
|
||||
|
||||
public bool IsAuthenticated => _override is not null || (Principal?.Identity?.IsAuthenticated ?? false);
|
||||
|
||||
public void SetUser(Guid userId, string userName) => _override = (userId, userName);
|
||||
|
||||
private Guid? ParseHttpUserId()
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,9 @@ internal sealed class IdentityService(UserManager<AppUser> userManager, SignInMa
|
||||
if (user is null)
|
||||
return Result.Failure<AuthenticatedUser>(AuthErrors.InvalidCredentials);
|
||||
|
||||
if (user.IsBlocked)
|
||||
return Result.Failure<AuthenticatedUser>(AuthErrors.UserBlocked);
|
||||
|
||||
var checkResult = await signInManager.CheckPasswordSignInAsync(user, password, lockoutOnFailure: true);
|
||||
if (checkResult.IsLockedOut)
|
||||
return Result.Failure<AuthenticatedUser>(AuthErrors.LockedOut);
|
||||
@@ -56,7 +59,7 @@ internal sealed class IdentityService(UserManager<AppUser> userManager, SignInMa
|
||||
return null;
|
||||
|
||||
var role = await GetPrimaryRoleAsync(user);
|
||||
return new CurrentUserProfile(user.Id, user.UserName!, role.Id, role.Name!, user.IsActivated, role.MaxConfigs);
|
||||
return new CurrentUserProfile(user.Id, user.UserName!, role.Id, role.Name!, user.IsActivated, user.IsBlocked, role.MaxConfigs);
|
||||
}
|
||||
|
||||
public async Task<Result> ChangePasswordAsync(Guid userId, string currentPassword, string newPassword, CancellationToken cancellationToken)
|
||||
@@ -118,6 +121,118 @@ internal sealed class IdentityService(UserManager<AppUser> userManager, SignInMa
|
||||
return user?.Id;
|
||||
}
|
||||
|
||||
public async Task<Result> BlockUserAsync(Guid userId, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await userManager.FindByIdAsync(userId.ToString());
|
||||
if (user is null)
|
||||
return Result.Failure(AuthErrors.Unauthorized);
|
||||
|
||||
user.IsBlocked = true;
|
||||
await userManager.UpdateSecurityStampAsync(user); // гасит уже выданные refresh-токены де-факто — токен привязан к пользователю, не к stamp; отзыв делает вызывающий handler явно
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
public async Task<Result> UnblockUserAsync(Guid userId, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await userManager.FindByIdAsync(userId.ToString());
|
||||
if (user is null)
|
||||
return Result.Failure(AuthErrors.Unauthorized);
|
||||
|
||||
user.IsBlocked = false;
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
public async Task<Result> ResetPasswordAsync(Guid userId, string newPassword, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await userManager.FindByIdAsync(userId.ToString());
|
||||
if (user is null)
|
||||
return Result.Failure(AuthErrors.Unauthorized);
|
||||
|
||||
var token = await userManager.GeneratePasswordResetTokenAsync(user);
|
||||
var result = await userManager.ResetPasswordAsync(user, token, newPassword);
|
||||
|
||||
return result.Succeeded
|
||||
? Result.Success()
|
||||
: Result.Failure(Error.Validation(
|
||||
"Auth.PasswordResetFailed", string.Join("; ", result.Errors.Select(e => e.Description))));
|
||||
}
|
||||
|
||||
public async Task<PagedList<UserSummaryDto>> ListUsersAsync(int page, int pageSize, string? search, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = userManager.Users.AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
query = query.Where(u => u.UserName!.Contains(search));
|
||||
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
var users = await query
|
||||
.OrderBy(u => u.UserName)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var items = new List<UserSummaryDto>(users.Count);
|
||||
foreach (var user in users)
|
||||
{
|
||||
var roleName = await GetPrimaryRoleNameAsync(user);
|
||||
items.Add(new UserSummaryDto(user.Id, user.UserName!, roleName, user.IsActivated, user.IsBlocked, user.ActivatedAt));
|
||||
}
|
||||
|
||||
return new PagedList<UserSummaryDto>(items, total, page, pageSize);
|
||||
}
|
||||
|
||||
public async Task<UserStatsDto> GetUserStatsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var total = await userManager.Users.CountAsync(cancellationToken);
|
||||
var activated = await userManager.Users.CountAsync(u => u.IsActivated, cancellationToken);
|
||||
return new UserStatsDto(total, activated);
|
||||
}
|
||||
|
||||
public async Task<Result> LinkTelegramAsync(Guid userId, long telegramUserId, string? telegramUsername, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await userManager.FindByIdAsync(userId.ToString());
|
||||
if (user is null)
|
||||
return Result.Failure(AuthErrors.Unauthorized);
|
||||
|
||||
var alreadyLinked = await userManager.Users.AsNoTracking()
|
||||
.AnyAsync(u => u.TelegramUserId == telegramUserId && u.Id != userId, cancellationToken);
|
||||
if (alreadyLinked)
|
||||
return Result.Failure(Error.Conflict("Telegram.AlreadyLinked", "Этот Telegram уже привязан к другому аккаунту."));
|
||||
|
||||
user.TelegramUserId = telegramUserId;
|
||||
user.TelegramUsername = telegramUsername;
|
||||
user.TelegramLinkedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
public async Task<Result> UnlinkTelegramAsync(Guid userId, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await userManager.FindByIdAsync(userId.ToString());
|
||||
if (user is null)
|
||||
return Result.Failure(AuthErrors.Unauthorized);
|
||||
|
||||
user.TelegramUserId = null;
|
||||
user.TelegramUsername = null;
|
||||
user.TelegramLinkedAt = null;
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
public async Task<Guid?> FindUserIdByTelegramUserIdAsync(long telegramUserId, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await userManager.Users.AsNoTracking()
|
||||
.FirstOrDefaultAsync(u => u.TelegramUserId == telegramUserId, cancellationToken);
|
||||
return user?.Id;
|
||||
}
|
||||
|
||||
public async Task<TelegramLinkInfo> GetTelegramLinkInfoAsync(Guid userId, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await userManager.FindByIdAsync(userId.ToString());
|
||||
return user?.TelegramUserId is not null
|
||||
? new TelegramLinkInfo(true, user.TelegramUserId, user.TelegramUsername)
|
||||
: new TelegramLinkInfo(false, null, null);
|
||||
}
|
||||
|
||||
private async Task<string> GetPrimaryRoleNameAsync(AppUser user)
|
||||
{
|
||||
var roles = await userManager.GetRolesAsync(user);
|
||||
|
||||
@@ -3,9 +3,11 @@ using Microsoft.EntityFrameworkCore;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Domain.Activation;
|
||||
using PnvPanel.Domain.Apps;
|
||||
using PnvPanel.Domain.Audit;
|
||||
using PnvPanel.Domain.Configs;
|
||||
using PnvPanel.Domain.Inbounds;
|
||||
using PnvPanel.Domain.Nodes;
|
||||
using PnvPanel.Domain.Telegram;
|
||||
using PnvPanel.Infrastructure.Identity;
|
||||
|
||||
namespace PnvPanel.Infrastructure.Persistence;
|
||||
@@ -21,12 +23,20 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
|
||||
public DbSet<ActivationRequest> ActivationRequests => Set<ActivationRequest>();
|
||||
|
||||
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
|
||||
|
||||
public DbSet<TelegramLinkToken> TelegramLinkTokens => Set<TelegramLinkToken>();
|
||||
|
||||
public DbSet<TelegramLoginRequest> TelegramLoginRequests => Set<TelegramLoginRequest>();
|
||||
|
||||
public DbSet<Node> Nodes => Set<Node>();
|
||||
|
||||
public DbSet<Inbound> Inbounds => Set<Inbound>();
|
||||
|
||||
public DbSet<VpnConfig> VpnConfigs => Set<VpnConfig>();
|
||||
|
||||
public DbSet<TrafficSample> TrafficSamples => Set<TrafficSample>();
|
||||
|
||||
public DbSet<ClientApp> ClientApps => Set<ClientApp>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using PnvPanel.Infrastructure.Identity;
|
||||
|
||||
namespace PnvPanel.Infrastructure.Persistence.Configurations;
|
||||
|
||||
public class AppUserConfiguration : IEntityTypeConfiguration<AppUser>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<AppUser> builder)
|
||||
{
|
||||
// Уникален среди привязанных (Postgres не считает NULL равным NULL — обычный unique подходит).
|
||||
builder.HasIndex(x => x.TelegramUserId).IsUnique();
|
||||
builder.HasIndex(x => x.SubscriptionToken).IsUnique();
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using PnvPanel.Domain.Audit;
|
||||
|
||||
namespace PnvPanel.Infrastructure.Persistence.Configurations;
|
||||
|
||||
public class AuditLogConfiguration : IEntityTypeConfiguration<AuditLog>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<AuditLog> builder)
|
||||
{
|
||||
builder.ToTable("AuditLogs");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.Id).ValueGeneratedOnAdd();
|
||||
|
||||
builder.Property(x => x.Action).IsRequired().HasMaxLength(100);
|
||||
builder.Property(x => x.TargetType).IsRequired().HasMaxLength(50);
|
||||
builder.Property(x => x.TargetId).IsRequired().HasMaxLength(100);
|
||||
builder.Property(x => x.Metadata).HasColumnType("jsonb");
|
||||
builder.Property(x => x.Source).HasConversion<string>().HasMaxLength(32);
|
||||
|
||||
builder.HasIndex(x => x.CreatedAt);
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using PnvPanel.Domain.Telegram;
|
||||
|
||||
namespace PnvPanel.Infrastructure.Persistence.Configurations;
|
||||
|
||||
public class TelegramLinkTokenConfiguration : IEntityTypeConfiguration<TelegramLinkToken>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TelegramLinkToken> builder)
|
||||
{
|
||||
builder.ToTable("TelegramLinkTokens");
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
builder.Property(x => x.Token).IsRequired().HasMaxLength(64);
|
||||
builder.HasIndex(x => x.Token).IsUnique();
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using PnvPanel.Domain.Telegram;
|
||||
|
||||
namespace PnvPanel.Infrastructure.Persistence.Configurations;
|
||||
|
||||
public class TelegramLoginRequestConfiguration : IEntityTypeConfiguration<TelegramLoginRequest>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TelegramLoginRequest> builder)
|
||||
{
|
||||
builder.ToTable("TelegramLoginRequests");
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
builder.Property(x => x.Status).HasConversion<string>().HasMaxLength(32);
|
||||
builder.Property(x => x.Context).HasMaxLength(200);
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using PnvPanel.Domain.Configs;
|
||||
|
||||
namespace PnvPanel.Infrastructure.Persistence.Configurations;
|
||||
|
||||
public class TrafficSampleConfiguration : IEntityTypeConfiguration<TrafficSample>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TrafficSample> builder)
|
||||
{
|
||||
builder.ToTable("TrafficSamples");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.Id).ValueGeneratedOnAdd();
|
||||
|
||||
builder.HasIndex(x => new { x.ConfigId, x.Timestamp });
|
||||
}
|
||||
}
|
||||
+628
@@ -0,0 +1,628 @@
|
||||
// <auto-generated />
|
||||
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("20260701194500_AddTrafficSamples")]
|
||||
partial class AddTrafficSamples
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("RoleId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetRoleClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserLogins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("RoleId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetUserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Activation.ActivationRequest", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Comment")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("DecidedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("DecidedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("RejectionReason")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId", "Status");
|
||||
|
||||
b.ToTable("ActivationRequests", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Apps.ClientApp", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<string>("DownloadUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("IconUrl")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("OperatingSystem")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("ClientApps", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Configs.TrafficSample", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<Guid>("ConfigId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("DownBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTimeOffset>("Timestamp")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long>("UpBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ConfigId", "Timestamp");
|
||||
|
||||
b.ToTable("TrafficSamples", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Configs.VpnConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ClientEmail")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("ClientExternalId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("DeviceLimit")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("InboundId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSyncAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Protocol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("SubscriptionToken")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<long>("UsedDownBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("UsedUpBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.PrimitiveCollection<Guid[]>("AllowedRoleIds")
|
||||
.IsRequired()
|
||||
.HasColumnType("uuid[]");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<bool>("IsPublished")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSyncAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int?>("MaxClients")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("NodeId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Port")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Protocol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Remark")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("BaseAddress")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSyncAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IsSystem")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("MaxConfigs")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("ActivatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("ActivatedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsActivated")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SubscriptionToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ReplacedByTokenHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("RevokedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TokenHash")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("RefreshTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", 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<System.Guid>", 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<Guid>("NodeId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b1.Property<string>("ProtectedPassword")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("CredentialsProtectedPassword");
|
||||
|
||||
b1.Property<string>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PnvPanel.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTrafficSamples : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TrafficSamples",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
ConfigId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Timestamp = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
UpBytes = table.Column<long>(type: "bigint", nullable: false),
|
||||
DownBytes = table.Column<long>(type: "bigint", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TrafficSamples", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TrafficSamples_ConfigId_Timestamp",
|
||||
table: "TrafficSamples",
|
||||
columns: new[] { "ConfigId", "Timestamp" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "TrafficSamples");
|
||||
}
|
||||
}
|
||||
}
|
||||
+675
@@ -0,0 +1,675 @@
|
||||
// <auto-generated />
|
||||
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("20260701195456_AddAuditAndBlocking")]
|
||||
partial class AddAuditAndBlocking
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("RoleId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetRoleClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserLogins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("RoleId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetUserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Activation.ActivationRequest", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Comment")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("DecidedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("DecidedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("RejectionReason")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId", "Status");
|
||||
|
||||
b.ToTable("ActivationRequests", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Apps.ClientApp", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<string>("DownloadUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("IconUrl")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("OperatingSystem")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("ClientApps", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Audit.AuditLog", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid?>("ActorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Metadata")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("TargetId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("TargetType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.ToTable("AuditLogs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Configs.TrafficSample", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<Guid>("ConfigId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("DownBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTimeOffset>("Timestamp")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long>("UpBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ConfigId", "Timestamp");
|
||||
|
||||
b.ToTable("TrafficSamples", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Configs.VpnConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ClientEmail")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("ClientExternalId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("DeviceLimit")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("InboundId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSyncAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Protocol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("SubscriptionToken")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<long>("UsedDownBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("UsedUpBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.PrimitiveCollection<Guid[]>("AllowedRoleIds")
|
||||
.IsRequired()
|
||||
.HasColumnType("uuid[]");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<bool>("IsPublished")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSyncAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int?>("MaxClients")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("NodeId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Port")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Protocol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Remark")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("BaseAddress")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSyncAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IsSystem")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("MaxConfigs")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("ActivatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("ActivatedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsActivated")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsBlocked")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SubscriptionToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ReplacedByTokenHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("RevokedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TokenHash")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("RefreshTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", 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<System.Guid>", 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<Guid>("NodeId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b1.Property<string>("ProtectedPassword")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("CredentialsProtectedPassword");
|
||||
|
||||
b1.Property<string>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PnvPanel.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAuditAndBlocking : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsBlocked",
|
||||
table: "AspNetUsers",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AuditLogs",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
ActorId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
Action = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
TargetType = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
TargetId = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
Metadata = table.Column<string>(type: "jsonb", nullable: true),
|
||||
Source = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AuditLogs", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AuditLogs_CreatedAt",
|
||||
table: "AuditLogs",
|
||||
column: "CreatedAt");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AuditLogs");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsBlocked",
|
||||
table: "AspNetUsers");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+747
@@ -0,0 +1,747 @@
|
||||
// <auto-generated />
|
||||
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("20260701205836_AddTelegram")]
|
||||
partial class AddTelegram
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("RoleId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetRoleClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserLogins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("RoleId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetUserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Activation.ActivationRequest", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Comment")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("DecidedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("DecidedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("RejectionReason")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId", "Status");
|
||||
|
||||
b.ToTable("ActivationRequests", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Apps.ClientApp", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<string>("DownloadUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("IconUrl")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("OperatingSystem")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("ClientApps", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Audit.AuditLog", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid?>("ActorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Metadata")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("TargetId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("TargetType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.ToTable("AuditLogs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Configs.TrafficSample", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<Guid>("ConfigId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("DownBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTimeOffset>("Timestamp")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long>("UpBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ConfigId", "Timestamp");
|
||||
|
||||
b.ToTable("TrafficSamples", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Configs.VpnConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ClientEmail")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("ClientExternalId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("DeviceLimit")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("InboundId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSyncAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Protocol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("SubscriptionToken")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<long>("UsedDownBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("UsedUpBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.PrimitiveCollection<Guid[]>("AllowedRoleIds")
|
||||
.IsRequired()
|
||||
.HasColumnType("uuid[]");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<bool>("IsPublished")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSyncAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int?>("MaxClients")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("NodeId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Port")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Protocol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Remark")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("BaseAddress")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSyncAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Nodes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLinkToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("ConsumedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Token")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("TelegramLinkTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLoginRequest", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Context")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid?>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("TelegramLoginRequests", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppRole", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IsSystem")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("MaxConfigs")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("ActivatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("ActivatedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsActivated")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsBlocked")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SubscriptionToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("TelegramLinkedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long?>("TelegramUserId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("TelegramUsername")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
b.HasIndex("NormalizedUserName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex");
|
||||
|
||||
b.HasIndex("SubscriptionToken")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TelegramUserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AspNetUsers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Infrastructure.Identity.RefreshToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ReplacedByTokenHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("RevokedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TokenHash")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("RefreshTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", 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<System.Guid>", 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<Guid>("NodeId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b1.Property<string>("ProtectedPassword")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("CredentialsProtectedPassword");
|
||||
|
||||
b1.Property<string>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PnvPanel.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTelegram : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||
name: "TelegramLinkedAt",
|
||||
table: "AspNetUsers",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<long>(
|
||||
name: "TelegramUserId",
|
||||
table: "AspNetUsers",
|
||||
type: "bigint",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "TelegramUsername",
|
||||
table: "AspNetUsers",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TelegramLinkTokens",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Token = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ExpiresAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
ConsumedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TelegramLinkTokens", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TelegramLoginRequests",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
Context = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
ExpiresAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TelegramLoginRequests", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AspNetUsers_SubscriptionToken",
|
||||
table: "AspNetUsers",
|
||||
column: "SubscriptionToken",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AspNetUsers_TelegramUserId",
|
||||
table: "AspNetUsers",
|
||||
column: "TelegramUserId",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TelegramLinkTokens_Token",
|
||||
table: "TelegramLinkTokens",
|
||||
column: "Token",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "TelegramLinkTokens");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "TelegramLoginRequests");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_AspNetUsers_SubscriptionToken",
|
||||
table: "AspNetUsers");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_AspNetUsers_TelegramUserId",
|
||||
table: "AspNetUsers");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TelegramLinkedAt",
|
||||
table: "AspNetUsers");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TelegramUserId",
|
||||
table: "AspNetUsers");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TelegramUsername",
|
||||
table: "AspNetUsers");
|
||||
}
|
||||
}
|
||||
}
|
||||
+146
@@ -203,6 +203,77 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("ClientApps", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Audit.AuditLog", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid?>("ActorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Metadata")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("TargetId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("TargetType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.ToTable("AuditLogs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Configs.TrafficSample", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<Guid>("ConfigId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("DownBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTimeOffset>("Timestamp")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long>("UpBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ConfigId", "Timestamp");
|
||||
|
||||
b.ToTable("TrafficSamples", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Configs.VpnConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -365,6 +436,63 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("Nodes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLinkToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("ConsumedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Token")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("TelegramLinkTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLoginRequest", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Context")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid?>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("TelegramLoginRequests", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppRole", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -427,6 +555,9 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
|
||||
b.Property<bool>("IsActivated")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsBlocked")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
@@ -457,6 +588,15 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("TelegramLinkedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long?>("TelegramUserId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("TelegramUsername")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
@@ -473,6 +613,12 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex");
|
||||
|
||||
b.HasIndex("SubscriptionToken")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TelegramUserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AspNetUsers", (string)null);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace PnvPanel.Infrastructure.Telegram;
|
||||
|
||||
public sealed class TelegramOptions
|
||||
{
|
||||
public const string SectionName = "Telegram";
|
||||
|
||||
public string? BotToken { get; init; }
|
||||
public string? BotUsername { get; init; }
|
||||
public string PublicSiteUrl { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Telegram id админов (через запятую) — авторизуют админ-кнопки в боте.</summary>
|
||||
public string? AdminTelegramUserIds { get; init; }
|
||||
|
||||
public IReadOnlyCollection<long> ParseAdminTelegramUserIds()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(AdminTelegramUserIds))
|
||||
return [];
|
||||
|
||||
return AdminTelegramUserIds
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(s => long.TryParse(s, out var id) ? id : (long?)null)
|
||||
.Where(id => id.HasValue)
|
||||
.Select(id => id!.Value)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
@@ -149,6 +150,67 @@ internal sealed class XuiPanelGateway(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result<IReadOnlyDictionary<string, ClientTrafficInfo>>> GetClientTrafficAsync(
|
||||
Node node, string inboundRemoteId, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = GetClient(node);
|
||||
var remoteInbound = await client.GetInboundAsync(inboundRemoteId, cancellationToken);
|
||||
if (remoteInbound is null)
|
||||
{
|
||||
return Result.Failure<IReadOnlyDictionary<string, ClientTrafficInfo>>(
|
||||
Error.Failure("Xui.InboundNotFound", "Inbound не найден на панели."));
|
||||
}
|
||||
|
||||
return Result.Success(ParseClientStats(remoteInbound.RawInboundJson));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Failure<IReadOnlyDictionary<string, ClientTrafficInfo>>(
|
||||
Error.Failure("Xui.TrafficFetchFailed", $"Не удалось получить трафик: {ex.Message}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ThreeXui.Net типизированно не отдаёт трафик по клиентам — достаём его из сырого JSON инбаунда:
|
||||
/// стандартное поле 3x-ui API "clientStats": [{ "email": "...", "up": N, "down": N }, ...].
|
||||
/// Формат форка может отличаться — при ошибке парсинга просто возвращаем пусто, не валим синхронизацию.
|
||||
/// </summary>
|
||||
private static IReadOnlyDictionary<string, ClientTrafficInfo> ParseClientStats(string? rawInboundJson)
|
||||
{
|
||||
var result = new Dictionary<string, ClientTrafficInfo>();
|
||||
if (string.IsNullOrWhiteSpace(rawInboundJson))
|
||||
return result;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(rawInboundJson);
|
||||
if (!doc.RootElement.TryGetProperty("clientStats", out var clientStats) || clientStats.ValueKind != JsonValueKind.Array)
|
||||
return result;
|
||||
|
||||
foreach (var stat in clientStats.EnumerateArray())
|
||||
{
|
||||
if (!stat.TryGetProperty("email", out var emailProp) || emailProp.ValueKind != JsonValueKind.String)
|
||||
continue;
|
||||
|
||||
var email = emailProp.GetString();
|
||||
if (string.IsNullOrEmpty(email))
|
||||
continue;
|
||||
|
||||
var up = stat.TryGetProperty("up", out var upProp) ? upProp.GetInt64() : 0;
|
||||
var down = stat.TryGetProperty("down", out var downProp) ? downProp.GetInt64() : 0;
|
||||
result[email] = new ClientTrafficInfo(up, down);
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Не удалось распарсить — возвращаем пусто, вызывающий код просто пропустит синк для этого инбаунда.
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public void InvalidateClient(Guid nodeId)
|
||||
{
|
||||
if (_clients.TryRemove(nodeId, out var lazy) && lazy.IsValueCreated)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user