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.MapPlanEndpoints();
|
||||||
app.MapNewsEndpoints();
|
app.MapNewsEndpoints();
|
||||||
app.MapInstructionEndpoints();
|
app.MapInstructionEndpoints();
|
||||||
|
app.MapMediaEndpoints();
|
||||||
app.MapAdminUserEndpoints();
|
app.MapAdminUserEndpoints();
|
||||||
app.MapAdminStatsEndpoints();
|
app.MapAdminStatsEndpoints();
|
||||||
app.MapAdminAppEndpoints();
|
app.MapAdminAppEndpoints();
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ public sealed class FactoryResetCommandHandler(
|
|||||||
|
|
||||||
await RevokeAllConfigsOnPanelsAsync(cancellationToken);
|
await RevokeAllConfigsOnPanelsAsync(cancellationToken);
|
||||||
await DeleteTicketAttachmentFilesAsync(cancellationToken);
|
await DeleteTicketAttachmentFilesAsync(cancellationToken);
|
||||||
|
await DeleteMediaImageFilesAsync(cancellationToken);
|
||||||
WipeApplicationData();
|
WipeApplicationData();
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
@@ -107,6 +108,13 @@ public sealed class FactoryResetCommandHandler(
|
|||||||
await fileStorage.DeleteAsync(attachment.StoredFileName, cancellationToken);
|
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()
|
private void WipeApplicationData()
|
||||||
{
|
{
|
||||||
dbContext.TicketAttachments.RemoveRange(dbContext.TicketAttachments);
|
dbContext.TicketAttachments.RemoveRange(dbContext.TicketAttachments);
|
||||||
@@ -123,6 +131,7 @@ public sealed class FactoryResetCommandHandler(
|
|||||||
dbContext.ClientApps.RemoveRange(dbContext.ClientApps);
|
dbContext.ClientApps.RemoveRange(dbContext.ClientApps);
|
||||||
dbContext.InstructionIntros.RemoveRange(dbContext.InstructionIntros);
|
dbContext.InstructionIntros.RemoveRange(dbContext.InstructionIntros);
|
||||||
dbContext.InstructionTabs.RemoveRange(dbContext.InstructionTabs);
|
dbContext.InstructionTabs.RemoveRange(dbContext.InstructionTabs);
|
||||||
|
dbContext.MediaImages.RemoveRange(dbContext.MediaImages);
|
||||||
dbContext.PricingSettings.RemoveRange(dbContext.PricingSettings);
|
dbContext.PricingSettings.RemoveRange(dbContext.PricingSettings);
|
||||||
dbContext.AuditLogs.RemoveRange(dbContext.AuditLogs);
|
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.Configs;
|
||||||
using PnvPanel.Domain.Inbounds;
|
using PnvPanel.Domain.Inbounds;
|
||||||
using PnvPanel.Domain.Instructions;
|
using PnvPanel.Domain.Instructions;
|
||||||
|
using PnvPanel.Domain.Media;
|
||||||
using PnvPanel.Domain.News;
|
using PnvPanel.Domain.News;
|
||||||
using PnvPanel.Domain.Nodes;
|
using PnvPanel.Domain.Nodes;
|
||||||
using PnvPanel.Domain.Plans;
|
using PnvPanel.Domain.Plans;
|
||||||
@@ -48,6 +49,8 @@ public interface IAppDbContext
|
|||||||
|
|
||||||
DbSet<InstructionTab> InstructionTabs { get; }
|
DbSet<InstructionTab> InstructionTabs { get; }
|
||||||
|
|
||||||
|
DbSet<MediaImage> MediaImages { get; }
|
||||||
|
|
||||||
DbSet<PricingSettings> PricingSettings { get; }
|
DbSet<PricingSettings> PricingSettings { get; }
|
||||||
|
|
||||||
DbSet<PricingDiscountTier> PricingDiscountTiers { 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.Configs;
|
||||||
using PnvPanel.Domain.Inbounds;
|
using PnvPanel.Domain.Inbounds;
|
||||||
using PnvPanel.Domain.Instructions;
|
using PnvPanel.Domain.Instructions;
|
||||||
|
using PnvPanel.Domain.Media;
|
||||||
using PnvPanel.Domain.News;
|
using PnvPanel.Domain.News;
|
||||||
using PnvPanel.Domain.Nodes;
|
using PnvPanel.Domain.Nodes;
|
||||||
using PnvPanel.Domain.Plans;
|
using PnvPanel.Domain.Plans;
|
||||||
@@ -58,6 +59,8 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
|
|
||||||
public DbSet<InstructionTab> InstructionTabs => Set<InstructionTab>();
|
public DbSet<InstructionTab> InstructionTabs => Set<InstructionTab>();
|
||||||
|
|
||||||
|
public DbSet<MediaImage> MediaImages => Set<MediaImage>();
|
||||||
|
|
||||||
public DbSet<PricingSettings> PricingSettings => Set<PricingSettings>();
|
public DbSet<PricingSettings> PricingSettings => Set<PricingSettings>();
|
||||||
|
|
||||||
public DbSet<PricingDiscountTier> PricingDiscountTiers => Set<PricingDiscountTier>();
|
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);
|
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 =>
|
modelBuilder.Entity("PnvPanel.Domain.News.NewsPost", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
|
|||||||
+2
@@ -17,6 +17,7 @@ using PnvPanel.Domain.Instructions;
|
|||||||
using PnvPanel.Domain.News;
|
using PnvPanel.Domain.News;
|
||||||
using PnvPanel.Domain.Nodes;
|
using PnvPanel.Domain.Nodes;
|
||||||
using PnvPanel.Domain.Plans;
|
using PnvPanel.Domain.Plans;
|
||||||
|
using PnvPanel.Domain.Media;
|
||||||
using PnvPanel.Domain.Pricing;
|
using PnvPanel.Domain.Pricing;
|
||||||
using PnvPanel.Domain.Support;
|
using PnvPanel.Domain.Support;
|
||||||
using PnvPanel.Domain.Telegram;
|
using PnvPanel.Domain.Telegram;
|
||||||
@@ -45,6 +46,7 @@ public sealed class ThrowingSaveDbContext(AppDbContext inner) : IAppDbContext
|
|||||||
public DbSet<TicketAttachment> TicketAttachments => inner.TicketAttachments;
|
public DbSet<TicketAttachment> TicketAttachments => inner.TicketAttachments;
|
||||||
public DbSet<InstructionIntro> InstructionIntros => inner.InstructionIntros;
|
public DbSet<InstructionIntro> InstructionIntros => inner.InstructionIntros;
|
||||||
public DbSet<InstructionTab> InstructionTabs => inner.InstructionTabs;
|
public DbSet<InstructionTab> InstructionTabs => inner.InstructionTabs;
|
||||||
|
public DbSet<MediaImage> MediaImages => inner.MediaImages;
|
||||||
public DbSet<PricingSettings> PricingSettings => inner.PricingSettings;
|
public DbSet<PricingSettings> PricingSettings => inner.PricingSettings;
|
||||||
public DbSet<PricingDiscountTier> PricingDiscountTiers => inner.PricingDiscountTiers;
|
public DbSet<PricingDiscountTier> PricingDiscountTiers => inner.PricingDiscountTiers;
|
||||||
public DbSet<BillingSettings> BillingSettings => inner.BillingSettings;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -127,6 +127,25 @@ status, createdAt }`. `expiresAt` всегда `null` (лимиты по сро
|
|||||||
чтобы публичная страница не падала. Вкладки — обычный CRUD без статуса черновик/опубликовано, как
|
чтобы публичная страница не падала. Вкладки — обычный CRUD без статуса черновик/опубликовано, как
|
||||||
у `NewsPostDto`.
|
у `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 рендерится обычным `<img>`, который не шлёт `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 — лента новостей
|
## News — лента новостей
|
||||||
|
|
||||||
| Метод | Путь | Роль | Тело запроса | Тело ответа |
|
| Метод | Путь | Роль | Тело запроса | Тело ответа |
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ PnvPanel — backend на **ASP.NET Core (.NET 10)** по принципам **C
|
|||||||
организованы по фичам (`Auth/Login/`, `Configs/Create/`, `Admin/Nodes/`, ...).
|
организованы по фичам (`Auth/Login/`, `Configs/Create/`, `Admin/Nodes/`, ...).
|
||||||
- **Ports (интерфейсы)**: `IAppDbContext`, `IXuiPanelGateway`, `ICurrentUser`, `IIdentityService`,
|
- **Ports (интерфейсы)**: `IAppDbContext`, `IXuiPanelGateway`, `ICurrentUser`, `IIdentityService`,
|
||||||
`ISecretProtector`, `IRealtimeNotifier`, `ITelegramNotifier`, `IRoleService`, `IFileStorage`
|
`ISecretProtector`, `IRealtimeNotifier`, `ITelegramNotifier`, `IRoleService`, `IFileStorage`
|
||||||
(вложения тикетов поддержки — диск в контейнере, см. `Infrastructure/Storage/DiskFileStorage`).
|
(вложения тикетов поддержки и картинки для markdown — диск в контейнере, см.
|
||||||
|
`Infrastructure/Storage/DiskFileStorage`).
|
||||||
- **Validators**: FluentValidation на команды, где есть что проверять помимо типов (не на все — см.
|
- **Validators**: FluentValidation на команды, где есть что проверять помимо типов (не на все — см.
|
||||||
[backend-conventions.md](backend-conventions.md)).
|
[backend-conventions.md](backend-conventions.md)).
|
||||||
- **DTO**: плоские `record`, конвертация из сущностей — статический метод `FromDomain(...)` на самом
|
- **DTO**: плоские `record`, конвертация из сущностей — статический метод `FromDomain(...)` на самом
|
||||||
@@ -98,8 +99,9 @@ PnvPanel — backend на **ASP.NET Core (.NET 10)** по принципам **C
|
|||||||
- **Secrets**: `DataProtectionSecretProtector : ISecretProtector` (шифрование паролей нод at-rest,
|
- **Secrets**: `DataProtectionSecretProtector : ISecretProtector` (шифрование паролей нод at-rest,
|
||||||
ASP.NET Core Data Protection, key-ring на томе `dp_keys`).
|
ASP.NET Core Data Protection, key-ring на томе `dp_keys`).
|
||||||
- **Telegram**: `TelegramNotifier : ITelegramNotifier` — отправка DM-уведомлений через `ITelegramBotClient`.
|
- **Telegram**: `TelegramNotifier : ITelegramNotifier` — отправка DM-уведомлений через `ITelegramBotClient`.
|
||||||
- **Storage**: `DiskFileStorage : IFileStorage` — вложения тикетов поддержки, файлы на диске под
|
- **Storage**: `DiskFileStorage : IFileStorage` — вложения тикетов поддержки и картинки для markdown
|
||||||
GUID-именем (`FileStorage:RootPath`, том `ticket_uploads` в docker-compose, как `dp_keys`).
|
(`MediaImage`), файлы на диске под GUID-именем (`FileStorage:RootPath`, том `ticket_uploads` в
|
||||||
|
docker-compose, как `dp_keys`).
|
||||||
|
|
||||||
> **SignalR-пуш физически лежит в `PnvPanel.Api/Hubs/`, не в `Infrastructure`.**
|
> **SignalR-пуш физически лежит в `PnvPanel.Api/Hubs/`, не в `Infrastructure`.**
|
||||||
> `SignalRRealtimeNotifier : IRealtimeNotifier` нужен `IHubContext<PanelHub>`, а сам `PanelHub`
|
> `SignalRRealtimeNotifier : IRealtimeNotifier` нужен `IHubContext<PanelHub>`, а сам `PanelHub`
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ VpnConfig ─*─ TrafficSample (история трафика; пишетс
|
|||||||
AuditLog (append-only журнал действий; ссылается на ActorId/TargetId)
|
AuditLog (append-only журнал действий; ссылается на ActorId/TargetId)
|
||||||
ClientApp (каталог приложений-клиентов; группируется по OperatingSystem)
|
ClientApp (каталог приложений-клиентов; группируется по OperatingSystem)
|
||||||
NewsPost (лента новостей; публикуется админом, видна всем аутентифицированным пользователям)
|
NewsPost (лента новостей; публикуется админом, видна всем аутентифицированным пользователям)
|
||||||
|
MediaImage (картинка для markdown инструкций/новостей; диск-хранилище, отдаётся анонимно по Id)
|
||||||
AppUser
|
AppUser
|
||||||
└─0..*─ SupportTicket (баг-репорт/предложение либо заявка на продление)
|
└─0..*─ SupportTicket (баг-репорт/предложение либо заявка на продление)
|
||||||
└─1───*─ TicketComment (переписка; первое сообщение = описание/обоснование)
|
└─1───*─ TicketComment (переписка; первое сообщение = описание/обоснование)
|
||||||
@@ -836,6 +837,25 @@ docker-compose) — первая в проекте функциональнос
|
|||||||
Отдаётся авторизованным эндпоинтом (`GET /api/support/attachments/{id}`, проверка владения тикетом
|
Отдаётся авторизованным эндпоинтом (`GET /api/support/attachments/{id}`, проверка владения тикетом
|
||||||
или роли admin), не статикой — вложения могут быть чувствительными.
|
или роли admin), не статикой — вложения могут быть чувствительными.
|
||||||
|
|
||||||
|
### MediaImage — картинка для markdown
|
||||||
|
Картинка, загруженная админом для вставки в markdown инструкций/новостей
|
||||||
|
(`POST /api/admin/media/images`). То же диск-хранилище (`IFileStorage`), те же ограничения, что и у
|
||||||
|
вложений тикетов (`image/jpeg|png|webp|gif`, ≤5 МБ), но, в отличие от них, **отдаётся анонимно** по
|
||||||
|
непрозрачному `Id`: markdown рендерится обычным `<img>`, который не шлёт `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
|
## Value Objects
|
||||||
|
|
||||||
- **NodeCredentials** (`Nodes/NodeCredentials.cs`) — `Username` + `ProtectedPassword` (шифротекст,
|
- **NodeCredentials** (`Nodes/NodeCredentials.cs`) — `Username` + `ProtectedPassword` (шифротекст,
|
||||||
|
|||||||
+7
-1
@@ -23,7 +23,7 @@ SPA на **React 19 + Vite + TypeScript**. Общается с бэком по R
|
|||||||
| Формы | react-hook-form + zod |
|
| Формы | react-hook-form + zod |
|
||||||
| Realtime | @microsoft/signalr |
|
| Realtime | @microsoft/signalr |
|
||||||
| QR-коды | qrcode.react (рендерит QR из готовой строки на клиенте) |
|
| 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` |
|
| Типы API | openapi-typescript (`pnpm gen:api`) — генерирует `schema.gen.ts` для сверки; фичи импортируют руками написанный `shared/api/types.ts` |
|
||||||
| i18n | react-i18next (RU + EN) |
|
| i18n | react-i18next (RU + EN) |
|
||||||
| Линт | oxlint (не ESLint) |
|
| Линт | oxlint (не ESLint) |
|
||||||
@@ -100,6 +100,12 @@ frontend/
|
|||||||
рендерится через `react-markdown` + `remark-gfm`), пагинация (`GET /api/news`), живое обновление
|
рендерится через `react-markdown` + `remark-gfm`), пагинация (`GET /api/news`), живое обновление
|
||||||
по SignalR (`newsPublished`, широковещательно всем). Админка (`/admin/news`): CRUD, обычный
|
по SignalR (`newsPublished`, широковещательно всем). Админка (`/admin/news`): CRUD, обычный
|
||||||
`<textarea>` с переключателем предпросмотра Markdown вместо WYSIWYG-редактора.
|
`<textarea>` с переключателем предпросмотра Markdown вместо WYSIWYG-редактора.
|
||||||
|
- **Markdown-редактор админки** (`features/admin/media/MarkdownEditor.tsx`) — общий для новостей и
|
||||||
|
инструкций (интро + вкладки): текст, предпросмотр и загрузка картинок (кнопка, вставка из буфера,
|
||||||
|
drag&drop). Файл уходит в `POST /api/admin/media/images`, а в текст на позицию курсора
|
||||||
|
вставляется ``; картинка отдаётся анонимно, поэтому подмена
|
||||||
|
компонента `img` в `react-markdown` не нужна. Оформление отрендеренного markdown —
|
||||||
|
общий `MARKDOWN_CLASSES` (`shared/lib/markdown.ts`), он же ограничивает картинки по ширине.
|
||||||
- **Настройки** (`/settings`): смена пароля, привязка/отвязка Telegram (`TelegramLinkCard`),
|
- **Настройки** (`/settings`): смена пароля, привязка/отвязка Telegram (`TelegramLinkCard`),
|
||||||
удаление аккаунта с подтверждением (`DeleteAccountSection`).
|
удаление аккаунта с подтверждением (`DeleteAccountSection`).
|
||||||
- **Админка** (`/admin/*`): вкладки — обзор (карточки статистики, без графиков), запросы активации,
|
- **Админка** (`/admin/*`): вкладки — обзор (карточки статистики, без графиков), запросы активации,
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import ReactMarkdown from 'react-markdown'
|
|
||||||
import remarkGfm from 'remark-gfm'
|
|
||||||
import { toast } from '@/shared/ui/toast-store'
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import { Textarea } from '@/shared/ui/textarea'
|
import { MarkdownEditor } from '@/features/admin/media/MarkdownEditor'
|
||||||
import { HttpError } from '@/shared/api/client'
|
import { HttpError } from '@/shared/api/client'
|
||||||
import type { InstructionIntroDto } from '@/shared/api/types'
|
import type { InstructionIntroDto } from '@/shared/api/types'
|
||||||
import { updateInstructionIntro } from './api'
|
import { updateInstructionIntro } from './api'
|
||||||
@@ -14,7 +12,6 @@ export function InstructionIntroEditor({ intro }: { intro: InstructionIntroDto }
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [body, setBody] = useState(intro.body)
|
const [body, setBody] = useState(intro.body)
|
||||||
const [previewMode, setPreviewMode] = useState(false)
|
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: () => updateInstructionIntro(body.trim()),
|
mutationFn: () => updateInstructionIntro(body.trim()),
|
||||||
@@ -29,19 +26,8 @@ export function InstructionIntroEditor({ intro }: { intro: InstructionIntroDto }
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex items-center justify-between">
|
<p className="text-sm text-muted-foreground">{t('admin.instructions.introHint')}</p>
|
||||||
<p className="text-sm text-muted-foreground">{t('admin.instructions.introHint')}</p>
|
<MarkdownEditor label={t('admin.instructions.tabBody')} value={body} onChange={setBody} rows={8} />
|
||||||
<Button type="button" size="sm" variant="ghost" onClick={() => setPreviewMode((v) => !v)}>
|
|
||||||
{t('admin.news.preview')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{previewMode ? (
|
|
||||||
<div className="flex min-h-32 flex-col gap-2 rounded-md border border-border px-3 py-2 text-sm [&_a]:text-primary [&_a]:hover:underline [&_li]:ml-4 [&_ul]:list-disc [&_ol]:list-decimal">
|
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{body || t('admin.instructions.tabBody')}</ReactMarkdown>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Textarea value={body} onChange={(e) => setBody(e.target.value)} rows={8} />
|
|
||||||
)}
|
|
||||||
<div>
|
<div>
|
||||||
<Button disabled={!body.trim() || !isDirty || mutation.isPending} onClick={() => mutation.mutate()}>
|
<Button disabled={!body.trim() || !isDirty || mutation.isPending} onClick={() => mutation.mutate()}>
|
||||||
{t('admin.roles.save')}
|
{t('admin.roles.save')}
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import ReactMarkdown from 'react-markdown'
|
|
||||||
import remarkGfm from 'remark-gfm'
|
|
||||||
import { toast } from '@/shared/ui/toast-store'
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/shared/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/shared/ui/dialog'
|
||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import { Input } from '@/shared/ui/input'
|
import { Input } from '@/shared/ui/input'
|
||||||
import { Label } from '@/shared/ui/label'
|
import { Label } from '@/shared/ui/label'
|
||||||
import { Textarea } from '@/shared/ui/textarea'
|
import { MarkdownEditor } from '@/features/admin/media/MarkdownEditor'
|
||||||
import { HttpError } from '@/shared/api/client'
|
import { HttpError } from '@/shared/api/client'
|
||||||
import type { InstructionTabDto } from '@/shared/api/types'
|
import type { InstructionTabDto } from '@/shared/api/types'
|
||||||
import { createInstructionTab, updateInstructionTab } from './api'
|
import { createInstructionTab, updateInstructionTab } from './api'
|
||||||
@@ -28,7 +26,6 @@ export function InstructionTabFormDialog({
|
|||||||
const [title, setTitle] = useState(tab?.title ?? '')
|
const [title, setTitle] = useState(tab?.title ?? '')
|
||||||
const [body, setBody] = useState(tab?.body ?? '')
|
const [body, setBody] = useState(tab?.body ?? '')
|
||||||
const [sortOrder, setSortOrder] = useState(String(tab?.sortOrder ?? 0))
|
const [sortOrder, setSortOrder] = useState(String(tab?.sortOrder ?? 0))
|
||||||
const [previewMode, setPreviewMode] = useState(false)
|
|
||||||
|
|
||||||
const isControlled = open !== undefined
|
const isControlled = open !== undefined
|
||||||
const dialogOpen = isControlled ? open : internalOpen
|
const dialogOpen = isControlled ? open : internalOpen
|
||||||
@@ -56,7 +53,7 @@ export function InstructionTabFormDialog({
|
|||||||
<Button size="sm">{t('admin.instructions.createTab')}</Button>
|
<Button size="sm">{t('admin.instructions.createTab')}</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
)}
|
)}
|
||||||
<DialogContent>
|
<DialogContent className="max-h-[90vh] max-w-3xl overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{tab ? tab.title : t('admin.instructions.createTab')}</DialogTitle>
|
<DialogTitle>{tab ? tab.title : t('admin.instructions.createTab')}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -71,21 +68,13 @@ export function InstructionTabFormDialog({
|
|||||||
<Label htmlFor="tabTitle">{t('admin.instructions.tabTitle')}</Label>
|
<Label htmlFor="tabTitle">{t('admin.instructions.tabTitle')}</Label>
|
||||||
<Input id="tabTitle" value={title} onChange={(e) => setTitle(e.target.value)} required />
|
<Input id="tabTitle" value={title} onChange={(e) => setTitle(e.target.value)} required />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<MarkdownEditor
|
||||||
<div className="flex items-center justify-between">
|
id="tabBody"
|
||||||
<Label htmlFor="tabBody">{t('admin.instructions.tabBody')}</Label>
|
label={t('admin.instructions.tabBody')}
|
||||||
<Button type="button" size="sm" variant="ghost" onClick={() => setPreviewMode((v) => !v)}>
|
value={body}
|
||||||
{t('admin.news.preview')}
|
onChange={setBody}
|
||||||
</Button>
|
required
|
||||||
</div>
|
/>
|
||||||
{previewMode ? (
|
|
||||||
<div className="flex min-h-32 flex-col gap-2 rounded-md border border-border px-3 py-2 text-sm [&_a]:text-primary [&_a]:hover:underline [&_li]:ml-4 [&_ul]:list-disc [&_ol]:list-decimal">
|
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{body || t('admin.instructions.tabBody')}</ReactMarkdown>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Textarea id="tabBody" value={body} onChange={(e) => setBody(e.target.value)} required />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="tabSortOrder">{t('admin.apps.sortOrder')}</Label>
|
<Label htmlFor="tabSortOrder">{t('admin.apps.sortOrder')}</Label>
|
||||||
<Input id="tabSortOrder" type="number" min={0} value={sortOrder} onChange={(e) => setSortOrder(e.target.value)} />
|
<Input id="tabSortOrder" type="number" min={0} value={sortOrder} onChange={(e) => setSortOrder(e.target.value)} />
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { ImagePlus } from 'lucide-react'
|
||||||
|
import ReactMarkdown from 'react-markdown'
|
||||||
|
import remarkGfm from 'remark-gfm'
|
||||||
|
import { HttpError } from '@/shared/api/client'
|
||||||
|
import { cn } from '@/shared/lib/cn'
|
||||||
|
import { MARKDOWN_CLASSES } from '@/shared/lib/markdown'
|
||||||
|
import { Button } from '@/shared/ui/button'
|
||||||
|
import { Label } from '@/shared/ui/label'
|
||||||
|
import { Textarea } from '@/shared/ui/textarea'
|
||||||
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
|
import { mediaImageUrl, uploadMediaImage } from './api'
|
||||||
|
|
||||||
|
/** Markdown-поле админки: текст + предпросмотр + загрузка картинок (кнопка, вставка из буфера,
|
||||||
|
* drag&drop). Загруженная картинка сразу вставляется в позицию курсора как ``. */
|
||||||
|
export function MarkdownEditor({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
rows = 12,
|
||||||
|
required,
|
||||||
|
}: {
|
||||||
|
id?: string
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
rows?: number
|
||||||
|
required?: boolean
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [previewMode, setPreviewMode] = useState(false)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [dragOver, setDragOver] = useState(false)
|
||||||
|
|
||||||
|
const insertAtCursor = (snippet: string) => {
|
||||||
|
const textarea = textareaRef.current
|
||||||
|
const start = textarea?.selectionStart ?? value.length
|
||||||
|
const end = textarea?.selectionEnd ?? value.length
|
||||||
|
const before = value.slice(0, start)
|
||||||
|
const after = value.slice(end)
|
||||||
|
// Картинка — блочный элемент: отбиваем пустой строкой, иначе markdown склеит её с абзацем.
|
||||||
|
const prefix = before && !before.endsWith('\n') ? '\n\n' : ''
|
||||||
|
const suffix = after.startsWith('\n') ? '' : '\n'
|
||||||
|
onChange(`${before}${prefix}${snippet}${suffix}${after}`)
|
||||||
|
|
||||||
|
const caret = before.length + prefix.length + snippet.length
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
textarea?.focus()
|
||||||
|
textarea?.setSelectionRange(caret, caret)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadFiles = async (files: File[]) => {
|
||||||
|
const images = files.filter((file) => file.type.startsWith('image/'))
|
||||||
|
if (images.length === 0) return
|
||||||
|
|
||||||
|
setUploading(true)
|
||||||
|
try {
|
||||||
|
const uploaded = []
|
||||||
|
for (const file of images) uploaded.push(await uploadMediaImage(file))
|
||||||
|
insertAtCursor(uploaded.map((image) => `})`).join('\n\n'))
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof HttpError ? error.detail : t('admin.media.uploadFailed'))
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<Label htmlFor={id}>{label}</Label>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={uploading}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<ImagePlus className="h-4 w-4" />
|
||||||
|
{uploading ? t('admin.media.uploading') : t('admin.media.uploadImage')}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" size="sm" variant="ghost" onClick={() => setPreviewMode((v) => !v)}>
|
||||||
|
{t('admin.news.preview')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{previewMode ? (
|
||||||
|
<div className={cn('flex flex-col gap-2 rounded-md border border-border px-3 py-2 text-sm', MARKDOWN_CLASSES)} style={{ minHeight: `${rows * 1.5}rem` }}>
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{value || label}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Textarea
|
||||||
|
id={id}
|
||||||
|
ref={textareaRef}
|
||||||
|
rows={rows}
|
||||||
|
required={required}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className={cn('font-mono', dragOver && 'ring-2 ring-primary/50')}
|
||||||
|
onPaste={(e) => {
|
||||||
|
const files = Array.from(e.clipboardData.files)
|
||||||
|
if (files.some((file) => file.type.startsWith('image/'))) {
|
||||||
|
e.preventDefault()
|
||||||
|
void uploadFiles(files)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragOver(true)
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onDrop={(e) => {
|
||||||
|
const files = Array.from(e.dataTransfer.files)
|
||||||
|
setDragOver(false)
|
||||||
|
if (files.some((file) => file.type.startsWith('image/'))) {
|
||||||
|
e.preventDefault()
|
||||||
|
void uploadFiles(files)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">{t('admin.media.hint')}</p>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const files = Array.from(e.target.files ?? [])
|
||||||
|
e.target.value = ''
|
||||||
|
void uploadFiles(files)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alt по имени файла (без расширения) — иначе пустой alt у картинки в markdown. */
|
||||||
|
function altText(fileName: string) {
|
||||||
|
return fileName.replace(/\.[^.]+$/, '').slice(0, 100)
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { apiUpload } from '@/shared/api/client'
|
||||||
|
import type { MediaImageDto } from '@/shared/api/types'
|
||||||
|
|
||||||
|
export function uploadMediaImage(file: File) {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.set('file', file)
|
||||||
|
return apiUpload<MediaImageDto>('/admin/media/images', formData)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Картинки отдаются анонимно по непрозрачному Id — обычный <img> из markdown не шлёт Authorization,
|
||||||
|
* поэтому в отличие от вложений тикетов blob-обёртка не нужна. */
|
||||||
|
export function mediaImageUrl(id: string) {
|
||||||
|
return `/api/media/images/${id}`
|
||||||
|
}
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import ReactMarkdown from 'react-markdown'
|
|
||||||
import remarkGfm from 'remark-gfm'
|
|
||||||
import { toast } from '@/shared/ui/toast-store'
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/shared/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/shared/ui/dialog'
|
||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import { Input } from '@/shared/ui/input'
|
import { Input } from '@/shared/ui/input'
|
||||||
import { Label } from '@/shared/ui/label'
|
import { Label } from '@/shared/ui/label'
|
||||||
import { Textarea } from '@/shared/ui/textarea'
|
import { MarkdownEditor } from '@/features/admin/media/MarkdownEditor'
|
||||||
import { HttpError } from '@/shared/api/client'
|
import { HttpError } from '@/shared/api/client'
|
||||||
import type { NewsPostDto } from '@/shared/api/types'
|
import type { NewsPostDto } from '@/shared/api/types'
|
||||||
import { createPost, updatePost } from './api'
|
import { createPost, updatePost } from './api'
|
||||||
@@ -27,7 +25,6 @@ export function NewsFormDialog({
|
|||||||
const [internalOpen, setInternalOpen] = useState(false)
|
const [internalOpen, setInternalOpen] = useState(false)
|
||||||
const [title, setTitle] = useState(post?.title ?? '')
|
const [title, setTitle] = useState(post?.title ?? '')
|
||||||
const [body, setBody] = useState(post?.body ?? '')
|
const [body, setBody] = useState(post?.body ?? '')
|
||||||
const [previewMode, setPreviewMode] = useState(false)
|
|
||||||
|
|
||||||
const isControlled = open !== undefined
|
const isControlled = open !== undefined
|
||||||
const dialogOpen = isControlled ? open : internalOpen
|
const dialogOpen = isControlled ? open : internalOpen
|
||||||
@@ -52,7 +49,7 @@ export function NewsFormDialog({
|
|||||||
<Button size="sm">{t('admin.news.create')}</Button>
|
<Button size="sm">{t('admin.news.create')}</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
)}
|
)}
|
||||||
<DialogContent>
|
<DialogContent className="max-h-[90vh] max-w-3xl overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{post ? post.title : t('admin.news.create')}</DialogTitle>
|
<DialogTitle>{post ? post.title : t('admin.news.create')}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -67,21 +64,7 @@ export function NewsFormDialog({
|
|||||||
<Label htmlFor="newsTitle">{t('admin.news.title')}</Label>
|
<Label htmlFor="newsTitle">{t('admin.news.title')}</Label>
|
||||||
<Input id="newsTitle" value={title} onChange={(e) => setTitle(e.target.value)} required />
|
<Input id="newsTitle" value={title} onChange={(e) => setTitle(e.target.value)} required />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<MarkdownEditor id="newsBody" label={t('admin.news.body')} value={body} onChange={setBody} required />
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label htmlFor="newsBody">{t('admin.news.body')}</Label>
|
|
||||||
<Button type="button" size="sm" variant="ghost" onClick={() => setPreviewMode((v) => !v)}>
|
|
||||||
{t('admin.news.preview')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{previewMode ? (
|
|
||||||
<div className="flex min-h-32 flex-col gap-2 rounded-md border border-border px-3 py-2 text-sm [&_a]:text-primary [&_a]:hover:underline [&_li]:ml-4 [&_ul]:list-disc [&_ol]:list-decimal">
|
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{body || t('admin.news.body')}</ReactMarkdown>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Textarea id="newsBody" value={body} onChange={(e) => setBody(e.target.value)} required />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button type="submit" disabled={!canSubmit || mutation.isPending}>
|
<Button type="submit" disabled={!canSubmit || mutation.isPending}>
|
||||||
{post ? t('admin.roles.save') : t('admin.news.create')}
|
{post ? t('admin.roles.save') : t('admin.news.create')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { useQuery } from '@tanstack/react-query'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
|
import { cn } from '@/shared/lib/cn'
|
||||||
|
import { MARKDOWN_CLASSES } from '@/shared/lib/markdown'
|
||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||||
import { listNews } from './api'
|
import { listNews } from './api'
|
||||||
@@ -42,7 +44,7 @@ export function NewsFeed() {
|
|||||||
<CardTitle className="text-base">{post.title}</CardTitle>
|
<CardTitle className="text-base">{post.title}</CardTitle>
|
||||||
<p className="text-xs text-muted-foreground">{new Date(post.createdAt).toLocaleString()}</p>
|
<p className="text-xs text-muted-foreground">{new Date(post.createdAt).toLocaleString()}</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-2 text-sm [&_a]:text-primary [&_a]:hover:underline [&_li]:ml-4 [&_ul]:list-disc [&_ol]:list-decimal">
|
<CardContent className={cn('flex flex-col gap-2 text-sm', MARKDOWN_CLASSES)}>
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{post.body}</ReactMarkdown>
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{post.body}</ReactMarkdown>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import { useRequireActivated } from '@/features/auth/guards'
|
|||||||
import { AppsCatalog } from '@/features/apps/AppsCatalog'
|
import { AppsCatalog } from '@/features/apps/AppsCatalog'
|
||||||
import { getInstructionIntro, listInstructionTabs } from '@/features/instructions/api'
|
import { getInstructionIntro, listInstructionTabs } from '@/features/instructions/api'
|
||||||
import { cn } from '@/shared/lib/cn'
|
import { cn } from '@/shared/lib/cn'
|
||||||
|
import { MARKDOWN_CLASSES } from '@/shared/lib/markdown'
|
||||||
|
|
||||||
export const Route = createFileRoute('/instructions')({ component: InstructionsPage })
|
export const Route = createFileRoute('/instructions')({ component: InstructionsPage })
|
||||||
|
|
||||||
const APPS_TAB_ID = '__apps__'
|
const APPS_TAB_ID = '__apps__'
|
||||||
const MARKDOWN_CLASSES = '[&_a]:text-primary [&_a]:hover:underline [&_li]:ml-4 [&_ul]:list-disc [&_ol]:list-decimal'
|
|
||||||
|
|
||||||
function InstructionsPage() {
|
function InstructionsPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|||||||
@@ -134,6 +134,15 @@ export type InstructionTabDto = {
|
|||||||
updatedAt: string | null
|
updatedAt: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** POST /api/admin/media/images — картинка для вставки в markdown. Ссылку клиент строит сам:
|
||||||
|
* `/api/media/images/{id}` (см. features/admin/media/api.ts). */
|
||||||
|
export type MediaImageDto = {
|
||||||
|
id: string
|
||||||
|
fileName: string
|
||||||
|
contentType: string
|
||||||
|
sizeBytes: number
|
||||||
|
}
|
||||||
|
|
||||||
export type LinkTokenResponse = {
|
export type LinkTokenResponse = {
|
||||||
deepLink: string | null
|
deepLink: string | null
|
||||||
expiresAt: string
|
expiresAt: string
|
||||||
|
|||||||
@@ -501,6 +501,12 @@ const resources = {
|
|||||||
tabDeleted: 'Вкладка удалена.',
|
tabDeleted: 'Вкладка удалена.',
|
||||||
confirmDeleteTab: 'Удалить вкладку инструкций?',
|
confirmDeleteTab: 'Удалить вкладку инструкций?',
|
||||||
},
|
},
|
||||||
|
media: {
|
||||||
|
uploadImage: 'Картинка',
|
||||||
|
uploading: 'Загрузка…',
|
||||||
|
uploadFailed: 'Не удалось загрузить картинку.',
|
||||||
|
hint: 'Картинку можно загрузить кнопкой, вставить из буфера (Ctrl+V) или перетащить в поле — в текст добавится ссылка . До 5 МБ, JPEG/PNG/WEBP/GIF.',
|
||||||
|
},
|
||||||
news: {
|
news: {
|
||||||
create: 'Добавить новость',
|
create: 'Добавить новость',
|
||||||
title: 'Заголовок',
|
title: 'Заголовок',
|
||||||
@@ -1095,6 +1101,12 @@ const resources = {
|
|||||||
tabDeleted: 'Tab deleted.',
|
tabDeleted: 'Tab deleted.',
|
||||||
confirmDeleteTab: 'Delete this instruction tab?',
|
confirmDeleteTab: 'Delete this instruction tab?',
|
||||||
},
|
},
|
||||||
|
media: {
|
||||||
|
uploadImage: 'Image',
|
||||||
|
uploading: 'Uploading…',
|
||||||
|
uploadFailed: 'Failed to upload the image.',
|
||||||
|
hint: 'Upload an image with the button, paste it from the clipboard (Ctrl+V) or drop it onto the field — an  link is inserted into the text. Up to 5 MB, JPEG/PNG/WEBP/GIF.',
|
||||||
|
},
|
||||||
news: {
|
news: {
|
||||||
create: 'Add post',
|
create: 'Add post',
|
||||||
title: 'Title',
|
title: 'Title',
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/** Оформление отрендеренного markdown (ссылки, списки, картинки) — одинаковое во всех местах:
|
||||||
|
* страница инструкций, лента новостей и предпросмотр в админке. */
|
||||||
|
export const MARKDOWN_CLASSES =
|
||||||
|
'[&_a]:text-primary [&_a]:hover:underline [&_li]:ml-4 [&_ul]:list-disc [&_ol]:list-decimal ' +
|
||||||
|
'[&_img]:my-2 [&_img]:max-w-full [&_img]:rounded-md [&_img]:border [&_img]:border-border'
|
||||||
Reference in New Issue
Block a user