diff --git a/backend/src/PnvPanel.Api/Endpoints/MediaEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/MediaEndpoints.cs
new file mode 100644
index 0000000..0ccd4d8
--- /dev/null
+++ b/backend/src/PnvPanel.Api/Endpoints/MediaEndpoints.cs
@@ -0,0 +1,67 @@
+using LiteCqrs;
+using PnvPanel.Api.Common;
+using PnvPanel.Application.Admin.Media;
+using PnvPanel.Application.Media;
+using PnvPanel.Application.Media.GetImage;
+using PnvPanel.Infrastructure.Identity;
+
+namespace PnvPanel.Api.Endpoints;
+
+public static class MediaEndpoints
+{
+ /// Год: содержимое картинки по Id неизменно (перезалив даёт новый Id).
+ private const string ImageCacheControl = "public, max-age=31536000, immutable";
+
+ public static IEndpointRouteBuilder MapMediaEndpoints(this IEndpointRouteBuilder app)
+ {
+ // Отдача — анонимная: markdown рендерится обычным
, который не шлёт Authorization.
+ // Защита — непрозрачный Guid в ссылке; в картинках инструкций/новостей нет персональных данных.
+ var group = app.MapGroup("/api/media").WithTags("Media");
+ group.MapGet("/images/{id:guid}", GetImage);
+
+ var admin = app.MapGroup("/api/admin/media")
+ .WithTags("Admin.Media")
+ .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
+
+ admin.MapPost("/images", UploadImage).DisableAntiforgery().Produces();
+
+ return app;
+ }
+
+ private static async Task GetImage(
+ Guid id,
+ HttpContext httpContext,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(new GetMediaImageQuery(id), cancellationToken);
+ if (!result.IsSuccess)
+ return result.ToHttpResult();
+
+ httpContext.Response.Headers.CacheControl = ImageCacheControl;
+ return Results.File(result.Value.Content, result.Value.ContentType);
+ }
+
+ private static async Task UploadImage(
+ IFormFile? file,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ if (file is null)
+ return Results.Problem(
+ title: MediaErrors.EmptyImage.Message,
+ statusCode: StatusCodes.Status400BadRequest
+ );
+
+ var upload = new MediaImageUpload(
+ file.OpenReadStream(),
+ file.FileName,
+ file.ContentType,
+ file.Length
+ );
+ var result = await sender.Send(new UploadMediaImageCommand(upload), cancellationToken);
+ return result.ToHttpResult();
+ }
+}
diff --git a/backend/src/PnvPanel.Api/Program.cs b/backend/src/PnvPanel.Api/Program.cs
index cccb30b..93c694c 100644
--- a/backend/src/PnvPanel.Api/Program.cs
+++ b/backend/src/PnvPanel.Api/Program.cs
@@ -187,6 +187,7 @@ app.MapAppEndpoints();
app.MapPlanEndpoints();
app.MapNewsEndpoints();
app.MapInstructionEndpoints();
+app.MapMediaEndpoints();
app.MapAdminUserEndpoints();
app.MapAdminStatsEndpoints();
app.MapAdminAppEndpoints();
diff --git a/backend/src/PnvPanel.Application/Admin/Maintenance/FactoryResetCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Maintenance/FactoryResetCommandHandler.cs
index da84db1..2b9df73 100644
--- a/backend/src/PnvPanel.Application/Admin/Maintenance/FactoryResetCommandHandler.cs
+++ b/backend/src/PnvPanel.Application/Admin/Maintenance/FactoryResetCommandHandler.cs
@@ -30,6 +30,7 @@ public sealed class FactoryResetCommandHandler(
await RevokeAllConfigsOnPanelsAsync(cancellationToken);
await DeleteTicketAttachmentFilesAsync(cancellationToken);
+ await DeleteMediaImageFilesAsync(cancellationToken);
WipeApplicationData();
await dbContext.SaveChangesAsync(cancellationToken);
@@ -107,6 +108,13 @@ public sealed class FactoryResetCommandHandler(
await fileStorage.DeleteAsync(attachment.StoredFileName, cancellationToken);
}
+ private async Task DeleteMediaImageFilesAsync(CancellationToken cancellationToken)
+ {
+ var images = await dbContext.MediaImages.AsNoTracking().ToListAsync(cancellationToken);
+ foreach (var image in images)
+ await fileStorage.DeleteAsync(image.StoredFileName, cancellationToken);
+ }
+
private void WipeApplicationData()
{
dbContext.TicketAttachments.RemoveRange(dbContext.TicketAttachments);
@@ -123,6 +131,7 @@ public sealed class FactoryResetCommandHandler(
dbContext.ClientApps.RemoveRange(dbContext.ClientApps);
dbContext.InstructionIntros.RemoveRange(dbContext.InstructionIntros);
dbContext.InstructionTabs.RemoveRange(dbContext.InstructionTabs);
+ dbContext.MediaImages.RemoveRange(dbContext.MediaImages);
dbContext.PricingSettings.RemoveRange(dbContext.PricingSettings);
dbContext.AuditLogs.RemoveRange(dbContext.AuditLogs);
}
diff --git a/backend/src/PnvPanel.Application/Admin/Media/UploadMediaImageCommand.cs b/backend/src/PnvPanel.Application/Admin/Media/UploadMediaImageCommand.cs
new file mode 100644
index 0000000..9b67063
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Admin/Media/UploadMediaImageCommand.cs
@@ -0,0 +1,8 @@
+using LiteCqrs;
+using PnvPanel.Application.Common.Models;
+using PnvPanel.Application.Media;
+
+namespace PnvPanel.Application.Admin.Media;
+
+public sealed record UploadMediaImageCommand(MediaImageUpload Image)
+ : ICommand>;
diff --git a/backend/src/PnvPanel.Application/Admin/Media/UploadMediaImageCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Media/UploadMediaImageCommandHandler.cs
new file mode 100644
index 0000000..26cf7c2
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Admin/Media/UploadMediaImageCommandHandler.cs
@@ -0,0 +1,42 @@
+using LiteCqrs;
+using PnvPanel.Application.Auth;
+using PnvPanel.Application.Common.Interfaces;
+using PnvPanel.Application.Common.Models;
+using PnvPanel.Application.Media;
+using MediaImageEntity = PnvPanel.Domain.Media.MediaImage;
+
+namespace PnvPanel.Application.Admin.Media;
+
+public sealed class UploadMediaImageCommandHandler(
+ IAppDbContext dbContext,
+ IFileStorage fileStorage,
+ ICurrentUser currentUser
+) : ICommandHandler>
+{
+ public async Task> Handle(
+ UploadMediaImageCommand command,
+ CancellationToken cancellationToken
+ )
+ {
+ if (currentUser.UserId is not { } adminId)
+ return Result.Failure(AuthErrors.Unauthorized);
+
+ if (MediaImageValidation.Validate(command.Image) is { } validationError)
+ return Result.Failure(validationError);
+
+ var storedFileName = await fileStorage.SaveAsync(command.Image.Content, cancellationToken);
+
+ var image = MediaImageEntity.Create(
+ command.Image.FileName,
+ storedFileName,
+ command.Image.ContentType,
+ command.Image.SizeBytes,
+ adminId
+ );
+ dbContext.MediaImages.Add(image);
+
+ return Result.Success(
+ new MediaImageDto(image.Id, image.FileName, image.ContentType, image.SizeBytes)
+ );
+ }
+}
diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs
index d5bf26c..8ca08dd 100644
--- a/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs
+++ b/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs
@@ -7,6 +7,7 @@ using PnvPanel.Domain.Billing;
using PnvPanel.Domain.Configs;
using PnvPanel.Domain.Inbounds;
using PnvPanel.Domain.Instructions;
+using PnvPanel.Domain.Media;
using PnvPanel.Domain.News;
using PnvPanel.Domain.Nodes;
using PnvPanel.Domain.Plans;
@@ -48,6 +49,8 @@ public interface IAppDbContext
DbSet InstructionTabs { get; }
+ DbSet MediaImages { get; }
+
DbSet PricingSettings { get; }
DbSet PricingDiscountTiers { get; }
diff --git a/backend/src/PnvPanel.Application/Media/GetImage/GetMediaImageQuery.cs b/backend/src/PnvPanel.Application/Media/GetImage/GetMediaImageQuery.cs
new file mode 100644
index 0000000..c38537d
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Media/GetImage/GetMediaImageQuery.cs
@@ -0,0 +1,7 @@
+using LiteCqrs;
+using PnvPanel.Application.Common.Models;
+
+namespace PnvPanel.Application.Media.GetImage;
+
+/// Анонимный доступ по непрозрачному Id — картинку грузит обычный <img> из markdown.
+public sealed record GetMediaImageQuery(Guid ImageId) : IQuery>;
diff --git a/backend/src/PnvPanel.Application/Media/GetImage/GetMediaImageQueryHandler.cs b/backend/src/PnvPanel.Application/Media/GetImage/GetMediaImageQueryHandler.cs
new file mode 100644
index 0000000..8d03591
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Media/GetImage/GetMediaImageQueryHandler.cs
@@ -0,0 +1,29 @@
+using Microsoft.EntityFrameworkCore;
+using LiteCqrs;
+using PnvPanel.Application.Common.Interfaces;
+using PnvPanel.Application.Common.Models;
+
+namespace PnvPanel.Application.Media.GetImage;
+
+public sealed class GetMediaImageQueryHandler(IAppDbContext dbContext, IFileStorage fileStorage)
+ : IQueryHandler>
+{
+ public async Task> Handle(
+ GetMediaImageQuery query,
+ CancellationToken cancellationToken
+ )
+ {
+ var image = await dbContext
+ .MediaImages.AsNoTracking()
+ .FirstOrDefaultAsync(i => i.Id == query.ImageId, cancellationToken);
+
+ if (image is null)
+ return Result.Failure(MediaErrors.NotFound);
+
+ var stream = await fileStorage.OpenReadAsync(image.StoredFileName, cancellationToken);
+ if (stream is null)
+ return Result.Failure(MediaErrors.NotFound);
+
+ return Result.Success(new MediaImageContent(stream, image.ContentType, image.FileName));
+ }
+}
diff --git a/backend/src/PnvPanel.Application/Media/MediaErrors.cs b/backend/src/PnvPanel.Application/Media/MediaErrors.cs
new file mode 100644
index 0000000..e764d84
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Media/MediaErrors.cs
@@ -0,0 +1,26 @@
+using PnvPanel.Application.Common.Models;
+
+namespace PnvPanel.Application.Media;
+
+public static class MediaErrors
+{
+ public static readonly Error NotFound = Error.NotFound(
+ "Media.NotFound",
+ "Картинка не найдена."
+ );
+
+ public static readonly Error EmptyImage = Error.Validation(
+ "Media.EmptyImage",
+ "Файл не выбран или пуст."
+ );
+
+ public static readonly Error ImageTooLarge = Error.Validation(
+ "Media.ImageTooLarge",
+ "Картинка превышает лимит 5 МБ."
+ );
+
+ public static readonly Error UnsupportedImageType = Error.Validation(
+ "Media.UnsupportedImageType",
+ "Поддерживаются только изображения (JPEG/PNG/WEBP/GIF)."
+ );
+}
diff --git a/backend/src/PnvPanel.Application/Media/MediaImageContent.cs b/backend/src/PnvPanel.Application/Media/MediaImageContent.cs
new file mode 100644
index 0000000..f041562
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Media/MediaImageContent.cs
@@ -0,0 +1,3 @@
+namespace PnvPanel.Application.Media;
+
+public sealed record MediaImageContent(Stream Content, string ContentType, string FileName);
diff --git a/backend/src/PnvPanel.Application/Media/MediaImageDto.cs b/backend/src/PnvPanel.Application/Media/MediaImageDto.cs
new file mode 100644
index 0000000..0437609
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Media/MediaImageDto.cs
@@ -0,0 +1,5 @@
+namespace PnvPanel.Application.Media;
+
+/// Ссылку на картинку клиент строит сам (`/api/media/images/{id}`) — Application не знает
+/// про маршруты Api.
+public sealed record MediaImageDto(Guid Id, string FileName, string ContentType, long SizeBytes);
diff --git a/backend/src/PnvPanel.Application/Media/MediaImageUpload.cs b/backend/src/PnvPanel.Application/Media/MediaImageUpload.cs
new file mode 100644
index 0000000..2398347
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Media/MediaImageUpload.cs
@@ -0,0 +1,10 @@
+namespace PnvPanel.Application.Media;
+
+/// Картинка на входе команды — Api-слой парсит multipart и передаёт сюда открытый поток;
+/// Application не знает про HTTP/IFormFile.
+public sealed record MediaImageUpload(
+ Stream Content,
+ string FileName,
+ string ContentType,
+ long SizeBytes
+);
diff --git a/backend/src/PnvPanel.Application/Media/MediaImageValidation.cs b/backend/src/PnvPanel.Application/Media/MediaImageValidation.cs
new file mode 100644
index 0000000..8dfcae7
--- /dev/null
+++ b/backend/src/PnvPanel.Application/Media/MediaImageValidation.cs
@@ -0,0 +1,34 @@
+using PnvPanel.Application.Common.Models;
+
+namespace PnvPanel.Application.Media;
+
+internal static class MediaImageValidation
+{
+ public const long MaxSizeBytes = 5 * 1024 * 1024;
+
+ // SVG сознательно не поддерживаем: картинка отдаётся анонимно и по прямой ссылке, а SVG — это
+ // документ со скриптами (XSS при открытии в соседней вкладке).
+ private static readonly HashSet AllowedContentTypes = new(
+ StringComparer.OrdinalIgnoreCase
+ )
+ {
+ "image/jpeg",
+ "image/png",
+ "image/webp",
+ "image/gif",
+ };
+
+ public static Error? Validate(MediaImageUpload image)
+ {
+ if (image.SizeBytes <= 0)
+ return MediaErrors.EmptyImage;
+
+ if (image.SizeBytes > MaxSizeBytes)
+ return MediaErrors.ImageTooLarge;
+
+ if (!AllowedContentTypes.Contains(image.ContentType))
+ return MediaErrors.UnsupportedImageType;
+
+ return null;
+ }
+}
diff --git a/backend/src/PnvPanel.Domain/Media/MediaImage.cs b/backend/src/PnvPanel.Domain/Media/MediaImage.cs
new file mode 100644
index 0000000..168148a
--- /dev/null
+++ b/backend/src/PnvPanel.Domain/Media/MediaImage.cs
@@ -0,0 +1,41 @@
+using PnvPanel.Domain.Common;
+
+namespace PnvPanel.Domain.Media;
+
+///
+/// Картинка, загруженная админом для вставки в markdown (инструкции, новости). StoredFileName —
+/// серверное имя на диске (GUID-based), FileName — оригинальное имя только для отображения
+/// (не участвует в построении пути — не доверяем пользовательскому вводу для файловой системы).
+/// Отдаётся анонимно по непрозрачному Id: markdown рендерится обычным <img>, который не шлёт JWT.
+///
+public sealed class MediaImage : Entity
+{
+ public string FileName { get; private set; } = string.Empty;
+ public string StoredFileName { get; private set; } = string.Empty;
+ public string ContentType { get; private set; } = string.Empty;
+ public long SizeBytes { get; private set; }
+ public Guid UploadedBy { get; private set; }
+ public DateTimeOffset CreatedAt { get; private set; }
+
+ private MediaImage() { }
+
+ public static MediaImage Create(
+ string fileName,
+ string storedFileName,
+ string contentType,
+ long sizeBytes,
+ Guid uploadedBy
+ )
+ {
+ return new MediaImage
+ {
+ Id = Guid.NewGuid(),
+ FileName = fileName,
+ StoredFileName = storedFileName,
+ ContentType = contentType,
+ SizeBytes = sizeBytes,
+ UploadedBy = uploadedBy,
+ CreatedAt = DateTimeOffset.UtcNow,
+ };
+ }
+}
diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs b/backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs
index e07fd07..b4754b7 100644
--- a/backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs
+++ b/backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs
@@ -8,6 +8,7 @@ using PnvPanel.Domain.Billing;
using PnvPanel.Domain.Configs;
using PnvPanel.Domain.Inbounds;
using PnvPanel.Domain.Instructions;
+using PnvPanel.Domain.Media;
using PnvPanel.Domain.News;
using PnvPanel.Domain.Nodes;
using PnvPanel.Domain.Plans;
@@ -58,6 +59,8 @@ public class AppDbContext(DbContextOptions options)
public DbSet InstructionTabs => Set();
+ public DbSet MediaImages => Set();
+
public DbSet PricingSettings => Set();
public DbSet PricingDiscountTiers => Set();
diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/MediaImageConfiguration.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/MediaImageConfiguration.cs
new file mode 100644
index 0000000..62fd15b
--- /dev/null
+++ b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/MediaImageConfiguration.cs
@@ -0,0 +1,20 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using PnvPanel.Domain.Media;
+
+namespace PnvPanel.Infrastructure.Persistence.Configurations;
+
+public class MediaImageConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("MediaImages");
+ builder.HasKey(x => x.Id);
+
+ builder.Property(x => x.FileName).IsRequired().HasMaxLength(255);
+ builder.Property(x => x.StoredFileName).IsRequired().HasMaxLength(100);
+ builder.Property(x => x.ContentType).IsRequired().HasMaxLength(100);
+
+ builder.HasIndex(x => x.StoredFileName).IsUnique();
+ }
+}
diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260730004636_AddMediaImages.Designer.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260730004636_AddMediaImages.Designer.cs
new file mode 100644
index 0000000..0b9a228
--- /dev/null
+++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260730004636_AddMediaImages.Designer.cs
@@ -0,0 +1,1129 @@
+//
+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("20260730004636_AddMediaImages")]
+ partial class AddMediaImages
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.10")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("text");
+
+ b.Property("ClaimValue")
+ .HasColumnType("text");
+
+ b.Property("RoleId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetRoleClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("text");
+
+ b.Property("ClaimValue")
+ .HasColumnType("text");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.Property("LoginProvider")
+ .HasColumnType("text");
+
+ b.Property("ProviderKey")
+ .HasColumnType("text");
+
+ b.Property("ProviderDisplayName")
+ .HasColumnType("text");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("LoginProvider", "ProviderKey");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserLogins", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.Property("RoleId")
+ .HasColumnType("uuid");
+
+ b.HasKey("UserId", "RoleId");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetUserRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.Property("LoginProvider")
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .HasColumnType("text");
+
+ b.Property("Value")
+ .HasColumnType("text");
+
+ b.HasKey("UserId", "LoginProvider", "Name");
+
+ b.ToTable("AspNetUserTokens", (string)null);
+ });
+
+ modelBuilder.Entity("PnvPanel.Domain.Activation.ActivationRequest", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Comment")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DecidedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DecidedBy")
+ .HasColumnType("uuid");
+
+ b.Property("RejectionReason")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "Status");
+
+ b.ToTable("ActivationRequests", (string)null);
+ });
+
+ modelBuilder.Entity("PnvPanel.Domain.Apps.ClientApp", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Description")
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)");
+
+ b.Property("DownloadUrl")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("IconUrl")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("IsEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("IsRecommended")
+ .HasColumnType("boolean");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("OperatingSystem")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.ToTable("ClientApps", (string)null);
+ });
+
+ modelBuilder.Entity("PnvPanel.Domain.Audit.AuditLog", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Action")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("ActorId")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Metadata")
+ .HasColumnType("jsonb");
+
+ b.Property("Source")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("TargetId")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("TargetType")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedAt");
+
+ b.ToTable("AuditLogs", (string)null);
+ });
+
+ modelBuilder.Entity("PnvPanel.Domain.Billing.BillingSettings", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("DefaultBillingEnabledForNewRoles")
+ .HasColumnType("boolean");
+
+ b.Property("GraceDays")
+ .HasColumnType("integer");
+
+ b.Property("RequisitesText")
+ .IsRequired()
+ .HasMaxLength(4000)
+ .HasColumnType("character varying(4000)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.ToTable("BillingSettings", (string)null);
+ });
+
+ modelBuilder.Entity("PnvPanel.Domain.Billing.PaymentRequest", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AmountSnapshot")
+ .HasColumnType("integer");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DecidedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DecidedBy")
+ .HasColumnType("uuid");
+
+ b.Property("Kind")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("Period")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("RejectionReason")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "Status");
+
+ b.ToTable("PaymentRequests", (string)null);
+ });
+
+ modelBuilder.Entity("PnvPanel.Domain.Configs.TrafficSample", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ConfigId")
+ .HasColumnType("uuid");
+
+ b.Property("DownBytes")
+ .HasColumnType("bigint");
+
+ b.Property("Timestamp")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UpBytes")
+ .HasColumnType("bigint");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ConfigId", "Timestamp");
+
+ b.ToTable("TrafficSamples", (string)null);
+ });
+
+ modelBuilder.Entity("PnvPanel.Domain.Configs.VpnConfig", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ClientEmail")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("ClientExternalId")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("InboundId")
+ .HasColumnType("uuid");
+
+ b.Property("Label")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("LastSyncAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Protocol")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("SubscriptionToken")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("UsedDownBytes")
+ .HasColumnType("bigint");
+
+ b.Property("UsedUpBytes")
+ .HasColumnType("bigint");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("InboundId");
+
+ b.HasIndex("SubscriptionToken")
+ .IsUnique();
+
+ b.HasIndex("UserId", "Status");
+
+ b.ToTable("VpnConfigs", (string)null);
+ });
+
+ modelBuilder.Entity("PnvPanel.Domain.Inbounds.Inbound", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.PrimitiveCollection("AllowedRoleIds")
+ .IsRequired()
+ .HasColumnType("uuid[]");
+
+ b.Property("DisplayName")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("IsAvailable")
+ .HasColumnType("boolean");
+
+ b.Property("IsPublished")
+ .HasColumnType("boolean");
+
+ b.Property("LastSyncAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("NodeId")
+ .HasColumnType("uuid");
+
+ b.Property("Port")
+ .HasColumnType("integer");
+
+ b.Property("Protocol")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("Remark")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("RemoteInboundId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NodeId", "RemoteInboundId")
+ .IsUnique();
+
+ b.ToTable("Inbounds", (string)null);
+ });
+
+ modelBuilder.Entity("PnvPanel.Domain.Instructions.InstructionIntro", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Body")
+ .IsRequired()
+ .HasMaxLength(20000)
+ .HasColumnType("character varying(20000)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.ToTable("InstructionIntros", (string)null);
+ });
+
+ modelBuilder.Entity("PnvPanel.Domain.Instructions.InstructionTab", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Body")
+ .IsRequired()
+ .HasMaxLength(20000)
+ .HasColumnType("character varying(20000)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("SortOrder");
+
+ b.ToTable("InstructionTabs", (string)null);
+ });
+
+ modelBuilder.Entity("PnvPanel.Domain.Media.MediaImage", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ContentType")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FileName")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.Property("SizeBytes")
+ .HasColumnType("bigint");
+
+ b.Property("StoredFileName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("UploadedBy")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("StoredFileName")
+ .IsUnique();
+
+ b.ToTable("MediaImages", (string)null);
+ });
+
+ modelBuilder.Entity("PnvPanel.Domain.News.NewsPost", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Body")
+ .IsRequired()
+ .HasMaxLength(20000)
+ .HasColumnType("character varying(20000)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedAt");
+
+ b.ToTable("NewsPosts", (string)null);
+ });
+
+ modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("BaseAddress")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("IsEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("LastSyncAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Location")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("NotifyOnStatusChange")
+ .HasColumnType("boolean");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.HasKey("Id");
+
+ b.ToTable("Nodes", (string)null);
+ });
+
+ modelBuilder.Entity("PnvPanel.Domain.Plans.Plan", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ConfigCount")
+ .HasColumnType("integer");
+
+ b.Property("IsEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.ToTable("Plans", (string)null);
+ });
+
+ modelBuilder.Entity("PnvPanel.Domain.Pricing.PricingDiscountTier", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("DiscountPercent")
+ .HasColumnType("integer");
+
+ b.Property("MinConfigs")
+ .HasColumnType("integer");
+
+ b.Property("PricingSettingsId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("PricingSettingsId", "MinConfigs")
+ .IsUnique();
+
+ b.ToTable("PricingDiscountTiers", (string)null);
+ });
+
+ modelBuilder.Entity("PnvPanel.Domain.Pricing.PricingSettings", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("PricePerConfigPerHalfYear")
+ .HasColumnType("integer");
+
+ b.Property("PricePerConfigPerQuarter")
+ .HasColumnType("integer");
+
+ b.Property("PricePerConfigPerYear")
+ .HasColumnType("integer");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.ToTable("PricingSettings", (string)null);
+ });
+
+ modelBuilder.Entity("PnvPanel.Domain.Support.SupportTicket", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("RequestedDays")
+ .HasColumnType("integer");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Type", "Status");
+
+ b.HasIndex("UserId", "Status");
+
+ b.ToTable("SupportTickets", (string)null);
+ });
+
+ modelBuilder.Entity("PnvPanel.Domain.Support.TicketAttachment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CommentId")
+ .HasColumnType("uuid");
+
+ b.Property("ContentType")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FileName")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.Property("SizeBytes")
+ .HasColumnType("bigint");
+
+ b.Property("StoredFileName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CommentId");
+
+ b.HasIndex("StoredFileName")
+ .IsUnique();
+
+ b.ToTable("TicketAttachments", (string)null);
+ });
+
+ modelBuilder.Entity("PnvPanel.Domain.Support.TicketComment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AuthorId")
+ .HasColumnType("uuid");
+
+ b.Property("Body")
+ .IsRequired()
+ .HasMaxLength(4000)
+ .HasColumnType("character varying(4000)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("TicketId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TicketId", "CreatedAt");
+
+ b.ToTable("TicketComments", (string)null);
+ });
+
+ modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLinkToken", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ConsumedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Token")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Token")
+ .IsUnique();
+
+ b.ToTable("TelegramLinkTokens", (string)null);
+ });
+
+ modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLoginRequest", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Context")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.ToTable("TelegramLoginRequests", (string)null);
+ });
+
+ modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppRole", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("BillingEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("text");
+
+ b.Property("IsSystem")
+ .HasColumnType("boolean");
+
+ b.Property("MaxIpLimit")
+ .HasColumnType("integer");
+
+ b.Property("Name")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("NormalizedName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique()
+ .HasDatabaseName("RoleNameIndex");
+
+ b.ToTable("AspNetRoles", (string)null);
+ });
+
+ modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppUser", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AccessFailedCount")
+ .HasColumnType("integer");
+
+ b.Property("ActivatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ActivatedBy")
+ .HasColumnType("uuid");
+
+ b.Property("BillingLastWarnedForPaidUntil")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("BillingPaidUntil")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("BillingSuspended")
+ .HasColumnType("boolean");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("text");
+
+ b.Property("ConfigQuota")
+ .HasColumnType("integer");
+
+ b.Property("Email")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("EmailConfirmed")
+ .HasColumnType("boolean");
+
+ b.Property("IsActivated")
+ .HasColumnType("boolean");
+
+ b.Property("IsBlocked")
+ .HasColumnType("boolean");
+
+ b.Property("LockoutEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("LockoutEnd")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("NormalizedEmail")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("NormalizedUserName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("PasswordHash")
+ .HasColumnType("text");
+
+ b.Property("PhoneNumber")
+ .HasColumnType("text");
+
+ b.Property("PhoneNumberConfirmed")
+ .HasColumnType("boolean");
+
+ b.Property("PlanId")
+ .HasColumnType("uuid");
+
+ b.Property("SecurityStamp")
+ .HasColumnType("text");
+
+ b.Property("SubscriptionToken")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("TelegramLinkedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("TelegramUserId")
+ .HasColumnType("bigint");
+
+ b.Property("TelegramUsername")
+ .HasColumnType("text");
+
+ b.Property("TwoFactorEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("UserName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedEmail")
+ .HasDatabaseName("EmailIndex");
+
+ b.HasIndex("NormalizedUserName")
+ .IsUnique()
+ .HasDatabaseName("UserNameIndex");
+
+ b.HasIndex("SubscriptionToken")
+ .IsUnique();
+
+ b.HasIndex("TelegramUserId")
+ .IsUnique();
+
+ b.ToTable("AspNetUsers", (string)null);
+ });
+
+ modelBuilder.Entity("PnvPanel.Infrastructure.Identity.RefreshToken", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ReplacedByTokenHash")
+ .HasColumnType("text");
+
+ b.Property("RevokedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("TokenHash")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TokenHash")
+ .IsUnique();
+
+ b.HasIndex("UserId");
+
+ b.ToTable("RefreshTokens", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b =>
+ {
+ b.OwnsOne("PnvPanel.Domain.Nodes.NodeCredentials", "Credentials", b1 =>
+ {
+ b1.Property("NodeId")
+ .HasColumnType("uuid");
+
+ b1.Property("ProtectedPassword")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("CredentialsProtectedPassword");
+
+ b1.Property("Username")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("CredentialsUsername");
+
+ b1.HasKey("NodeId");
+
+ b1.ToTable("Nodes");
+
+ b1.WithOwner()
+ .HasForeignKey("NodeId");
+ });
+
+ b.Navigation("Credentials")
+ .IsRequired();
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260730004636_AddMediaImages.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260730004636_AddMediaImages.cs
new file mode 100644
index 0000000..d9d5335
--- /dev/null
+++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260730004636_AddMediaImages.cs
@@ -0,0 +1,45 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace PnvPanel.Infrastructure.Persistence.Migrations
+{
+ ///
+ public partial class AddMediaImages : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "MediaImages",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ FileName = table.Column(type: "character varying(255)", maxLength: 255, nullable: false),
+ StoredFileName = table.Column(type: "character varying(100)", maxLength: 100, nullable: false),
+ ContentType = table.Column(type: "character varying(100)", maxLength: 100, nullable: false),
+ SizeBytes = table.Column(type: "bigint", nullable: false),
+ UploadedBy = table.Column