Refactor project files for improved readability and structure
CI / Backend (build + test) (push) Successful in 1m18s
CI / Frontend (lint + typecheck + build) (push) Successful in 31s

- Cleaned up whitespace in Directory.Build.props and Directory.Packages.props for consistency.
- Reformatted project file references in PnvPanel.Api.csproj for better clarity.
- Enhanced code readability in various endpoint files by adjusting line breaks and indentation.
- Standardized method signatures and improved formatting in ResultExtensions and multiple endpoint classes for better maintainability.
This commit is contained in:
Leonid Pershin
2026-07-14 07:24:13 +03:00
parent 9d5424bb9c
commit df137ca5a7
285 changed files with 6911 additions and 2063 deletions
@@ -8,7 +8,10 @@ using PnvPanel.Infrastructure.Persistence;
namespace PnvPanel.Infrastructure.BackgroundJobs;
public sealed class NodeHealthCheckService(IServiceScopeFactory scopeFactory, ILogger<NodeHealthCheckService> logger) : BackgroundService
public sealed class NodeHealthCheckService(
IServiceScopeFactory scopeFactory,
ILogger<NodeHealthCheckService> logger
) : BackgroundService
{
private static readonly TimeSpan Interval = TimeSpan.FromMinutes(2);
@@ -25,8 +28,7 @@ public sealed class NodeHealthCheckService(IServiceScopeFactory scopeFactory, IL
{
logger.LogError(ex, "Node health-check failed");
}
}
while (await timer.WaitForNextTickAsync(stoppingToken));
} while (await timer.WaitForNextTickAsync(stoppingToken));
}
private async Task CheckAllAsync(CancellationToken cancellationToken)
@@ -46,7 +48,12 @@ public sealed class NodeHealthCheckService(IServiceScopeFactory scopeFactory, IL
if (node.Status != newStatus)
{
node.UpdateStatus(newStatus);
await notifier.NotifyNodeStatusChangedAsync(node.Id, newStatus, node.LastSyncAt, cancellationToken);
await notifier.NotifyNodeStatusChangedAsync(
node.Id,
newStatus,
node.LastSyncAt,
cancellationToken
);
}
}
@@ -9,8 +9,10 @@ namespace PnvPanel.Infrastructure.BackgroundJobs;
/// <summary>TTL-чистка истории трафика (TrafficRetention__RetentionDays, по умолчанию 30 дней).</summary>
public sealed class TrafficRetentionService(
IServiceScopeFactory scopeFactory, IOptions<TrafficRetentionOptions> options, ILogger<TrafficRetentionService> logger)
: BackgroundService
IServiceScopeFactory scopeFactory,
IOptions<TrafficRetentionOptions> options,
ILogger<TrafficRetentionService> logger
) : BackgroundService
{
private static readonly TimeSpan Interval = TimeSpan.FromHours(24);
@@ -27,8 +29,7 @@ public sealed class TrafficRetentionService(
{
logger.LogError(ex, "Traffic history retention failed");
}
}
while (await timer.WaitForNextTickAsync(stoppingToken));
} while (await timer.WaitForNextTickAsync(stoppingToken));
}
private async Task CleanupAsync(CancellationToken cancellationToken)
@@ -37,7 +38,9 @@ public sealed class TrafficRetentionService(
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);
var deleted = await dbContext
.TrafficSamples.Where(s => s.Timestamp < cutoff)
.ExecuteDeleteAsync(cancellationToken);
if (deleted > 0)
logger.LogInformation("Deleted {Count} expired traffic history records", deleted);
@@ -13,7 +13,10 @@ namespace PnvPanel.Infrastructure.BackgroundJobs;
/// VpnConfig + пишет TrafficSample. Реконсиляция дрейфа: если панель недоступна — просто пропускаем
/// эту ноду в этом цикле, не роняем весь сервис и не трогаем локальные данные.
/// </summary>
public sealed class TrafficSyncService(IServiceScopeFactory scopeFactory, ILogger<TrafficSyncService> logger) : BackgroundService
public sealed class TrafficSyncService(
IServiceScopeFactory scopeFactory,
ILogger<TrafficSyncService> logger
) : BackgroundService
{
private static readonly TimeSpan Interval = TimeSpan.FromMinutes(5);
@@ -30,8 +33,7 @@ public sealed class TrafficSyncService(IServiceScopeFactory scopeFactory, ILogge
{
logger.LogError(ex, "Traffic sync failed");
}
}
while (await timer.WaitForNextTickAsync(stoppingToken));
} while (await timer.WaitForNextTickAsync(stoppingToken));
}
private async Task SyncAllAsync(CancellationToken cancellationToken)
@@ -45,18 +47,26 @@ public sealed class TrafficSyncService(IServiceScopeFactory scopeFactory, ILogge
foreach (var node in nodes)
{
var inbounds = await dbContext.Inbounds.Where(i => i.NodeId == node.Id).ToListAsync(cancellationToken);
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)
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);
var trafficResult = await gateway.GetClientTrafficAsync(
node,
inbound.RemoteInboundId,
cancellationToken
);
if (!trafficResult.IsSuccess)
continue;
@@ -66,10 +76,22 @@ public sealed class TrafficSyncService(IServiceScopeFactory scopeFactory, ILogge
continue;
config.UpdateTraffic(traffic.UpBytes, traffic.DownBytes);
dbContext.TrafficSamples.Add(TrafficSample.Create(config.Id, DateTimeOffset.UtcNow, 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);
config.UserId,
config.Id,
traffic.UpBytes,
traffic.DownBytes,
cancellationToken
);
}
}
}
@@ -25,14 +25,22 @@ namespace PnvPanel.Infrastructure;
/// </summary>
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
public static IServiceCollection AddInfrastructure(
this IServiceCollection services,
IConfiguration configuration
)
{
// Строка подключения читается лениво внутри лямбды (а не в локальную переменную сразу), иначе
// в тестах WebApplicationFactory.ConfigureAppConfiguration (Testcontainers-порт) не успевает
// примениться до регистрации DbContext — окажется закэширован дефолт из appsettings.json.
services.AddDbContext<AppDbContext>(options => options.UseNpgsql(
configuration["ConnectionStrings:Default"]
?? throw new InvalidOperationException("Строка подключения 'ConnectionStrings:Default' не сконфигурирована.")));
services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(
configuration["ConnectionStrings:Default"]
?? throw new InvalidOperationException(
"Строка подключения 'ConnectionStrings:Default' не сконфигурирована."
)
)
);
services.AddScoped<IAppDbContext>(sp => sp.GetRequiredService<AppDbContext>());
services
@@ -53,10 +61,13 @@ public static class DependencyInjection
.AddDefaultTokenProviders();
services.Configure<JwtOptions>(configuration.GetSection(JwtOptions.SectionName));
services.Configure<AdminSeedOptions>(configuration.GetSection(AdminSeedOptions.SectionName));
services.Configure<AdminSeedOptions>(
configuration.GetSection(AdminSeedOptions.SectionName)
);
services.Configure<RolesOptions>(configuration.GetSection(RolesOptions.SectionName));
var jwtOptions = configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>()
var jwtOptions =
configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>()
?? throw new InvalidOperationException("Секция конфигурации 'Jwt' не задана.");
services
@@ -70,7 +81,9 @@ public static class DependencyInjection
ValidateAudience = true,
ValidAudience = jwtOptions.Audience,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SigningKey)),
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(jwtOptions.SigningKey)
),
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30),
};
@@ -82,7 +95,10 @@ public static class DependencyInjection
OnMessageReceived = context =>
{
var accessToken = context.Request.Query["access_token"];
if (!string.IsNullOrEmpty(accessToken) && context.HttpContext.Request.Path.StartsWithSegments("/hubs"))
if (
!string.IsNullOrEmpty(accessToken)
&& context.HttpContext.Request.Path.StartsWithSegments("/hubs")
)
context.Token = accessToken;
return Task.CompletedTask;
@@ -106,7 +122,10 @@ public static class DependencyInjection
services.AddSingleton<IXuiConnectionStringBuilder, VmessConnectionStringBuilder>();
services.AddSingleton<IXuiConnectionStringBuilder, TrojanConnectionStringBuilder>();
services.AddSingleton<IXuiConnectionStringBuilder, ShadowsocksConnectionStringBuilder>();
services.AddSingleton<IXuiConnectionStringBuilderResolver, XuiConnectionStringBuilderResolver>();
services.AddSingleton<
IXuiConnectionStringBuilderResolver,
XuiConnectionStringBuilderResolver
>();
services.AddSingleton<ISecretProtector, DataProtectionSecretProtector>();
// Singleton: держит кэш per-node XUI-клиентов между запросами (см. XuiPanelGateway).
@@ -123,14 +142,18 @@ public static class DependencyInjection
services.AddScoped<ICurrentUserSetter>(sp => sp.GetRequiredService<CurrentUser>());
services.AddScoped<DbInitializer>();
services.Configure<TrafficRetentionOptions>(configuration.GetSection(TrafficRetentionOptions.SectionName));
services.Configure<TrafficRetentionOptions>(
configuration.GetSection(TrafficRetentionOptions.SectionName)
);
services.AddHostedService<TrafficSyncService>();
services.AddHostedService<NodeHealthCheckService>();
services.AddHostedService<TrafficRetentionService>();
services.Configure<TelegramOptions>(configuration.GetSection(TelegramOptions.SectionName));
services.Configure<FileStorageOptions>(configuration.GetSection(FileStorageOptions.SectionName));
services.Configure<FileStorageOptions>(
configuration.GetSection(FileStorageOptions.SectionName)
);
services.AddSingleton<IFileStorage, DiskFileStorage>();
return services;
@@ -18,11 +18,8 @@ public class AppRole : IdentityRole<Guid>
public bool IsSystem { get; set; }
public AppRole()
{
}
public AppRole() { }
public AppRole(string name) : base(name)
{
}
public AppRole(string name)
: base(name) { }
}
@@ -8,7 +8,9 @@ namespace PnvPanel.Infrastructure.Identity;
/// В HTTP-запросах читает JWT-claims. В Telegram-боте (нет HttpContext) вызывающая сторона
/// заранее задаёт пользователя через ICurrentUserSetter.SetUser(...) в рамках DI-scope апдейта.
/// </summary>
internal sealed class CurrentUser(IHttpContextAccessor httpContextAccessor) : ICurrentUser, ICurrentUserSetter
internal sealed class CurrentUser(IHttpContextAccessor httpContextAccessor)
: ICurrentUser,
ICurrentUserSetter
{
private (Guid Id, string Name)? _override;
@@ -18,7 +20,8 @@ internal sealed class CurrentUser(IHttpContextAccessor httpContextAccessor) : IC
public string? UserName => _override?.Name ?? Principal?.FindFirstValue(ClaimTypes.Name);
public bool IsAuthenticated => _override is not null || (Principal?.Identity?.IsAuthenticated ?? false);
public bool IsAuthenticated =>
_override is not null || (Principal?.Identity?.IsAuthenticated ?? false);
public void SetUser(Guid userId, string userName) => _override = (userId, userName);
@@ -16,14 +16,28 @@ public sealed class DbInitializer(
AppDbContext dbContext,
IOptions<AdminSeedOptions> adminSeedOptions,
IOptions<RolesOptions> rolesOptions,
ILogger<DbInitializer> logger)
ILogger<DbInitializer> logger
)
{
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
public async Task SeedAsync(CancellationToken cancellationToken = default)
{
await EnsureRoleAsync(RoleNames.Admin, AppRole.UnlimitedMaxConfigs, AppRole.UnlimitedMaxIpLimit, isSystem: true);
await EnsureRoleAsync(RoleNames.User, rolesOptions.Value.DefaultUserMaxConfigs, rolesOptions.Value.DefaultUserMaxIpLimit, isSystem: true);
await EnsureRoleAsync(
RoleNames.Admin,
AppRole.UnlimitedMaxConfigs,
AppRole.UnlimitedMaxIpLimit,
isSystem: true
);
await EnsureRoleAsync(
RoleNames.User,
rolesOptions.Value.DefaultUserMaxConfigs,
rolesOptions.Value.DefaultUserMaxIpLimit,
isSystem: true
);
await SeedAdminAsync();
await SeedClientAppsAsync(cancellationToken);
}
@@ -33,12 +47,18 @@ public sealed class DbInitializer(
if (await roleManager.RoleExistsAsync(name))
return;
var role = new AppRole(name) { MaxConfigs = maxConfigs, MaxIpLimit = maxIpLimit, IsSystem = isSystem };
var role = new AppRole(name)
{
MaxConfigs = maxConfigs,
MaxIpLimit = maxIpLimit,
IsSystem = isSystem,
};
var result = await roleManager.CreateAsync(role);
if (!result.Succeeded)
{
throw new InvalidOperationException(
$"Failed to create role '{name}': {string.Join(", ", result.Errors.Select(e => e.Description))}");
$"Failed to create role '{name}': {string.Join(", ", result.Errors.Select(e => e.Description))}"
);
}
logger.LogInformation("Created system role {RoleName}", name);
@@ -47,9 +67,14 @@ public sealed class DbInitializer(
private async Task SeedAdminAsync()
{
var options = adminSeedOptions.Value;
if (string.IsNullOrWhiteSpace(options.Username) || string.IsNullOrWhiteSpace(options.Password))
if (
string.IsNullOrWhiteSpace(options.Username)
|| string.IsNullOrWhiteSpace(options.Password)
)
{
logger.LogWarning("AdminSeed__Username/AdminSeed__Password not set — admin account not created");
logger.LogWarning(
"AdminSeed__Username/AdminSeed__Password not set — admin account not created"
);
return;
}
@@ -68,7 +93,8 @@ public sealed class DbInitializer(
if (!createResult.Succeeded)
{
throw new InvalidOperationException(
$"Failed to create admin account: {string.Join(", ", createResult.Errors.Select(e => e.Description))}");
$"Failed to create admin account: {string.Join(", ", createResult.Errors.Select(e => e.Description))}"
);
}
await userManager.AddToRoleAsync(admin, RoleNames.Admin);
@@ -94,12 +120,21 @@ public sealed class DbInitializer(
{
if (!Enum.TryParse<OsPlatform>(entry.OperatingSystem, ignoreCase: true, out var os))
{
logger.LogWarning("Unknown OS '{Os}' in client app catalog seed — skipped", entry.OperatingSystem);
logger.LogWarning(
"Unknown OS '{Os}' in client app catalog seed — skipped",
entry.OperatingSystem
);
continue;
}
var app = ClientApp.Create(
entry.Name, new Uri(entry.DownloadUrl, UriKind.Absolute), os, entry.Description, iconUrl: null, entry.SortOrder);
entry.Name,
new Uri(entry.DownloadUrl, UriKind.Absolute),
os,
entry.Description,
iconUrl: null,
entry.SortOrder
);
dbContext.ClientApps.Add(app);
}
@@ -108,5 +143,11 @@ public sealed class DbInitializer(
}
private sealed record ClientAppSeedEntry(
string Name, string OperatingSystem, string DownloadUrl, string? Description, int SortOrder, bool IsEnabled);
string Name,
string OperatingSystem,
string DownloadUrl,
string? Description,
int SortOrder,
bool IsEnabled
);
}
@@ -4,7 +4,10 @@ namespace PnvPanel.Infrastructure.Identity;
public static class DbInitializerExtensions
{
public static async Task SeedDataAsync(this IServiceProvider services, CancellationToken cancellationToken = default)
public static async Task SeedDataAsync(
this IServiceProvider services,
CancellationToken cancellationToken = default
)
{
await using var scope = services.CreateAsyncScope();
var initializer = scope.ServiceProvider.GetRequiredService<DbInitializer>();
@@ -7,10 +7,17 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Infrastructure.Identity;
internal sealed class IdentityService(UserManager<AppUser> userManager, SignInManager<AppUser> signInManager, RoleManager<AppRole> roleManager)
: IIdentityService
internal sealed class IdentityService(
UserManager<AppUser> userManager,
SignInManager<AppUser> signInManager,
RoleManager<AppRole> roleManager
) : IIdentityService
{
public async Task<Result<Guid>> CreateUserAsync(string userName, string password, CancellationToken cancellationToken)
public async Task<Result<Guid>> CreateUserAsync(
string userName,
string password,
CancellationToken cancellationToken
)
{
var user = new AppUser
{
@@ -22,18 +29,27 @@ internal sealed class IdentityService(UserManager<AppUser> userManager, SignInMa
if (!createResult.Succeeded)
{
return createResult.Errors.Any(e => e.Code == nameof(IdentityErrorDescriber.DuplicateUserName))
return createResult.Errors.Any(e =>
e.Code == nameof(IdentityErrorDescriber.DuplicateUserName)
)
? Result.Failure<Guid>(AuthErrors.DuplicateUserName)
: Result.Failure<Guid>(Error.Validation(
"Auth.RegistrationFailed",
string.Join("; ", createResult.Errors.Select(e => e.Description))));
: Result.Failure<Guid>(
Error.Validation(
"Auth.RegistrationFailed",
string.Join("; ", createResult.Errors.Select(e => e.Description))
)
);
}
await userManager.AddToRoleAsync(user, RoleNames.User);
return Result.Success(user.Id);
}
public async Task<Result<AuthenticatedUser>> ValidateCredentialsAsync(string userName, string password, CancellationToken cancellationToken)
public async Task<Result<AuthenticatedUser>> ValidateCredentialsAsync(
string userName,
string password,
CancellationToken cancellationToken
)
{
var user = await userManager.FindByNameAsync(userName);
if (user is null)
@@ -42,7 +58,11 @@ internal sealed class IdentityService(UserManager<AppUser> userManager, SignInMa
if (user.IsBlocked)
return Result.Failure<AuthenticatedUser>(AuthErrors.UserBlocked);
var checkResult = await signInManager.CheckPasswordSignInAsync(user, password, lockoutOnFailure: true);
var checkResult = await signInManager.CheckPasswordSignInAsync(
user,
password,
lockoutOnFailure: true
);
if (checkResult.IsLockedOut)
return Result.Failure<AuthenticatedUser>(AuthErrors.LockedOut);
if (!checkResult.Succeeded)
@@ -52,7 +72,10 @@ internal sealed class IdentityService(UserManager<AppUser> userManager, SignInMa
return Result.Success(new AuthenticatedUser(user.Id, user.UserName!, roleName));
}
public async Task<CurrentUserProfile?> GetProfileAsync(Guid userId, CancellationToken cancellationToken)
public async Task<CurrentUserProfile?> GetProfileAsync(
Guid userId,
CancellationToken cancellationToken
)
{
var user = await userManager.FindByIdAsync(userId.ToString());
if (user is null)
@@ -60,11 +83,24 @@ internal sealed class IdentityService(UserManager<AppUser> userManager, SignInMa
var role = await GetPrimaryRoleAsync(user);
return new CurrentUserProfile(
user.Id, user.UserName!, role.Id, role.Name!, user.IsActivated, user.IsBlocked, role.MaxConfigs, role.MaxIpLimit,
user.SubscriptionToken);
user.Id,
user.UserName!,
role.Id,
role.Name!,
user.IsActivated,
user.IsBlocked,
role.MaxConfigs,
role.MaxIpLimit,
user.SubscriptionToken
);
}
public async Task<Result> ChangePasswordAsync(Guid userId, string currentPassword, string newPassword, CancellationToken cancellationToken)
public async Task<Result> ChangePasswordAsync(
Guid userId,
string currentPassword,
string newPassword,
CancellationToken cancellationToken
)
{
var user = await userManager.FindByIdAsync(userId.ToString());
if (user is null)
@@ -73,12 +109,19 @@ internal sealed class IdentityService(UserManager<AppUser> userManager, SignInMa
var result = await userManager.ChangePasswordAsync(user, currentPassword, newPassword);
return result.Succeeded
? Result.Success()
: Result.Failure(Error.Validation(
"Auth.PasswordChangeFailed",
string.Join("; ", result.Errors.Select(e => e.Description))));
: Result.Failure(
Error.Validation(
"Auth.PasswordChangeFailed",
string.Join("; ", result.Errors.Select(e => e.Description))
)
);
}
public async Task<Result> ChangeUserNameAsync(Guid userId, string newUserName, CancellationToken cancellationToken)
public async Task<Result> ChangeUserNameAsync(
Guid userId,
string newUserName,
CancellationToken cancellationToken
)
{
var user = await userManager.FindByIdAsync(userId.ToString());
if (user is null)
@@ -90,12 +133,19 @@ internal sealed class IdentityService(UserManager<AppUser> userManager, SignInMa
return result.Errors.Any(e => e.Code == nameof(IdentityErrorDescriber.DuplicateUserName))
? Result.Failure(AuthErrors.DuplicateUserName)
: Result.Failure(Error.Validation(
"Auth.UserNameChangeFailed",
string.Join("; ", result.Errors.Select(e => e.Description))));
: Result.Failure(
Error.Validation(
"Auth.UserNameChangeFailed",
string.Join("; ", result.Errors.Select(e => e.Description))
)
);
}
public async Task<Result> ActivateUserAsync(Guid userId, Guid activatedBy, CancellationToken cancellationToken)
public async Task<Result> ActivateUserAsync(
Guid userId,
Guid activatedBy,
CancellationToken cancellationToken
)
{
var user = await userManager.FindByIdAsync(userId.ToString());
if (user is null)
@@ -110,13 +160,16 @@ internal sealed class IdentityService(UserManager<AppUser> userManager, SignInMa
return Result.Success();
}
public async Task<IReadOnlyDictionary<Guid, string>> GetUserNamesAsync(IReadOnlyCollection<Guid> userIds, CancellationToken cancellationToken)
public async Task<IReadOnlyDictionary<Guid, string>> GetUserNamesAsync(
IReadOnlyCollection<Guid> userIds,
CancellationToken cancellationToken
)
{
if (userIds.Count == 0)
return new Dictionary<Guid, string>();
return await userManager.Users
.Where(u => userIds.Contains(u.Id))
return await userManager
.Users.Where(u => userIds.Contains(u.Id))
.ToDictionaryAsync(u => u.Id, u => u.UserName!, cancellationToken);
}
@@ -129,13 +182,21 @@ internal sealed class IdentityService(UserManager<AppUser> userManager, SignInMa
var result = await userManager.DeleteAsync(user);
return result.Succeeded
? Result.Success()
: Result.Failure(Error.Failure(
"Auth.DeleteAccountFailed", string.Join("; ", result.Errors.Select(e => e.Description))));
: Result.Failure(
Error.Failure(
"Auth.DeleteAccountFailed",
string.Join("; ", result.Errors.Select(e => e.Description))
)
);
}
public async Task<Guid?> FindUserIdBySubscriptionTokenAsync(string token, CancellationToken cancellationToken)
public async Task<Guid?> FindUserIdBySubscriptionTokenAsync(
string token,
CancellationToken cancellationToken
)
{
var user = await userManager.Users.AsNoTracking()
var user = await userManager
.Users.AsNoTracking()
.FirstOrDefaultAsync(u => u.SubscriptionToken == token, cancellationToken);
return user?.Id;
}
@@ -161,7 +222,11 @@ internal sealed class IdentityService(UserManager<AppUser> userManager, SignInMa
return Result.Success();
}
public async Task<Result> ResetPasswordAsync(Guid userId, string newPassword, CancellationToken cancellationToken)
public async Task<Result> ResetPasswordAsync(
Guid userId,
string newPassword,
CancellationToken cancellationToken
)
{
var user = await userManager.FindByIdAsync(userId.ToString());
if (user is null)
@@ -172,11 +237,20 @@ internal sealed class IdentityService(UserManager<AppUser> userManager, SignInMa
return result.Succeeded
? Result.Success()
: Result.Failure(Error.Validation(
"Auth.PasswordResetFailed", string.Join("; ", result.Errors.Select(e => e.Description))));
: 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)
public async Task<PagedList<UserSummaryDto>> ListUsersAsync(
int page,
int pageSize,
string? search,
CancellationToken cancellationToken
)
{
var query = userManager.Users.AsNoTracking();
if (!string.IsNullOrWhiteSpace(search))
@@ -193,7 +267,16 @@ internal sealed class IdentityService(UserManager<AppUser> userManager, SignInMa
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));
items.Add(
new UserSummaryDto(
user.Id,
user.UserName!,
roleName,
user.IsActivated,
user.IsBlocked,
user.ActivatedAt
)
);
}
return new PagedList<UserSummaryDto>(items, total, page, pageSize);
@@ -206,16 +289,27 @@ internal sealed class IdentityService(UserManager<AppUser> userManager, SignInMa
return new UserStatsDto(total, activated);
}
public async Task<Result> LinkTelegramAsync(Guid userId, long telegramUserId, string? telegramUsername, CancellationToken cancellationToken)
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()
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 уже привязан к другому аккаунту."));
return Result.Failure(
Error.Conflict(
"Telegram.AlreadyLinked",
"Этот Telegram уже привязан к другому аккаунту."
)
);
user.TelegramUserId = telegramUserId;
user.TelegramUsername = telegramUsername;
@@ -237,14 +331,21 @@ internal sealed class IdentityService(UserManager<AppUser> userManager, SignInMa
return Result.Success();
}
public async Task<Guid?> FindUserIdByTelegramUserIdAsync(long telegramUserId, CancellationToken cancellationToken)
public async Task<Guid?> FindUserIdByTelegramUserIdAsync(
long telegramUserId,
CancellationToken cancellationToken
)
{
var user = await userManager.Users.AsNoTracking()
var user = await userManager
.Users.AsNoTracking()
.FirstOrDefaultAsync(u => u.TelegramUserId == telegramUserId, cancellationToken);
return user?.Id;
}
public async Task<TelegramLinkInfo> GetTelegramLinkInfoAsync(Guid userId, CancellationToken cancellationToken)
public async Task<TelegramLinkInfo> GetTelegramLinkInfoAsync(
Guid userId,
CancellationToken cancellationToken
)
{
var user = await userManager.FindByIdAsync(userId.ToString());
return user?.TelegramUserId is not null
@@ -252,9 +353,12 @@ internal sealed class IdentityService(UserManager<AppUser> userManager, SignInMa
: new TelegramLinkInfo(false, null, null);
}
public async Task<IReadOnlyCollection<long>> GetActivatedLinkedTelegramUserIdsAsync(CancellationToken cancellationToken)
public async Task<IReadOnlyCollection<long>> GetActivatedLinkedTelegramUserIdsAsync(
CancellationToken cancellationToken
)
{
return await userManager.Users.AsNoTracking()
return await userManager
.Users.AsNoTracking()
.Where(u => u.TelegramUserId != null && u.IsActivated && !u.IsBlocked)
.Select(u => u.TelegramUserId!.Value)
.ToListAsync(cancellationToken);
@@ -273,5 +377,6 @@ internal sealed class IdentityService(UserManager<AppUser> userManager, SignInMa
?? throw new InvalidOperationException($"Роль '{roleName}' не найдена.");
}
private static string GenerateSubscriptionToken() => Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
private static string GenerateSubscriptionToken() =>
Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
}
@@ -11,7 +11,9 @@ internal sealed class JwtTokenService(IOptions<JwtOptions> options) : IJwtTokenS
{
private readonly JwtOptions _options = options.Value;
public (string AccessToken, DateTimeOffset ExpiresAt) GenerateAccessToken(AuthenticatedUser user)
public (string AccessToken, DateTimeOffset ExpiresAt) GenerateAccessToken(
AuthenticatedUser user
)
{
var expiresAt = DateTimeOffset.UtcNow.AddMinutes(_options.AccessTokenMinutes);
@@ -32,7 +34,8 @@ internal sealed class JwtTokenService(IOptions<JwtOptions> options) : IJwtTokenS
audience: _options.Audience,
claims: claims,
expires: expiresAt.UtcDateTime,
signingCredentials: credentials);
signingCredentials: credentials
);
return (new JwtSecurityTokenHandler().WriteToken(token), expiresAt);
}
@@ -9,32 +9,44 @@ using PnvPanel.Infrastructure.Persistence;
namespace PnvPanel.Infrastructure.Identity;
internal sealed class RefreshTokenService(AppDbContext dbContext, IOptions<JwtOptions> options) : IRefreshTokenService
internal sealed class RefreshTokenService(AppDbContext dbContext, IOptions<JwtOptions> options)
: IRefreshTokenService
{
private readonly JwtOptions _options = options.Value;
public async Task<IssuedRefreshToken> IssueAsync(Guid userId, CancellationToken cancellationToken)
public async Task<IssuedRefreshToken> IssueAsync(
Guid userId,
CancellationToken cancellationToken
)
{
var rawToken = GenerateRawToken();
var expiresAt = DateTimeOffset.UtcNow.AddDays(_options.RefreshTokenDays);
dbContext.RefreshTokens.Add(new RefreshToken
{
Id = Guid.NewGuid(),
UserId = userId,
TokenHash = Hash(rawToken),
ExpiresAt = expiresAt,
CreatedAt = DateTimeOffset.UtcNow,
});
dbContext.RefreshTokens.Add(
new RefreshToken
{
Id = Guid.NewGuid(),
UserId = userId,
TokenHash = Hash(rawToken),
ExpiresAt = expiresAt,
CreatedAt = DateTimeOffset.UtcNow,
}
);
await dbContext.SaveChangesAsync(cancellationToken);
return new IssuedRefreshToken(rawToken, expiresAt);
}
public async Task<Result<RotatedRefreshToken>> RotateAsync(string rawToken, CancellationToken cancellationToken)
public async Task<Result<RotatedRefreshToken>> RotateAsync(
string rawToken,
CancellationToken cancellationToken
)
{
var hash = Hash(rawToken);
var existing = await dbContext.RefreshTokens.SingleOrDefaultAsync(t => t.TokenHash == hash, cancellationToken);
var existing = await dbContext.RefreshTokens.SingleOrDefaultAsync(
t => t.TokenHash == hash,
cancellationToken
);
if (existing is null)
return Result.Failure<RotatedRefreshToken>(AuthErrors.InvalidRefreshToken);
@@ -56,14 +68,16 @@ internal sealed class RefreshTokenService(AppDbContext dbContext, IOptions<JwtOp
existing.RevokedAt = DateTimeOffset.UtcNow;
existing.ReplacedByTokenHash = newHash;
dbContext.RefreshTokens.Add(new RefreshToken
{
Id = Guid.NewGuid(),
UserId = existing.UserId,
TokenHash = newHash,
ExpiresAt = newExpiresAt,
CreatedAt = DateTimeOffset.UtcNow,
});
dbContext.RefreshTokens.Add(
new RefreshToken
{
Id = Guid.NewGuid(),
UserId = existing.UserId,
TokenHash = newHash,
ExpiresAt = newExpiresAt,
CreatedAt = DateTimeOffset.UtcNow,
}
);
await dbContext.SaveChangesAsync(cancellationToken);
@@ -73,7 +87,10 @@ internal sealed class RefreshTokenService(AppDbContext dbContext, IOptions<JwtOp
public async Task RevokeAsync(string rawToken, CancellationToken cancellationToken)
{
var hash = Hash(rawToken);
var existing = await dbContext.RefreshTokens.SingleOrDefaultAsync(t => t.TokenHash == hash, cancellationToken);
var existing = await dbContext.RefreshTokens.SingleOrDefaultAsync(
t => t.TokenHash == hash,
cancellationToken
);
if (existing is null || existing.RevokedAt is not null)
return;
@@ -83,8 +100,8 @@ internal sealed class RefreshTokenService(AppDbContext dbContext, IOptions<JwtOp
private async Task RevokeAllForUserAsync(Guid userId, CancellationToken cancellationToken)
{
var activeTokens = await dbContext.RefreshTokens
.Where(t => t.UserId == userId && t.RevokedAt == null)
var activeTokens = await dbContext
.RefreshTokens.Where(t => t.UserId == userId && t.RevokedAt == null)
.ToListAsync(cancellationToken);
foreach (var token in activeTokens)
@@ -93,7 +110,9 @@ internal sealed class RefreshTokenService(AppDbContext dbContext, IOptions<JwtOp
await dbContext.SaveChangesAsync(cancellationToken);
}
private static string GenerateRawToken() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
private static string GenerateRawToken() =>
Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
private static string Hash(string rawToken) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(rawToken)));
private static string Hash(string rawToken) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(rawToken)));
}
@@ -7,25 +7,47 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Infrastructure.Identity;
internal sealed class RoleService(RoleManager<AppRole> roleManager, UserManager<AppUser> userManager) : IRoleService
internal sealed class RoleService(
RoleManager<AppRole> roleManager,
UserManager<AppUser> userManager
) : IRoleService
{
public async Task<Result<RoleDto>> CreateRoleAsync(string name, int maxConfigs, int maxIpLimit, CancellationToken cancellationToken)
public async Task<Result<RoleDto>> CreateRoleAsync(
string name,
int maxConfigs,
int maxIpLimit,
CancellationToken cancellationToken
)
{
if (await roleManager.RoleExistsAsync(name))
return Result.Failure<RoleDto>(RoleErrors.DuplicateName);
var role = new AppRole(name) { MaxConfigs = maxConfigs, MaxIpLimit = maxIpLimit, IsSystem = false };
var role = new AppRole(name)
{
MaxConfigs = maxConfigs,
MaxIpLimit = maxIpLimit,
IsSystem = false,
};
var result = await roleManager.CreateAsync(role);
if (!result.Succeeded)
{
return Result.Failure<RoleDto>(Error.Validation(
"Roles.CreateFailed", string.Join("; ", result.Errors.Select(e => e.Description))));
return Result.Failure<RoleDto>(
Error.Validation(
"Roles.CreateFailed",
string.Join("; ", result.Errors.Select(e => e.Description))
)
);
}
return Result.Success(ToDto(role));
}
public async Task<Result<RoleDto>> UpdateRoleAsync(Guid roleId, int maxConfigs, int maxIpLimit, CancellationToken cancellationToken)
public async Task<Result<RoleDto>> UpdateRoleAsync(
Guid roleId,
int maxConfigs,
int maxIpLimit,
CancellationToken cancellationToken
)
{
var role = await roleManager.FindByIdAsync(roleId.ToString());
if (role is null)
@@ -57,13 +79,17 @@ internal sealed class RoleService(RoleManager<AppRole> roleManager, UserManager<
public async Task<IReadOnlyList<RoleDto>> ListRolesAsync(CancellationToken cancellationToken)
{
return await roleManager.Roles
.OrderBy(r => r.Name)
return await roleManager
.Roles.OrderBy(r => r.Name)
.Select(r => new RoleDto(r.Id, r.Name!, r.MaxConfigs, r.MaxIpLimit, r.IsSystem))
.ToListAsync(cancellationToken);
}
public async Task<Result> ChangeUserRoleAsync(Guid userId, Guid roleId, CancellationToken cancellationToken)
public async Task<Result> ChangeUserRoleAsync(
Guid userId,
Guid roleId,
CancellationToken cancellationToken
)
{
var user = await userManager.FindByIdAsync(userId.ToString());
if (user is null)
@@ -81,5 +107,6 @@ internal sealed class RoleService(RoleManager<AppRole> roleManager, UserManager<
return Result.Success();
}
private static RoleDto ToDto(AppRole role) => new(role.Id, role.Name!, role.MaxConfigs, role.MaxIpLimit, role.IsSystem);
private static RoleDto ToDto(AppRole role) =>
new(role.Id, role.Name!, role.MaxConfigs, role.MaxIpLimit, role.IsSystem);
}
@@ -19,7 +19,8 @@ namespace PnvPanel.Infrastructure.Persistence;
/// (добавляются по мере реализации фич, см. docs/roadmap.md).
/// </summary>
public class AppDbContext(DbContextOptions<AppDbContext> options)
: IdentityDbContext<AppUser, AppRole, Guid>(options), IAppDbContext
: IdentityDbContext<AppUser, AppRole, Guid>(options),
IAppDbContext
{
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
@@ -13,7 +13,8 @@ public class ClientAppConfiguration : IEntityTypeConfiguration<ClientApp>
builder.Property(x => x.Name).IsRequired().HasMaxLength(100);
builder.Property(x => x.DownloadUrl)
builder
.Property(x => x.DownloadUrl)
.IsRequired()
.HasMaxLength(500)
.HasConversion(uri => uri.ToString(), s => new Uri(s, UriKind.Absolute));
@@ -13,7 +13,8 @@ public class NodeConfiguration : IEntityTypeConfiguration<Node>
builder.Property(x => x.Name).IsRequired().HasMaxLength(100);
builder.Property(x => x.BaseAddress)
builder
.Property(x => x.BaseAddress)
.IsRequired()
.HasMaxLength(500)
.HasConversion(uri => uri.ToString(), s => new Uri(s, UriKind.Absolute));
@@ -21,10 +22,20 @@ public class NodeConfiguration : IEntityTypeConfiguration<Node>
builder.Property(x => x.Location).HasMaxLength(100);
builder.Property(x => x.Status).HasConversion<string>().HasMaxLength(32);
builder.OwnsOne(x => x.Credentials, credentials =>
{
credentials.Property(c => c.Username).HasColumnName("CredentialsUsername").IsRequired().HasMaxLength(200);
credentials.Property(c => c.ProtectedPassword).HasColumnName("CredentialsProtectedPassword").IsRequired();
});
builder.OwnsOne(
x => x.Credentials,
credentials =>
{
credentials
.Property(c => c.Username)
.HasColumnName("CredentialsUsername")
.IsRequired()
.HasMaxLength(200);
credentials
.Property(c => c.ProtectedPassword)
.HasColumnName("CredentialsProtectedPassword")
.IsRequired();
}
);
}
}
@@ -11,9 +11,7 @@ public class RefreshTokenConfiguration : IEntityTypeConfiguration<RefreshToken>
builder.ToTable("RefreshTokens");
builder.HasKey(x => x.Id);
builder.Property(x => x.TokenHash)
.IsRequired()
.HasMaxLength(128);
builder.Property(x => x.TokenHash).IsRequired().HasMaxLength(128);
builder.HasIndex(x => x.TokenHash).IsUnique();
builder.HasIndex(x => x.UserId);
@@ -8,7 +8,10 @@ namespace PnvPanel.Infrastructure.Persistence;
/// </summary>
public static class MigrationExtensions
{
public static async Task ApplyMigrationsAsync(this IServiceProvider services, CancellationToken cancellationToken = default)
public static async Task ApplyMigrationsAsync(
this IServiceProvider services,
CancellationToken cancellationToken = default
)
{
await using var scope = services.CreateAsyncScope();
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
@@ -8,15 +8,9 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
protected override void Up(MigrationBuilder migrationBuilder) { }
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
protected override void Down(MigrationBuilder migrationBuilder) { }
}
}
@@ -19,14 +19,23 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
Id = table.Column<Guid>(type: "uuid", nullable: false),
MaxConfigs = table.Column<int>(type: "integer", nullable: false),
IsSystem = table.Column<bool>(type: "boolean", nullable: false),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true)
Name = table.Column<string>(
type: "character varying(256)",
maxLength: 256,
nullable: true
),
NormalizedName = table.Column<string>(
type: "character varying(256)",
maxLength: 256,
nullable: true
),
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true),
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoles", x => x.Id);
});
}
);
migrationBuilder.CreateTable(
name: "AspNetUsers",
@@ -34,12 +43,31 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
IsActivated = table.Column<bool>(type: "boolean", nullable: false),
ActivatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
ActivatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: true
),
ActivatedBy = table.Column<Guid>(type: "uuid", nullable: true),
UserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedUserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
Email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
UserName = table.Column<string>(
type: "character varying(256)",
maxLength: 256,
nullable: true
),
NormalizedUserName = table.Column<string>(
type: "character varying(256)",
maxLength: 256,
nullable: true
),
Email = table.Column<string>(
type: "character varying(256)",
maxLength: 256,
nullable: true
),
NormalizedEmail = table.Column<string>(
type: "character varying(256)",
maxLength: 256,
nullable: true
),
EmailConfirmed = table.Column<bool>(type: "boolean", nullable: false),
PasswordHash = table.Column<string>(type: "text", nullable: true),
SecurityStamp = table.Column<string>(type: "text", nullable: true),
@@ -47,14 +75,18 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
PhoneNumber = table.Column<string>(type: "text", nullable: true),
PhoneNumberConfirmed = table.Column<bool>(type: "boolean", nullable: false),
TwoFactorEnabled = table.Column<bool>(type: "boolean", nullable: false),
LockoutEnd = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
LockoutEnd = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: true
),
LockoutEnabled = table.Column<bool>(type: "boolean", nullable: false),
AccessFailedCount = table.Column<int>(type: "integer", nullable: false)
AccessFailedCount = table.Column<int>(type: "integer", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUsers", x => x.Id);
});
}
);
migrationBuilder.CreateTable(
name: "RefreshTokens",
@@ -62,26 +94,44 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
TokenHash = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
ExpiresAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
RevokedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
ReplacedByTokenHash = table.Column<string>(type: "text", nullable: true)
TokenHash = table.Column<string>(
type: "character varying(128)",
maxLength: 128,
nullable: false
),
ExpiresAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
RevokedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: true
),
ReplacedByTokenHash = table.Column<string>(type: "text", nullable: true),
},
constraints: table =>
{
table.PrimaryKey("PK_RefreshTokens", x => x.Id);
});
}
);
migrationBuilder.CreateTable(
name: "AspNetRoleClaims",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Id = table
.Column<int>(type: "integer", nullable: false)
.Annotation(
"Npgsql:ValueGenerationStrategy",
NpgsqlValueGenerationStrategy.IdentityByDefaultColumn
),
RoleId = table.Column<Guid>(type: "uuid", nullable: false),
ClaimType = table.Column<string>(type: "text", nullable: true),
ClaimValue = table.Column<string>(type: "text", nullable: true)
ClaimValue = table.Column<string>(type: "text", nullable: true),
},
constraints: table =>
{
@@ -91,18 +141,24 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateTable(
name: "AspNetUserClaims",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Id = table
.Column<int>(type: "integer", nullable: false)
.Annotation(
"Npgsql:ValueGenerationStrategy",
NpgsqlValueGenerationStrategy.IdentityByDefaultColumn
),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
ClaimType = table.Column<string>(type: "text", nullable: true),
ClaimValue = table.Column<string>(type: "text", nullable: true)
ClaimValue = table.Column<string>(type: "text", nullable: true),
},
constraints: table =>
{
@@ -112,8 +168,10 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateTable(
name: "AspNetUserLogins",
@@ -122,25 +180,30 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
LoginProvider = table.Column<string>(type: "text", nullable: false),
ProviderKey = table.Column<string>(type: "text", nullable: false),
ProviderDisplayName = table.Column<string>(type: "text", nullable: true),
UserId = table.Column<Guid>(type: "uuid", nullable: false)
UserId = table.Column<Guid>(type: "uuid", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey });
table.PrimaryKey(
"PK_AspNetUserLogins",
x => new { x.LoginProvider, x.ProviderKey }
);
table.ForeignKey(
name: "FK_AspNetUserLogins_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateTable(
name: "AspNetUserRoles",
columns: table => new
{
UserId = table.Column<Guid>(type: "uuid", nullable: false),
RoleId = table.Column<Guid>(type: "uuid", nullable: false)
RoleId = table.Column<Guid>(type: "uuid", nullable: false),
},
constraints: table =>
{
@@ -150,14 +213,17 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
onDelete: ReferentialAction.Cascade
);
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateTable(
name: "AspNetUserTokens",
@@ -166,94 +232,105 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
UserId = table.Column<Guid>(type: "uuid", nullable: false),
LoginProvider = table.Column<string>(type: "text", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Value = table.Column<string>(type: "text", nullable: true)
Value = table.Column<string>(type: "text", nullable: true),
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
table.PrimaryKey(
"PK_AspNetUserTokens",
x => new
{
x.UserId,
x.LoginProvider,
x.Name,
}
);
table.ForeignKey(
name: "FK_AspNetUserTokens_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateIndex(
name: "IX_AspNetRoleClaims_RoleId",
table: "AspNetRoleClaims",
column: "RoleId");
column: "RoleId"
);
migrationBuilder.CreateIndex(
name: "RoleNameIndex",
table: "AspNetRoles",
column: "NormalizedName",
unique: true);
unique: true
);
migrationBuilder.CreateIndex(
name: "IX_AspNetUserClaims_UserId",
table: "AspNetUserClaims",
column: "UserId");
column: "UserId"
);
migrationBuilder.CreateIndex(
name: "IX_AspNetUserLogins_UserId",
table: "AspNetUserLogins",
column: "UserId");
column: "UserId"
);
migrationBuilder.CreateIndex(
name: "IX_AspNetUserRoles_RoleId",
table: "AspNetUserRoles",
column: "RoleId");
column: "RoleId"
);
migrationBuilder.CreateIndex(
name: "EmailIndex",
table: "AspNetUsers",
column: "NormalizedEmail");
column: "NormalizedEmail"
);
migrationBuilder.CreateIndex(
name: "UserNameIndex",
table: "AspNetUsers",
column: "NormalizedUserName",
unique: true);
unique: true
);
migrationBuilder.CreateIndex(
name: "IX_RefreshTokens_TokenHash",
table: "RefreshTokens",
column: "TokenHash",
unique: true);
unique: true
);
migrationBuilder.CreateIndex(
name: "IX_RefreshTokens_UserId",
table: "RefreshTokens",
column: "UserId");
column: "UserId"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AspNetRoleClaims");
migrationBuilder.DropTable(name: "AspNetRoleClaims");
migrationBuilder.DropTable(
name: "AspNetUserClaims");
migrationBuilder.DropTable(name: "AspNetUserClaims");
migrationBuilder.DropTable(
name: "AspNetUserLogins");
migrationBuilder.DropTable(name: "AspNetUserLogins");
migrationBuilder.DropTable(
name: "AspNetUserRoles");
migrationBuilder.DropTable(name: "AspNetUserRoles");
migrationBuilder.DropTable(
name: "AspNetUserTokens");
migrationBuilder.DropTable(name: "AspNetUserTokens");
migrationBuilder.DropTable(
name: "RefreshTokens");
migrationBuilder.DropTable(name: "RefreshTokens");
migrationBuilder.DropTable(
name: "AspNetRoles");
migrationBuilder.DropTable(name: "AspNetRoles");
migrationBuilder.DropTable(
name: "AspNetUsers");
migrationBuilder.DropTable(name: "AspNetUsers");
}
}
}
@@ -17,29 +17,48 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Comment = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Comment = table.Column<string>(
type: "character varying(500)",
maxLength: 500,
nullable: true
),
Status = table.Column<string>(
type: "character varying(32)",
maxLength: 32,
nullable: false
),
DecidedBy = table.Column<Guid>(type: "uuid", nullable: true),
DecidedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
RejectionReason = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
DecidedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: true
),
RejectionReason = table.Column<string>(
type: "character varying(500)",
maxLength: 500,
nullable: true
),
CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("PK_ActivationRequests", x => x.Id);
});
}
);
migrationBuilder.CreateIndex(
name: "IX_ActivationRequests_UserId_Status",
table: "ActivationRequests",
columns: new[] { "UserId", "Status" });
columns: new[] { "UserId", "Status" }
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ActivationRequests");
migrationBuilder.DropTable(name: "ActivationRequests");
}
}
}
@@ -17,56 +17,105 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
NodeId = table.Column<Guid>(type: "uuid", nullable: false),
RemoteInboundId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Protocol = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Remark = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
RemoteInboundId = table.Column<string>(
type: "character varying(64)",
maxLength: 64,
nullable: false
),
Protocol = table.Column<string>(
type: "character varying(32)",
maxLength: 32,
nullable: false
),
Remark = table.Column<string>(
type: "character varying(200)",
maxLength: 200,
nullable: false
),
Port = table.Column<int>(type: "integer", nullable: false),
IsPublished = table.Column<bool>(type: "boolean", nullable: false),
DisplayName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
DisplayName = table.Column<string>(
type: "character varying(100)",
maxLength: 100,
nullable: true
),
MaxClients = table.Column<int>(type: "integer", nullable: true),
AllowedRoleIds = table.Column<Guid[]>(type: "uuid[]", nullable: false),
LastSyncAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
LastSyncAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: true
),
},
constraints: table =>
{
table.PrimaryKey("PK_Inbounds", x => x.Id);
});
}
);
migrationBuilder.CreateTable(
name: "Nodes",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
BaseAddress = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
CredentialsUsername = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
CredentialsProtectedPassword = table.Column<string>(type: "text", nullable: false),
Location = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Name = table.Column<string>(
type: "character varying(100)",
maxLength: 100,
nullable: false
),
BaseAddress = table.Column<string>(
type: "character varying(500)",
maxLength: 500,
nullable: false
),
CredentialsUsername = table.Column<string>(
type: "character varying(200)",
maxLength: 200,
nullable: false
),
CredentialsProtectedPassword = table.Column<string>(
type: "text",
nullable: false
),
Location = table.Column<string>(
type: "character varying(100)",
maxLength: 100,
nullable: true
),
Status = table.Column<string>(
type: "character varying(32)",
maxLength: 32,
nullable: false
),
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
LastSyncAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
LastSyncAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: true
),
CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("PK_Nodes", x => x.Id);
});
}
);
migrationBuilder.CreateIndex(
name: "IX_Inbounds_NodeId_RemoteInboundId",
table: "Inbounds",
columns: new[] { "NodeId", "RemoteInboundId" },
unique: true);
unique: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Inbounds");
migrationBuilder.DropTable(name: "Inbounds");
migrationBuilder.DropTable(
name: "Nodes");
migrationBuilder.DropTable(name: "Nodes");
}
}
}
@@ -16,25 +16,47 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
table: "AspNetUsers",
type: "text",
nullable: false,
defaultValue: "");
defaultValue: ""
);
migrationBuilder.CreateTable(
name: "ClientApps",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
DownloadUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
OperatingSystem = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Description = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: true),
IconUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
Name = table.Column<string>(
type: "character varying(100)",
maxLength: 100,
nullable: false
),
DownloadUrl = table.Column<string>(
type: "character varying(500)",
maxLength: 500,
nullable: false
),
OperatingSystem = table.Column<string>(
type: "character varying(32)",
maxLength: 32,
nullable: false
),
Description = table.Column<string>(
type: "character varying(300)",
maxLength: 300,
nullable: true
),
IconUrl = table.Column<string>(
type: "character varying(500)",
maxLength: 500,
nullable: true
),
SortOrder = table.Column<int>(type: "integer", nullable: false),
IsEnabled = table.Column<bool>(type: "boolean", nullable: false)
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_ClientApps", x => x.Id);
});
}
);
migrationBuilder.CreateTable(
name: "VpnConfigs",
@@ -43,53 +65,86 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
InboundId = table.Column<Guid>(type: "uuid", nullable: false),
Label = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
ClientEmail = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
ClientExternalId = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Protocol = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Label = table.Column<string>(
type: "character varying(100)",
maxLength: 100,
nullable: true
),
ClientEmail = table.Column<string>(
type: "character varying(200)",
maxLength: 200,
nullable: false
),
ClientExternalId = table.Column<string>(
type: "character varying(200)",
maxLength: 200,
nullable: false
),
Protocol = table.Column<string>(
type: "character varying(32)",
maxLength: 32,
nullable: false
),
DeviceLimit = table.Column<int>(type: "integer", nullable: false),
UsedUpBytes = table.Column<long>(type: "bigint", nullable: false),
UsedDownBytes = table.Column<long>(type: "bigint", nullable: false),
ExpiresAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
SubscriptionToken = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
LastSyncAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
ExpiresAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: true
),
Status = table.Column<string>(
type: "character varying(32)",
maxLength: 32,
nullable: false
),
SubscriptionToken = table.Column<string>(
type: "character varying(128)",
maxLength: 128,
nullable: false
),
LastSyncAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: true
),
CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("PK_VpnConfigs", x => x.Id);
});
}
);
migrationBuilder.CreateIndex(
name: "IX_VpnConfigs_InboundId",
table: "VpnConfigs",
column: "InboundId");
column: "InboundId"
);
migrationBuilder.CreateIndex(
name: "IX_VpnConfigs_SubscriptionToken",
table: "VpnConfigs",
column: "SubscriptionToken",
unique: true);
unique: true
);
migrationBuilder.CreateIndex(
name: "IX_VpnConfigs_UserId_Status",
table: "VpnConfigs",
columns: new[] { "UserId", "Status" });
columns: new[] { "UserId", "Status" }
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ClientApps");
migrationBuilder.DropTable(name: "ClientApps");
migrationBuilder.DropTable(
name: "VpnConfigs");
migrationBuilder.DropTable(name: "VpnConfigs");
migrationBuilder.DropColumn(
name: "SubscriptionToken",
table: "AspNetUsers");
migrationBuilder.DropColumn(name: "SubscriptionToken", table: "AspNetUsers");
}
}
}
@@ -16,29 +16,37 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
name: "TrafficSamples",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
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),
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)
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" });
columns: new[] { "ConfigId", "Timestamp" }
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TrafficSamples");
migrationBuilder.DropTable(name: "TrafficSamples");
}
}
}
@@ -17,42 +17,65 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
table: "AspNetUsers",
type: "boolean",
nullable: false,
defaultValue: false);
defaultValue: false
);
migrationBuilder.CreateTable(
name: "AuditLogs",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
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),
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)
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");
column: "CreatedAt"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AuditLogs");
migrationBuilder.DropTable(name: "AuditLogs");
migrationBuilder.DropColumn(
name: "IsBlocked",
table: "AspNetUsers");
migrationBuilder.DropColumn(name: "IsBlocked", table: "AspNetUsers");
}
}
}
@@ -15,98 +15,121 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
name: "TelegramLinkedAt",
table: "AspNetUsers",
type: "timestamp with time zone",
nullable: true);
nullable: true
);
migrationBuilder.AddColumn<long>(
name: "TelegramUserId",
table: "AspNetUsers",
type: "bigint",
nullable: true);
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "TelegramUsername",
table: "AspNetUsers",
type: "text",
nullable: true);
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),
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)
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),
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)
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);
unique: true
);
migrationBuilder.CreateIndex(
name: "IX_AspNetUsers_TelegramUserId",
table: "AspNetUsers",
column: "TelegramUserId",
unique: true);
unique: true
);
migrationBuilder.CreateIndex(
name: "IX_TelegramLinkTokens_Token",
table: "TelegramLinkTokens",
column: "Token",
unique: true);
unique: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TelegramLinkTokens");
migrationBuilder.DropTable(name: "TelegramLinkTokens");
migrationBuilder.DropTable(
name: "TelegramLoginRequests");
migrationBuilder.DropTable(name: "TelegramLoginRequests");
migrationBuilder.DropIndex(
name: "IX_AspNetUsers_SubscriptionToken",
table: "AspNetUsers");
table: "AspNetUsers"
);
migrationBuilder.DropIndex(
name: "IX_AspNetUsers_TelegramUserId",
table: "AspNetUsers");
migrationBuilder.DropIndex(name: "IX_AspNetUsers_TelegramUserId", table: "AspNetUsers");
migrationBuilder.DropColumn(
name: "TelegramLinkedAt",
table: "AspNetUsers");
migrationBuilder.DropColumn(name: "TelegramLinkedAt", table: "AspNetUsers");
migrationBuilder.DropColumn(
name: "TelegramUserId",
table: "AspNetUsers");
migrationBuilder.DropColumn(name: "TelegramUserId", table: "AspNetUsers");
migrationBuilder.DropColumn(
name: "TelegramUsername",
table: "AspNetUsers");
migrationBuilder.DropColumn(name: "TelegramUsername", table: "AspNetUsers");
}
}
}
@@ -10,9 +10,7 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DeviceLimit",
table: "VpnConfigs");
migrationBuilder.DropColumn(name: "DeviceLimit", table: "VpnConfigs");
}
/// <inheritdoc />
@@ -23,7 +21,8 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
table: "VpnConfigs",
type: "integer",
nullable: false,
defaultValue: 0);
defaultValue: 0
);
}
}
}
@@ -16,27 +16,42 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Title = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Body = table.Column<string>(type: "character varying(20000)", maxLength: 20000, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
Title = table.Column<string>(
type: "character varying(200)",
maxLength: 200,
nullable: false
),
Body = table.Column<string>(
type: "character varying(20000)",
maxLength: 20000,
nullable: false
),
CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
UpdatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: true
),
},
constraints: table =>
{
table.PrimaryKey("PK_NewsPosts", x => x.Id);
});
}
);
migrationBuilder.CreateIndex(
name: "IX_NewsPosts_CreatedAt",
table: "NewsPosts",
column: "CreatedAt");
column: "CreatedAt"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "NewsPosts");
migrationBuilder.DropTable(name: "NewsPosts");
}
}
}
@@ -18,17 +18,18 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
table: "AspNetRoles",
type: "integer",
nullable: false,
defaultValue: -1);
defaultValue: -1
);
migrationBuilder.Sql("UPDATE \"AspNetRoles\" SET \"MaxIpLimit\" = 2 WHERE \"Name\" = 'user';");
migrationBuilder.Sql(
"UPDATE \"AspNetRoles\" SET \"MaxIpLimit\" = 2 WHERE \"Name\" = 'user';"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "MaxIpLimit",
table: "AspNetRoles");
migrationBuilder.DropColumn(name: "MaxIpLimit", table: "AspNetRoles");
}
}
}
@@ -17,18 +17,34 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Type = table.Column<string>(
type: "character varying(32)",
maxLength: 32,
nullable: false
),
Status = table.Column<string>(
type: "character varying(32)",
maxLength: 32,
nullable: false
),
RequestedRoleId = table.Column<Guid>(type: "uuid", nullable: true),
ProposedRoleName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
ProposedRoleName = table.Column<string>(
type: "character varying(100)",
maxLength: 100,
nullable: true
),
ProposedMaxConfigs = table.Column<int>(type: "integer", nullable: true),
ProposedMaxIpLimit = table.Column<int>(type: "integer", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("PK_SupportTickets", x => x.Id);
});
}
);
migrationBuilder.CreateTable(
name: "TicketAttachments",
@@ -36,16 +52,32 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CommentId = table.Column<Guid>(type: "uuid", nullable: false),
FileName = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
StoredFileName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
ContentType = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
FileName = table.Column<string>(
type: "character varying(255)",
maxLength: 255,
nullable: false
),
StoredFileName = table.Column<string>(
type: "character varying(100)",
maxLength: 100,
nullable: false
),
ContentType = table.Column<string>(
type: "character varying(100)",
maxLength: 100,
nullable: false
),
SizeBytes = table.Column<long>(type: "bigint", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("PK_TicketAttachments", x => x.Id);
});
}
);
migrationBuilder.CreateTable(
name: "TicketComments",
@@ -54,52 +86,62 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
Id = table.Column<Guid>(type: "uuid", nullable: false),
TicketId = table.Column<Guid>(type: "uuid", nullable: false),
AuthorId = table.Column<Guid>(type: "uuid", nullable: false),
Body = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
Body = table.Column<string>(
type: "character varying(4000)",
maxLength: 4000,
nullable: false
),
CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("PK_TicketComments", x => x.Id);
});
}
);
migrationBuilder.CreateIndex(
name: "IX_SupportTickets_Type_Status",
table: "SupportTickets",
columns: new[] { "Type", "Status" });
columns: new[] { "Type", "Status" }
);
migrationBuilder.CreateIndex(
name: "IX_SupportTickets_UserId_Status",
table: "SupportTickets",
columns: new[] { "UserId", "Status" });
columns: new[] { "UserId", "Status" }
);
migrationBuilder.CreateIndex(
name: "IX_TicketAttachments_CommentId",
table: "TicketAttachments",
column: "CommentId");
column: "CommentId"
);
migrationBuilder.CreateIndex(
name: "IX_TicketAttachments_StoredFileName",
table: "TicketAttachments",
column: "StoredFileName",
unique: true);
unique: true
);
migrationBuilder.CreateIndex(
name: "IX_TicketComments_TicketId_CreatedAt",
table: "TicketComments",
columns: new[] { "TicketId", "CreatedAt" });
columns: new[] { "TicketId", "CreatedAt" }
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SupportTickets");
migrationBuilder.DropTable(name: "SupportTickets");
migrationBuilder.DropTable(
name: "TicketAttachments");
migrationBuilder.DropTable(name: "TicketAttachments");
migrationBuilder.DropTable(
name: "TicketComments");
migrationBuilder.DropTable(name: "TicketComments");
}
}
}
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\PnvPanel.Application\PnvPanel.Application.csproj" />
</ItemGroup>
@@ -22,5 +21,4 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -21,8 +21,8 @@ internal sealed class XuiPanelGateway(
IXuiHttpClientFactory httpClientFactory,
IXuiConnectionStringBuilderResolver connectionStringResolver,
ISecretProtector secretProtector,
ILoggerFactory loggerFactory)
: IXuiPanelGateway, IDisposable
ILoggerFactory loggerFactory
) : IXuiPanelGateway, IDisposable
{
private readonly ConcurrentDictionary<Guid, Lazy<IXuiClient>> _clients = new();
@@ -30,7 +30,12 @@ internal sealed class XuiPanelGateway(
{
return XuiBaseUrlValidator.IsAllowed(baseAddress.ToString(), out var reason)
? Result.Success()
: Result.Failure(Error.Validation("Nodes.BaseAddressNotAllowed", reason ?? "Адрес панели не разрешён."));
: Result.Failure(
Error.Validation(
"Nodes.BaseAddressNotAllowed",
reason ?? "Адрес панели не разрешён."
)
);
}
public async Task<NodeProbeResult> ProbeAsync(Node node, CancellationToken cancellationToken)
@@ -47,7 +52,10 @@ internal sealed class XuiPanelGateway(
}
}
public async Task<Result<IReadOnlyList<RemoteInboundInfo>>> ListInboundsAsync(Node node, CancellationToken cancellationToken)
public async Task<Result<IReadOnlyList<RemoteInboundInfo>>> ListInboundsAsync(
Node node,
CancellationToken cancellationToken
)
{
try
{
@@ -57,7 +65,12 @@ internal sealed class XuiPanelGateway(
var mapped = remoteInbounds
.Select(i => (Summary: i, Protocol: TryParseProtocol(i.Protocol)))
.Where(x => x.Protocol is not null)
.Select(x => new RemoteInboundInfo(x.Summary.ExternalId, x.Protocol!.Value, x.Summary.Remark, x.Summary.Port))
.Select(x => new RemoteInboundInfo(
x.Summary.ExternalId,
x.Protocol!.Value,
x.Summary.Remark,
x.Summary.Port
))
.ToList();
return Result.Success<IReadOnlyList<RemoteInboundInfo>>(mapped);
@@ -65,13 +78,20 @@ internal sealed class XuiPanelGateway(
catch (Exception ex)
{
return Result.Failure<IReadOnlyList<RemoteInboundInfo>>(
Error.Failure("Xui.Unreachable", $"Нода недоступна: {ex.Message}"));
Error.Failure("Xui.Unreachable", $"Нода недоступна: {ex.Message}")
);
}
}
public async Task<Result<string>> AddClientAsync(
Node node, string inboundRemoteId, VpnProtocol protocol, string clientEmail, string clientName, int limitIp,
CancellationToken cancellationToken)
Node node,
string inboundRemoteId,
VpnProtocol protocol,
string clientEmail,
string clientName,
int limitIp,
CancellationToken cancellationToken
)
{
try
{
@@ -79,83 +99,146 @@ internal sealed class XuiPanelGateway(
// Наш domain-sentinel RoleQuota.Unlimited (-1) переводим в нативное "без лимита" 3x-ui (0) —
// ThreeXui.Net.AddClientRequest.LimitIp обязателен (не nullable), 0 в самом 3x-ui означает unlimited.
var request = new AddClientRequest(
clientName, clientEmail, ToRemoteProtocol(protocol), limitIp == RoleQuota.Unlimited ? 0 : limitIp, null);
clientName,
clientEmail,
ToRemoteProtocol(protocol),
limitIp == RoleQuota.Unlimited ? 0 : limitIp,
null
);
var result = await client.AddClientAsync(inboundRemoteId, request, cancellationToken);
return Result.Success(result.ExternalClientId);
}
catch (Exception ex)
{
return Result.Failure<string>(Error.Failure("Xui.AddClientFailed", $"Не удалось создать клиента: {ex.Message}"));
return Result.Failure<string>(
Error.Failure("Xui.AddClientFailed", $"Не удалось создать клиента: {ex.Message}")
);
}
}
public async Task<Result> RemoveClientAsync(
Node node, string inboundRemoteId, string clientExternalId, VpnProtocol protocol, CancellationToken cancellationToken)
Node node,
string inboundRemoteId,
string clientExternalId,
VpnProtocol protocol,
CancellationToken cancellationToken
)
{
try
{
var client = GetClient(node);
await client.RemoveClientAsync(inboundRemoteId, clientExternalId, ToRemoteProtocol(protocol), cancellationToken);
await client.RemoveClientAsync(
inboundRemoteId,
clientExternalId,
ToRemoteProtocol(protocol),
cancellationToken
);
return Result.Success();
}
catch (Exception ex)
{
return Result.Failure(Error.Failure("Xui.RemoveClientFailed", $"Не удалось удалить клиента: {ex.Message}"));
return Result.Failure(
Error.Failure("Xui.RemoveClientFailed", $"Не удалось удалить клиента: {ex.Message}")
);
}
}
public async Task<Result> UpdateClientAsync(
Node node, string inboundRemoteId, string clientExternalId, VpnProtocol protocol,
string name, bool enable, CancellationToken cancellationToken)
Node node,
string inboundRemoteId,
string clientExternalId,
VpnProtocol protocol,
string name,
bool enable,
CancellationToken cancellationToken
)
{
try
{
var client = GetClient(node);
// deviceLimit: null — не трогаем то, что уже стоит на клиенте в панели (см. AddClientAsync).
var request = new UpdateClientRequest(null, null, enable, name);
await client.UpdateClientAsync(inboundRemoteId, clientExternalId, ToRemoteProtocol(protocol), request, cancellationToken);
await client.UpdateClientAsync(
inboundRemoteId,
clientExternalId,
ToRemoteProtocol(protocol),
request,
cancellationToken
);
return Result.Success();
}
catch (Exception ex)
{
return Result.Failure(Error.Failure("Xui.UpdateClientFailed", $"Не удалось изменить клиента: {ex.Message}"));
return Result.Failure(
Error.Failure(
"Xui.UpdateClientFailed",
$"Не удалось изменить клиента: {ex.Message}"
)
);
}
}
public async Task<Result<string>> BuildConnectionStringAsync(
Node node, Inbound inbound, string clientExternalId, string clientName, string publicHost,
CancellationToken cancellationToken)
Node node,
Inbound inbound,
string clientExternalId,
string clientName,
string publicHost,
CancellationToken cancellationToken
)
{
try
{
var client = GetClient(node);
var remoteInbound = await client.GetInboundAsync(inbound.RemoteInboundId, cancellationToken);
var remoteInbound = await client.GetInboundAsync(
inbound.RemoteInboundId,
cancellationToken
);
if (remoteInbound is null)
{
return Result.Failure<string>(
Error.Failure("Xui.ConnectionStringFailed", "Inbound не найден на панели."));
Error.Failure("Xui.ConnectionStringFailed", "Inbound не найден на панели.")
);
}
var builder = connectionStringResolver.Resolve(ToRemoteProtocol(inbound.Protocol));
if (builder is null)
{
return Result.Failure<string>(
Error.Failure("Xui.ConnectionStringFailed", $"Протокол {inbound.Protocol} не поддерживается."));
Error.Failure(
"Xui.ConnectionStringFailed",
$"Протокол {inbound.Protocol} не поддерживается."
)
);
}
var request = new XuiConnectionStringRequest(
clientExternalId, clientName, inbound.Port, publicHost, node.BaseAddress.ToString(), remoteInbound);
clientExternalId,
clientName,
inbound.Port,
publicHost,
node.BaseAddress.ToString(),
remoteInbound
);
return Result.Success(builder.Build(request));
}
catch (Exception ex)
{
return Result.Failure<string>(Error.Failure("Xui.ConnectionStringFailed", $"Не удалось построить ссылку: {ex.Message}"));
return Result.Failure<string>(
Error.Failure(
"Xui.ConnectionStringFailed",
$"Не удалось построить ссылку: {ex.Message}"
)
);
}
}
public async Task<Result<IReadOnlyDictionary<string, ClientTrafficInfo>>> GetClientTrafficAsync(
Node node, string inboundRemoteId, CancellationToken cancellationToken)
Node node,
string inboundRemoteId,
CancellationToken cancellationToken
)
{
try
{
@@ -164,7 +247,8 @@ internal sealed class XuiPanelGateway(
if (remoteInbound is null)
{
return Result.Failure<IReadOnlyDictionary<string, ClientTrafficInfo>>(
Error.Failure("Xui.InboundNotFound", "Inbound не найден на панели."));
Error.Failure("Xui.InboundNotFound", "Inbound не найден на панели.")
);
}
return Result.Success(ParseClientStats(remoteInbound.RawInboundJson));
@@ -172,7 +256,8 @@ internal sealed class XuiPanelGateway(
catch (Exception ex)
{
return Result.Failure<IReadOnlyDictionary<string, ClientTrafficInfo>>(
Error.Failure("Xui.TrafficFetchFailed", $"Не удалось получить трафик: {ex.Message}"));
Error.Failure("Xui.TrafficFetchFailed", $"Не удалось получить трафик: {ex.Message}")
);
}
}
@@ -181,7 +266,9 @@ internal sealed class XuiPanelGateway(
/// стандартное поле 3x-ui API "clientStats": [{ "email": "...", "up": N, "down": N }, ...].
/// Формат форка может отличаться — при ошибке парсинга просто возвращаем пусто, не валим синхронизацию.
/// </summary>
private static IReadOnlyDictionary<string, ClientTrafficInfo> ParseClientStats(string? rawInboundJson)
private static IReadOnlyDictionary<string, ClientTrafficInfo> ParseClientStats(
string? rawInboundJson
)
{
var result = new Dictionary<string, ClientTrafficInfo>();
if (string.IsNullOrWhiteSpace(rawInboundJson))
@@ -190,12 +277,18 @@ internal sealed class XuiPanelGateway(
try
{
using var doc = JsonDocument.Parse(rawInboundJson);
if (!doc.RootElement.TryGetProperty("clientStats", out var clientStats) || clientStats.ValueKind != JsonValueKind.Array)
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)
if (
!stat.TryGetProperty("email", out var emailProp)
|| emailProp.ValueKind != JsonValueKind.String
)
continue;
var email = emailProp.GetString();
@@ -221,34 +314,40 @@ internal sealed class XuiPanelGateway(
(lazy.Value as IDisposable)?.Dispose();
}
private IXuiClient GetClient(Node node)
=> _clients.GetOrAdd(node.Id, _ => new Lazy<IXuiClient>(() => CreateClient(node))).Value;
private IXuiClient GetClient(Node node) =>
_clients.GetOrAdd(node.Id, _ => new Lazy<IXuiClient>(() => CreateClient(node))).Value;
private IXuiClient CreateClient(Node node)
{
var httpClient = httpClientFactory.Create(node.BaseAddress, allowInsecureTls: false, timeout: TimeSpan.FromSeconds(15));
var httpClient = httpClientFactory.Create(
node.BaseAddress,
allowInsecureTls: false,
timeout: TimeSpan.FromSeconds(15)
);
var password = secretProtector.Unprotect(node.Credentials.ProtectedPassword);
var logger = loggerFactory.CreateLogger<XuiClient>();
return new XuiClient(httpClient, node.Credentials.Username, password, logger);
}
private static VpnProtocol? TryParseProtocol(string raw) => raw.ToLowerInvariant() switch
{
"vless" => VpnProtocol.Vless,
"vmess" => VpnProtocol.Vmess,
"trojan" => VpnProtocol.Trojan,
"shadowsocks" => VpnProtocol.Shadowsocks,
_ => null,
};
private static VpnProtocol? TryParseProtocol(string raw) =>
raw.ToLowerInvariant() switch
{
"vless" => VpnProtocol.Vless,
"vmess" => VpnProtocol.Vmess,
"trojan" => VpnProtocol.Trojan,
"shadowsocks" => VpnProtocol.Shadowsocks,
_ => null,
};
private static string ToRemoteProtocol(VpnProtocol protocol) => protocol switch
{
VpnProtocol.Vless => "vless",
VpnProtocol.Vmess => "vmess",
VpnProtocol.Trojan => "trojan",
VpnProtocol.Shadowsocks => "shadowsocks",
_ => throw new ArgumentOutOfRangeException(nameof(protocol)),
};
private static string ToRemoteProtocol(VpnProtocol protocol) =>
protocol switch
{
VpnProtocol.Vless => "vless",
VpnProtocol.Vmess => "vmess",
VpnProtocol.Trojan => "trojan",
VpnProtocol.Shadowsocks => "shadowsocks",
_ => throw new ArgumentOutOfRangeException(nameof(protocol)),
};
public void Dispose()
{