Add image management functionality: introduce Image entity and related API endpoints, update database schema to support image storage, and enhance UI with a new gallery feature for image selection and upload. Update translations for gallery-related terms.
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Api.Common;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Images;
|
||||
using TeleWave.Application.Images.DeleteImage;
|
||||
using TeleWave.Application.Images.GetImageFile;
|
||||
using TeleWave.Application.Images.ListImages;
|
||||
using TeleWave.Application.Images.UploadImage;
|
||||
using TeleWave.Domain.Images;
|
||||
using TeleWave.Infrastructure.Identity;
|
||||
|
||||
namespace TeleWave.Api.Endpoints;
|
||||
|
||||
public static class ImageEndpoints
|
||||
{
|
||||
private const long MaxBytes = 50L * 1024 * 1024; // 50 МБ
|
||||
|
||||
private static readonly IReadOnlySet<string> AllowedExtensions = new HashSet<string>(
|
||||
StringComparer.OrdinalIgnoreCase
|
||||
)
|
||||
{
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".webp",
|
||||
".bmp",
|
||||
".gif",
|
||||
};
|
||||
|
||||
public static IEndpointRouteBuilder MapImageEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var admin = app.MapGroup("/api/admin/images")
|
||||
.WithTags("Admin.Images")
|
||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||
|
||||
admin.MapGet("", ListImages).Produces<IReadOnlyList<ImageDto>>();
|
||||
admin.MapPost("", UploadImage).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||
admin.MapDelete("/{id:guid}", DeleteImage).Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
// Публичная отдача файла (для <img> у зрителя и в админке).
|
||||
app.MapGet("/api/images/{id:guid}", ServeImage).WithTags("Images");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListImages(
|
||||
ImageCategory category,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new ListImagesQuery(category), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> UploadImage(
|
||||
ImageCategory category,
|
||||
string fileName,
|
||||
HttpRequest request,
|
||||
IImageStore storage,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (request.ContentLength is > MaxBytes or 0 or null)
|
||||
return ImageErrors.InvalidFile.ToProblem();
|
||||
var ext = Path.GetExtension(fileName).ToLowerInvariant();
|
||||
if (!AllowedExtensions.Contains(ext))
|
||||
return ImageErrors.InvalidFile.ToProblem();
|
||||
|
||||
var created = await sender.Send(
|
||||
new UploadImageCommand(category, ext, fileName),
|
||||
cancellationToken
|
||||
);
|
||||
if (!created.IsSuccess)
|
||||
return created.ToHttpResult();
|
||||
|
||||
try
|
||||
{
|
||||
await storage.SaveAsync(created.Value, ext, request.Body, cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Файл не сохранился — не оставляем висячую запись реестра.
|
||||
await sender.Send(new DeleteImageCommand(created.Value), cancellationToken);
|
||||
throw;
|
||||
}
|
||||
|
||||
return Results.Created($"/api/images/{created.Value}", new CreatedIdResponse(created.Value));
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteImage(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new DeleteImageCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ServeImage(
|
||||
Guid id,
|
||||
HttpResponse response,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new GetImageFileQuery(id), cancellationToken);
|
||||
if (!result.IsSuccess)
|
||||
return Results.NotFound();
|
||||
|
||||
response.Headers.CacheControl = "public, max-age=86400";
|
||||
return Results.File(result.Value, ContentTypeFor(Path.GetExtension(result.Value)));
|
||||
}
|
||||
|
||||
private static string ContentTypeFor(string extension) =>
|
||||
extension.ToLowerInvariant() switch
|
||||
{
|
||||
".png" => "image/png",
|
||||
".webp" => "image/webp",
|
||||
".gif" => "image/gif",
|
||||
".bmp" => "image/bmp",
|
||||
_ => "image/jpeg",
|
||||
};
|
||||
}
|
||||
@@ -119,6 +119,7 @@ app.MapStreamingEndpoints();
|
||||
app.MapMaintenanceEndpoints();
|
||||
app.MapSettingsEndpoints();
|
||||
app.MapMetadataEndpoints();
|
||||
app.MapImageEndpoints();
|
||||
|
||||
// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов.
|
||||
app.UseDefaultFiles();
|
||||
|
||||
Reference in New Issue
Block a user