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.
This commit is contained in:
@@ -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
|
||||
{
|
||||
/// <summary>Год: содержимое картинки по Id неизменно (перезалив даёт новый Id).</summary>
|
||||
private const string ImageCacheControl = "public, max-age=31536000, immutable";
|
||||
|
||||
public static IEndpointRouteBuilder MapMediaEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
// Отдача — анонимная: markdown рендерится обычным <img>, который не шлёт 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<MediaImageDto>();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> 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<IResult> 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();
|
||||
}
|
||||
}
|
||||
@@ -187,6 +187,7 @@ app.MapAppEndpoints();
|
||||
app.MapPlanEndpoints();
|
||||
app.MapNewsEndpoints();
|
||||
app.MapInstructionEndpoints();
|
||||
app.MapMediaEndpoints();
|
||||
app.MapAdminUserEndpoints();
|
||||
app.MapAdminStatsEndpoints();
|
||||
app.MapAdminAppEndpoints();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<Result<MediaImageDto>>;
|
||||
@@ -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<UploadMediaImageCommand, Result<MediaImageDto>>
|
||||
{
|
||||
public async Task<Result<MediaImageDto>> Handle(
|
||||
UploadMediaImageCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (currentUser.UserId is not { } adminId)
|
||||
return Result.Failure<MediaImageDto>(AuthErrors.Unauthorized);
|
||||
|
||||
if (MediaImageValidation.Validate(command.Image) is { } validationError)
|
||||
return Result.Failure<MediaImageDto>(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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<InstructionTab> InstructionTabs { get; }
|
||||
|
||||
DbSet<MediaImage> MediaImages { get; }
|
||||
|
||||
DbSet<PricingSettings> PricingSettings { get; }
|
||||
|
||||
DbSet<PricingDiscountTier> PricingDiscountTiers { get; }
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
using LiteCqrs;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Media.GetImage;
|
||||
|
||||
/// <summary>Анонимный доступ по непрозрачному Id — картинку грузит обычный <img> из markdown.</summary>
|
||||
public sealed record GetMediaImageQuery(Guid ImageId) : IQuery<Result<MediaImageContent>>;
|
||||
@@ -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<GetMediaImageQuery, Result<MediaImageContent>>
|
||||
{
|
||||
public async Task<Result<MediaImageContent>> 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<MediaImageContent>(MediaErrors.NotFound);
|
||||
|
||||
var stream = await fileStorage.OpenReadAsync(image.StoredFileName, cancellationToken);
|
||||
if (stream is null)
|
||||
return Result.Failure<MediaImageContent>(MediaErrors.NotFound);
|
||||
|
||||
return Result.Success(new MediaImageContent(stream, image.ContentType, image.FileName));
|
||||
}
|
||||
}
|
||||
@@ -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)."
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace PnvPanel.Application.Media;
|
||||
|
||||
public sealed record MediaImageContent(Stream Content, string ContentType, string FileName);
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace PnvPanel.Application.Media;
|
||||
|
||||
/// <summary>Ссылку на картинку клиент строит сам (`/api/media/images/{id}`) — Application не знает
|
||||
/// про маршруты Api.</summary>
|
||||
public sealed record MediaImageDto(Guid Id, string FileName, string ContentType, long SizeBytes);
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace PnvPanel.Application.Media;
|
||||
|
||||
/// <summary>Картинка на входе команды — Api-слой парсит multipart и передаёт сюда открытый поток;
|
||||
/// Application не знает про HTTP/IFormFile.</summary>
|
||||
public sealed record MediaImageUpload(
|
||||
Stream Content,
|
||||
string FileName,
|
||||
string ContentType,
|
||||
long SizeBytes
|
||||
);
|
||||
@@ -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<string> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using PnvPanel.Domain.Common;
|
||||
|
||||
namespace PnvPanel.Domain.Media;
|
||||
|
||||
/// <summary>
|
||||
/// Картинка, загруженная админом для вставки в markdown (инструкции, новости). StoredFileName —
|
||||
/// серверное имя на диске (GUID-based), FileName — оригинальное имя только для отображения
|
||||
/// (не участвует в построении пути — не доверяем пользовательскому вводу для файловой системы).
|
||||
/// Отдаётся анонимно по непрозрачному Id: markdown рендерится обычным <img>, который не шлёт JWT.
|
||||
/// </summary>
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<AppDbContext> options)
|
||||
|
||||
public DbSet<InstructionTab> InstructionTabs => Set<InstructionTab>();
|
||||
|
||||
public DbSet<MediaImage> MediaImages => Set<MediaImage>();
|
||||
|
||||
public DbSet<PricingSettings> PricingSettings => Set<PricingSettings>();
|
||||
|
||||
public DbSet<PricingDiscountTier> PricingDiscountTiers => Set<PricingDiscountTier>();
|
||||
|
||||
+20
@@ -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<MediaImage>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<MediaImage> 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();
|
||||
}
|
||||
}
|
||||
backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260730004636_AddMediaImages.Designer.cs
Generated
+1129
File diff suppressed because it is too large
Load Diff
+45
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PnvPanel.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddMediaImages : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "MediaImages",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
FileName = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
|
||||
StoredFileName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
ContentType = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
SizeBytes = table.Column<long>(type: "bigint", nullable: false),
|
||||
UploadedBy = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "MediaImages");
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
@@ -519,6 +519,44 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("InstructionTabs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Media.MediaImage", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ContentType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<long>("SizeBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("StoredFileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid>("UploadedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("StoredFileName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("MediaImages", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.News.NewsPost", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
|
||||
+2
@@ -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<TicketAttachment> TicketAttachments => inner.TicketAttachments;
|
||||
public DbSet<InstructionIntro> InstructionIntros => inner.InstructionIntros;
|
||||
public DbSet<InstructionTab> InstructionTabs => inner.InstructionTabs;
|
||||
public DbSet<MediaImage> MediaImages => inner.MediaImages;
|
||||
public DbSet<PricingSettings> PricingSettings => inner.PricingSettings;
|
||||
public DbSet<PricingDiscountTier> PricingDiscountTiers => inner.PricingDiscountTiers;
|
||||
public DbSet<BillingSettings> BillingSettings => inner.BillingSettings;
|
||||
|
||||
@@ -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<IFileStorage>();
|
||||
|
||||
[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<CancellationToken>())
|
||||
.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<CancellationToken>())
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -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<IFileStorage>();
|
||||
|
||||
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<Stream>(), Arg.Any<CancellationToken>())
|
||||
.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<Stream>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>Лимит продублирован из MediaImageValidation — она internal (см. Application).</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
|
||||
/// <summary>Минимальный валидный PNG (1×1) — не важно содержимое, важен путь загрузка → отдача.</summary>
|
||||
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<MediaImageResponse>();
|
||||
Assert.NotNull(image);
|
||||
Assert.Equal("shot.png", image!.FileName);
|
||||
|
||||
// Анонимный клиент: markdown-картинку грузит обычный <img> без 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("<svg/>"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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user