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:
Leonid Pershin
2026-07-02 01:01:03 +03:00
parent 1a8d33efa3
commit 7b6fe9ad78
142 changed files with 7570 additions and 22 deletions
@@ -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() });
}
}
+33
View File
@@ -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>
+31
View File
@@ -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&lt;Program&gt; в интеграционных тестах.</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("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
}
@@ -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,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&lt;PanelHub&gt;, а 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_&lt;token&gt;").</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
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()
{
get
{
var value = Principal?.FindFirstValue(ClaimTypes.NameIdentifier);
return Guid.TryParse(value, out var id) ? id : null;
}
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)
@@ -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();
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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);
}
}
@@ -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 });
}
}
@@ -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
}
}
}
@@ -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");
}
}
}
@@ -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
}
}
}
@@ -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");
}
}
}
@@ -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
}
}
}
@@ -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");
}
}
}
@@ -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)