From c2ed3240bd63bba56194fa1a394a086ddc76a18b Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 30 Jul 2026 04:05:01 +0300 Subject: [PATCH] Add media image handling and related endpoints - Introduced `MediaImage` entity to manage images for markdown in instructions and news. - Updated `IAppDbContext` and `AppDbContext` to include `MediaImages` DbSet. - Implemented `DeleteMediaImageFilesAsync` method in `FactoryResetCommandHandler` to remove media images during factory reset. - Added new API endpoints for uploading and retrieving media images, enhancing markdown support. - Updated frontend components to utilize the new `MarkdownEditor` for image uploads in instructions and news. - Enhanced documentation to reflect the new media handling features and API specifications. --- .../PnvPanel.Api/Endpoints/MediaEndpoints.cs | 67 + backend/src/PnvPanel.Api/Program.cs | 1 + .../Maintenance/FactoryResetCommandHandler.cs | 9 + .../Admin/Media/UploadMediaImageCommand.cs | 8 + .../Media/UploadMediaImageCommandHandler.cs | 42 + .../Common/Interfaces/IAppDbContext.cs | 3 + .../Media/GetImage/GetMediaImageQuery.cs | 7 + .../GetImage/GetMediaImageQueryHandler.cs | 29 + .../PnvPanel.Application/Media/MediaErrors.cs | 26 + .../Media/MediaImageContent.cs | 3 + .../Media/MediaImageDto.cs | 5 + .../Media/MediaImageUpload.cs | 10 + .../Media/MediaImageValidation.cs | 34 + .../src/PnvPanel.Domain/Media/MediaImage.cs | 41 + .../Persistence/AppDbContext.cs | 3 + .../Configurations/MediaImageConfiguration.cs | 20 + .../20260730004636_AddMediaImages.Designer.cs | 1129 +++++++++++++++++ .../20260730004636_AddMediaImages.cs | 45 + .../Migrations/AppDbContextModelSnapshot.cs | 38 + .../RotateVpnConfigCommandHandlerTests.cs | 2 + .../Media/GetMediaImageQueryHandlerTests.cs | 76 ++ .../UploadMediaImageCommandHandlerTests.cs | 95 ++ .../Media/MediaImageFlowTests.cs | 98 ++ docs/api-design.md | 19 + docs/architecture.md | 8 +- docs/domain-model.md | 20 + docs/frontend.md | 8 +- .../instructions/InstructionIntroEditor.tsx | 20 +- .../instructions/InstructionTabFormDialog.tsx | 29 +- .../features/admin/media/MarkdownEditor.tsx | 151 +++ frontend/src/features/admin/media/api.ts | 14 + .../features/admin/news/NewsFormDialog.tsx | 23 +- frontend/src/features/news/NewsFeed.tsx | 4 +- frontend/src/routes/instructions.tsx | 2 +- frontend/src/shared/api/types.ts | 9 + frontend/src/shared/lib/i18n.ts | 12 + frontend/src/shared/lib/markdown.ts | 5 + 37 files changed, 2052 insertions(+), 63 deletions(-) create mode 100644 backend/src/PnvPanel.Api/Endpoints/MediaEndpoints.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Media/UploadMediaImageCommand.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Media/UploadMediaImageCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Media/GetImage/GetMediaImageQuery.cs create mode 100644 backend/src/PnvPanel.Application/Media/GetImage/GetMediaImageQueryHandler.cs create mode 100644 backend/src/PnvPanel.Application/Media/MediaErrors.cs create mode 100644 backend/src/PnvPanel.Application/Media/MediaImageContent.cs create mode 100644 backend/src/PnvPanel.Application/Media/MediaImageDto.cs create mode 100644 backend/src/PnvPanel.Application/Media/MediaImageUpload.cs create mode 100644 backend/src/PnvPanel.Application/Media/MediaImageValidation.cs create mode 100644 backend/src/PnvPanel.Domain/Media/MediaImage.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Configurations/MediaImageConfiguration.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260730004636_AddMediaImages.Designer.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260730004636_AddMediaImages.cs create mode 100644 backend/tests/PnvPanel.Application.Tests/Media/GetMediaImageQueryHandlerTests.cs create mode 100644 backend/tests/PnvPanel.Application.Tests/Media/UploadMediaImageCommandHandlerTests.cs create mode 100644 backend/tests/PnvPanel.IntegrationTests/Media/MediaImageFlowTests.cs create mode 100644 frontend/src/features/admin/media/MarkdownEditor.tsx create mode 100644 frontend/src/features/admin/media/api.ts create mode 100644 frontend/src/shared/lib/markdown.ts 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(type: "uuid", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_MediaImages", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_MediaImages_StoredFileName", + table: "MediaImages", + column: "StoredFileName", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "MediaImages"); + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index 94321f9..b0d9e59 100644 --- a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -519,6 +519,44 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations 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") diff --git a/backend/tests/PnvPanel.Application.Tests/Configs/Rotate/RotateVpnConfigCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Configs/Rotate/RotateVpnConfigCommandHandlerTests.cs index 7a6b879..388fd16 100644 --- a/backend/tests/PnvPanel.Application.Tests/Configs/Rotate/RotateVpnConfigCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Configs/Rotate/RotateVpnConfigCommandHandlerTests.cs @@ -17,6 +17,7 @@ using PnvPanel.Domain.Instructions; using PnvPanel.Domain.News; using PnvPanel.Domain.Nodes; using PnvPanel.Domain.Plans; +using PnvPanel.Domain.Media; using PnvPanel.Domain.Pricing; using PnvPanel.Domain.Support; using PnvPanel.Domain.Telegram; @@ -45,6 +46,7 @@ public sealed class ThrowingSaveDbContext(AppDbContext inner) : IAppDbContext public DbSet TicketAttachments => inner.TicketAttachments; public DbSet InstructionIntros => inner.InstructionIntros; public DbSet InstructionTabs => inner.InstructionTabs; + public DbSet MediaImages => inner.MediaImages; public DbSet PricingSettings => inner.PricingSettings; public DbSet PricingDiscountTiers => inner.PricingDiscountTiers; public DbSet BillingSettings => inner.BillingSettings; diff --git a/backend/tests/PnvPanel.Application.Tests/Media/GetMediaImageQueryHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Media/GetMediaImageQueryHandlerTests.cs new file mode 100644 index 0000000..815264f --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Media/GetMediaImageQueryHandlerTests.cs @@ -0,0 +1,76 @@ +using NSubstitute; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Media; +using PnvPanel.Application.Media.GetImage; +using PnvPanel.Application.Tests.TestSupport; +using PnvPanel.Domain.Media; +using Xunit; + +namespace PnvPanel.Application.Tests.Media; + +public class GetMediaImageQueryHandlerTests +{ + private readonly IFileStorage _fileStorage = Substitute.For(); + + [Fact] + public async Task Handle_WhenImageExists_ReturnsContentStream() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var image = MediaImage.Create("shot.png", "stored-name", "image/png", 10, Guid.NewGuid()); + dbContext.MediaImages.Add(image); + await dbContext.SaveChangesAsync(CancellationToken.None); + + _fileStorage + .OpenReadAsync("stored-name", Arg.Any()) + .Returns(new MemoryStream([1, 2, 3])); + + var handler = new GetMediaImageQueryHandler(dbContext, _fileStorage); + + var result = await handler.Handle( + new GetMediaImageQuery(image.Id), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + Assert.Equal("image/png", result.Value.ContentType); + Assert.Equal("shot.png", result.Value.FileName); + } + + [Fact] + public async Task Handle_WhenRowExistsButFileMissing_ReturnsNotFound() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var image = MediaImage.Create("shot.png", "stored-name", "image/png", 10, Guid.NewGuid()); + dbContext.MediaImages.Add(image); + await dbContext.SaveChangesAsync(CancellationToken.None); + + _fileStorage + .OpenReadAsync("stored-name", Arg.Any()) + .Returns((Stream?)null); + + var handler = new GetMediaImageQueryHandler(dbContext, _fileStorage); + + var result = await handler.Handle( + new GetMediaImageQuery(image.Id), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.Equal(MediaErrors.NotFound, result.Error); + } + + [Fact] + public async Task Handle_WhenImageUnknown_ReturnsNotFound() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var handler = new GetMediaImageQueryHandler(dbContext, _fileStorage); + + var result = await handler.Handle( + new GetMediaImageQuery(Guid.NewGuid()), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.Equal(MediaErrors.NotFound, result.Error); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Media/UploadMediaImageCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Media/UploadMediaImageCommandHandlerTests.cs new file mode 100644 index 0000000..441e556 --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Media/UploadMediaImageCommandHandlerTests.cs @@ -0,0 +1,95 @@ +using NSubstitute; +using PnvPanel.Application.Admin.Media; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Media; +using PnvPanel.Application.Tests.TestSupport; +using Xunit; + +namespace PnvPanel.Application.Tests.Media; + +public class UploadMediaImageCommandHandlerTests +{ + private readonly IFileStorage _fileStorage = Substitute.For(); + + private static MediaImageUpload Upload( + string contentType = "image/png", + long sizeBytes = 1024, + string fileName = "screenshot.png" + ) => new(new MemoryStream([1, 2, 3]), fileName, contentType, sizeBytes); + + [Fact] + public async Task Handle_WithValidImage_SavesFileAndRow() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var adminId = Guid.NewGuid(); + _fileStorage + .SaveAsync(Arg.Any(), Arg.Any()) + .Returns("stored-name"); + + var handler = new UploadMediaImageCommandHandler( + dbContext, + _fileStorage, + FakeCurrentUser.Authenticated(adminId, "admin") + ); + + var result = await handler.Handle( + new UploadMediaImageCommand(Upload()), + CancellationToken.None + ); + await dbContext.SaveChangesAsync(CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("screenshot.png", result.Value.FileName); + var image = Assert.Single(dbContext.MediaImages); + Assert.Equal("stored-name", image.StoredFileName); + Assert.Equal(adminId, image.UploadedBy); + } + + [Fact] + public async Task Handle_WithUnsupportedContentType_ReturnsValidationErrorWithoutSaving() + { + using var dbContext = InMemoryDbContextFactory.Create(); + + var handler = new UploadMediaImageCommandHandler( + dbContext, + _fileStorage, + FakeCurrentUser.Authenticated(Guid.NewGuid()) + ); + + var result = await handler.Handle( + new UploadMediaImageCommand(Upload(contentType: "image/svg+xml", fileName: "x.svg")), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.Equal(MediaErrors.UnsupportedImageType, result.Error); + Assert.Empty(dbContext.MediaImages); + await _fileStorage + .DidNotReceive() + .SaveAsync(Arg.Any(), Arg.Any()); + } + + /// Лимит продублирован из MediaImageValidation — она internal (см. Application). + private const long MaxSizeBytes = 5 * 1024 * 1024; + + [Fact] + public async Task Handle_WithTooLargeImage_ReturnsValidationError() + { + using var dbContext = InMemoryDbContextFactory.Create(); + + var handler = new UploadMediaImageCommandHandler( + dbContext, + _fileStorage, + FakeCurrentUser.Authenticated(Guid.NewGuid()) + ); + + var result = await handler.Handle( + new UploadMediaImageCommand(Upload(sizeBytes: MaxSizeBytes + 1)), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.Equal(MediaErrors.ImageTooLarge, result.Error); + Assert.Empty(dbContext.MediaImages); + } +} diff --git a/backend/tests/PnvPanel.IntegrationTests/Media/MediaImageFlowTests.cs b/backend/tests/PnvPanel.IntegrationTests/Media/MediaImageFlowTests.cs new file mode 100644 index 0000000..1172188 --- /dev/null +++ b/backend/tests/PnvPanel.IntegrationTests/Media/MediaImageFlowTests.cs @@ -0,0 +1,98 @@ +using System.Net; +using System.Net.Http.Headers; +using PnvPanel.IntegrationTests.TestSupport; +using Xunit; +using static PnvPanel.IntegrationTests.TestSupport.AuthTestHelper; + +namespace PnvPanel.IntegrationTests.Media; + +[Collection(IntegrationTestCollection.Name)] +public class MediaImageFlowTests(PnvPanelWebApplicationFactory factory) +{ + private sealed record MediaImageResponse( + Guid Id, + string FileName, + string ContentType, + long SizeBytes + ); + + /// Минимальный валидный PNG (1×1) — не важно содержимое, важен путь загрузка → отдача. + private static readonly byte[] PngBytes = Convert.FromBase64String( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" + ); + + private static MultipartFormDataContent ImageContent( + byte[] bytes, + string contentType, + string fileName + ) + { + var file = new ByteArrayContent(bytes); + file.Headers.ContentType = new MediaTypeHeaderValue(contentType); + return new MultipartFormDataContent { { file, "file", fileName } }; + } + + [Fact] + public async Task AdminUploadsImage_ThenAnyoneCanFetchItWithoutAuth() + { + using var adminClient = factory.CreateClient(); + adminClient.UseBearerToken(await LoginAsAdminAsync(adminClient)); + + var uploadResponse = await adminClient.PostAsync( + "/api/admin/media/images", + ImageContent(PngBytes, "image/png", "shot.png") + ); + Assert.Equal(HttpStatusCode.OK, uploadResponse.StatusCode); + var image = await uploadResponse.ReadAsAsync(); + Assert.NotNull(image); + Assert.Equal("shot.png", image!.FileName); + + // Анонимный клиент: markdown-картинку грузит обычный без Authorization. + using var anonymousClient = factory.CreateClient(); + var getResponse = await anonymousClient.GetAsync($"/api/media/images/{image.Id}"); + + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + Assert.Equal("image/png", getResponse.Content.Headers.ContentType?.MediaType); + Assert.Equal(PngBytes, await getResponse.Content.ReadAsByteArrayAsync()); + } + + [Fact] + public async Task Upload_WhenNotAdmin_ReturnsForbidden() + { + using var userClient = factory.CreateClient(); + var userName = $"mia_{Guid.NewGuid():N}"[..20]; + var (_, token) = await RegisterAndLoginAsync(userClient, userName, "P@ssw0rd123"); + userClient.UseBearerToken(token); + + var response = await userClient.PostAsync( + "/api/admin/media/images", + ImageContent(PngBytes, "image/png", "shot.png") + ); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task Upload_WithUnsupportedType_ReturnsBadRequest() + { + using var adminClient = factory.CreateClient(); + adminClient.UseBearerToken(await LoginAsAdminAsync(adminClient)); + + var response = await adminClient.PostAsync( + "/api/admin/media/images", + ImageContent(""u8.ToArray(), "image/svg+xml", "x.svg") + ); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task GetImage_WhenUnknownId_ReturnsNotFound() + { + using var anonymousClient = factory.CreateClient(); + + var response = await anonymousClient.GetAsync($"/api/media/images/{Guid.NewGuid()}"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } +} diff --git a/docs/api-design.md b/docs/api-design.md index 8b41e35..3582473 100644 --- a/docs/api-design.md +++ b/docs/api-design.md @@ -127,6 +127,25 @@ status, createdAt }`. `expiresAt` всегда `null` (лимиты по сро чтобы публичная страница не падала. Вкладки — обычный CRUD без статуса черновик/опубликовано, как у `NewsPostDto`. +## Media — картинки для markdown + +| Метод | Путь | Роль | Тело запроса | Тело ответа | +| ----- | -------------------------- | --------- | ------------------------------- | ---------------- | +| POST | `/api/admin/media/images` | admin | `multipart/form-data`: `file` | `MediaImageDto` | +| GET | `/api/media/images/{id}` | аноним | — | тело картинки (`Content-Type` как при загрузке) | + +Картинки вставляются админом в markdown инструкций и новостей. `MediaImageDto`: +`{ id, fileName, contentType, sizeBytes }` — ссылку клиент строит сам: `/api/media/images/{id}`. + +Отдача **анонимная**: markdown рендерится обычным ``, который не шлёт `Authorization` (в отличие +от вложений тикетов, которые клиент качает как blob). Защита — непрозрачный `Guid` в ссылке; в +картинках инструкций/новостей персональных данных нет. Ответ помечен +`Cache-Control: public, max-age=31536000, immutable` — содержимое по `Id` неизменно (перезалив даёт новый `Id`). + +Ограничения загрузки — как у вложений тикетов: ≤5 МБ, `image/jpeg|png|webp|gif`. SVG не поддерживается +осознанно: картинка открывается по прямой ссылке, а SVG — документ со скриптами. Отдельного экрана +управления медиа нет; файлы лежат в том же `IFileStorage` (volume) и стираются при factory reset. + ## News — лента новостей | Метод | Путь | Роль | Тело запроса | Тело ответа | diff --git a/docs/architecture.md b/docs/architecture.md index 15a9c07..1378c32 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -69,7 +69,8 @@ PnvPanel — backend на **ASP.NET Core (.NET 10)** по принципам **C организованы по фичам (`Auth/Login/`, `Configs/Create/`, `Admin/Nodes/`, ...). - **Ports (интерфейсы)**: `IAppDbContext`, `IXuiPanelGateway`, `ICurrentUser`, `IIdentityService`, `ISecretProtector`, `IRealtimeNotifier`, `ITelegramNotifier`, `IRoleService`, `IFileStorage` - (вложения тикетов поддержки — диск в контейнере, см. `Infrastructure/Storage/DiskFileStorage`). + (вложения тикетов поддержки и картинки для markdown — диск в контейнере, см. + `Infrastructure/Storage/DiskFileStorage`). - **Validators**: FluentValidation на команды, где есть что проверять помимо типов (не на все — см. [backend-conventions.md](backend-conventions.md)). - **DTO**: плоские `record`, конвертация из сущностей — статический метод `FromDomain(...)` на самом @@ -98,8 +99,9 @@ PnvPanel — backend на **ASP.NET Core (.NET 10)** по принципам **C - **Secrets**: `DataProtectionSecretProtector : ISecretProtector` (шифрование паролей нод at-rest, ASP.NET Core Data Protection, key-ring на томе `dp_keys`). - **Telegram**: `TelegramNotifier : ITelegramNotifier` — отправка DM-уведомлений через `ITelegramBotClient`. -- **Storage**: `DiskFileStorage : IFileStorage` — вложения тикетов поддержки, файлы на диске под - GUID-именем (`FileStorage:RootPath`, том `ticket_uploads` в docker-compose, как `dp_keys`). +- **Storage**: `DiskFileStorage : IFileStorage` — вложения тикетов поддержки и картинки для markdown + (`MediaImage`), файлы на диске под GUID-именем (`FileStorage:RootPath`, том `ticket_uploads` в + docker-compose, как `dp_keys`). > **SignalR-пуш физически лежит в `PnvPanel.Api/Hubs/`, не в `Infrastructure`.** > `SignalRRealtimeNotifier : IRealtimeNotifier` нужен `IHubContext`, а сам `PanelHub` diff --git a/docs/domain-model.md b/docs/domain-model.md index 73a8f17..fcb4153 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -30,6 +30,7 @@ VpnConfig ─*─ TrafficSample (история трафика; пишетс AuditLog (append-only журнал действий; ссылается на ActorId/TargetId) ClientApp (каталог приложений-клиентов; группируется по OperatingSystem) NewsPost (лента новостей; публикуется админом, видна всем аутентифицированным пользователям) +MediaImage (картинка для markdown инструкций/новостей; диск-хранилище, отдаётся анонимно по Id) AppUser └─0..*─ SupportTicket (баг-репорт/предложение либо заявка на продление) └─1───*─ TicketComment (переписка; первое сообщение = описание/обоснование) @@ -836,6 +837,25 @@ docker-compose) — первая в проекте функциональнос Отдаётся авторизованным эндпоинтом (`GET /api/support/attachments/{id}`, проверка владения тикетом или роли admin), не статикой — вложения могут быть чувствительными. +### MediaImage — картинка для markdown +Картинка, загруженная админом для вставки в markdown инструкций/новостей +(`POST /api/admin/media/images`). То же диск-хранилище (`IFileStorage`), те же ограничения, что и у +вложений тикетов (`image/jpeg|png|webp|gif`, ≤5 МБ), но, в отличие от них, **отдаётся анонимно** по +непрозрачному `Id`: markdown рендерится обычным ``, который не шлёт `Authorization`. + +| Поле | Тип | Заметки | +| ---------------- | ---------------- | ------------------------------------------------------------------ | +| `Id` | `Guid` | PK; он же — ссылка `/api/media/images/{id}` в markdown | +| `FileName` | `string` | Оригинальное имя — для `alt` и отображения, не участвует в пути на диске | +| `StoredFileName` | `string` | Серверное GUID-имя на диске | +| `ContentType` | `string` | SVG не допускается (документ со скриптами, а ссылка публичная) | +| `SizeBytes` | `long` | | +| `UploadedBy` | `Guid` | Админ-загрузчик (для расследования, отдельного экрана управления нет) | +| `CreatedAt` | `DateTimeOffset` | | + +Связи с инструкцией/новостью нет — картинка живёт только как ссылка внутри markdown-текста, поэтому +удаление вкладки/новости файл не трогает; всё медиа стирается при factory reset. + ## Value Objects - **NodeCredentials** (`Nodes/NodeCredentials.cs`) — `Username` + `ProtectedPassword` (шифротекст, diff --git a/docs/frontend.md b/docs/frontend.md index 26d475c..9ea9f93 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -23,7 +23,7 @@ SPA на **React 19 + Vite + TypeScript**. Общается с бэком по R | Формы | react-hook-form + zod | | Realtime | @microsoft/signalr | | QR-коды | qrcode.react (рендерит QR из готовой строки на клиенте) | -| Markdown | react-markdown + remark-gfm (лента новостей; без rehype-raw — сырой HTML не рендерится) | +| Markdown | react-markdown + remark-gfm (новости, инструкции; без rehype-raw — сырой HTML не рендерится) | | Типы API | openapi-typescript (`pnpm gen:api`) — генерирует `schema.gen.ts` для сверки; фичи импортируют руками написанный `shared/api/types.ts` | | i18n | react-i18next (RU + EN) | | Линт | oxlint (не ESLint) | @@ -100,6 +100,12 @@ frontend/ рендерится через `react-markdown` + `remark-gfm`), пагинация (`GET /api/news`), живое обновление по SignalR (`newsPublished`, широковещательно всем). Админка (`/admin/news`): CRUD, обычный `