From 69d13195645682972ef4a0590984ecde98a62a23 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 26 Jul 2026 23:05:37 +0300 Subject: [PATCH] Add UploadLimits service and refactor user and media listing endpoints to use filter records for improved parameter handling. Update audio upload logic to streamline parameter passing and enhance clarity. Adjust ImageEndpoints to use HashSet for allowed extensions, and refine StreamingEndpoints to clarify token handling. --- .../src/TeleWave.Api/Common/UploadLimits.cs | 21 ++++++++ .../Endpoints/AdminUserEndpoints.cs | 44 +++++++++-------- .../Endpoints/ChannelEndpoints.Bumpers.cs | 42 ++++++++++------ .../TeleWave.Api/Endpoints/ImageEndpoints.cs | 4 +- .../TeleWave.Api/Endpoints/MediaEndpoints.cs | 48 ++++++++++--------- .../Endpoints/StreamingEndpoints.cs | 6 ++- backend/src/TeleWave.Api/Program.cs | 1 + 7 files changed, 108 insertions(+), 58 deletions(-) create mode 100644 backend/src/TeleWave.Api/Common/UploadLimits.cs diff --git a/backend/src/TeleWave.Api/Common/UploadLimits.cs b/backend/src/TeleWave.Api/Common/UploadLimits.cs new file mode 100644 index 0000000..b954225 --- /dev/null +++ b/backend/src/TeleWave.Api/Common/UploadLimits.cs @@ -0,0 +1,21 @@ +using Microsoft.Extensions.Options; +using TeleWave.Infrastructure.Media; + +namespace TeleWave.Api.Common; + +/// +/// Лимиты приёма файла, собранные в одно место. Они живут в двух разных секциях конфигурации +/// (Media и Storage), но проверяются всегда вместе и только на входе загрузки — +/// хендлеру незачем знать про обе секции и тащить два в сигнатуре. +/// +public sealed class UploadLimits( + IOptions media, + IOptions storage +) +{ + /// Потолок размера загружаемого файла. + public long MaxUploadBytes { get; } = media.Value.MaxUploadBytes; + + /// Сколько места должно остаться свободным после приёма файла. + public long MinFreeSpaceBytes { get; } = storage.Value.MinFreeSpaceBytes; +} diff --git a/backend/src/TeleWave.Api/Endpoints/AdminUserEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/AdminUserEndpoints.cs index 34c0419..b7e2e1e 100644 --- a/backend/src/TeleWave.Api/Endpoints/AdminUserEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/AdminUserEndpoints.cs @@ -32,31 +32,21 @@ public static class AdminUserEndpoints return app; } - /// - /// Все параметры со значениями по умолчанию: обязательный bool в query превращает - /// запрос без него в 400 ещё до хендлера — ошибку в таком виде отладить тяжело. - /// private static async Task ListUsers( + [AsParameters] ListUsersFilter filter, ISender sender, - CancellationToken cancellationToken, - int page = 1, - int pageSize = 20, - string? search = null, - Guid? roleId = null, - bool? isBlocked = null, - string? sort = null, - bool desc = false + CancellationToken cancellationToken ) { var result = await sender.Send( new ListUsersQuery( - page <= 0 ? 1 : page, - pageSize <= 0 ? 20 : pageSize, - search, - roleId, - isBlocked, - sort, - desc + filter.Page is > 0 ? filter.Page.Value : 1, + filter.PageSize is > 0 ? filter.PageSize.Value : 20, + filter.Search, + filter.RoleId, + filter.IsBlocked, + filter.Sort, + filter.Desc ?? false ), cancellationToken ); @@ -126,6 +116,22 @@ public static class AdminUserEndpoints } } +/// +/// Фильтр и страница списка пользователей. Все поля nullable намеренно: обязательный параметр в query +/// превращает запрос без него в 400 ещё до хендлера — ошибку в таком виде отладить тяжело. Умолчания +/// подставляет сам хендлер, а не объявление: при AsParameters обязательность определяется +/// nullable-типом, а не наличием значения по умолчанию в конструкторе. +/// +public sealed record ListUsersFilter( + int? Page, + int? PageSize, + string? Search, + Guid? RoleId, + bool? IsBlocked, + string? Sort, + bool? Desc +); + public sealed record CreateUserBody(string UserName, string Password, Guid RoleId); public sealed record ResetPasswordBody(string NewPassword); diff --git a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.Bumpers.cs b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.Bumpers.cs index 4368f9b..a5116db 100644 --- a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.Bumpers.cs +++ b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.Bumpers.cs @@ -66,9 +66,7 @@ public static partial class ChannelEndpoints } private static async Task UploadTemplateAudio( - Guid id, - Guid templateId, - string fileName, + [AsParameters] BumperAudioUpload upload, HttpRequest request, IBumperTemplateStorage storage, IAudioProbe probe, @@ -76,23 +74,31 @@ public static partial class ChannelEndpoints CancellationToken cancellationToken ) { - if (ResolveBumperExtension(fileName, request, BumperFiles.AudioExtensions) is not { } ext) + if ( + ResolveBumperExtension(upload.FileName, request, BumperFiles.AudioExtensions) + is not { } ext + ) return ChannelErrors.InvalidBumperFile.ToProblem(); - await storage.SaveAudioAsync(templateId, ext, request.Body, cancellationToken); + await storage.SaveAudioAsync(upload.TemplateId, ext, request.Body, cancellationToken); // Длина заставки идёт по длине звука — замеряем ffprobe (при неудаче 0 → дефолтная длина). - var path = storage.AudioPath(templateId, ext); + var path = storage.AudioPath(upload.TemplateId, ext); var duration = path is null ? null : await probe.TryGetDurationAsync(path, cancellationToken); var result = await sender.Send( - new SetBumperTemplateAudioCommand(id, templateId, ext, duration?.TotalSeconds ?? 0), + new SetBumperTemplateAudioCommand( + upload.Id, + upload.TemplateId, + ext, + duration?.TotalSeconds ?? 0 + ), cancellationToken ); if (!result.IsSuccess) - await storage.DeleteAudioAsync(templateId, cancellationToken); + await storage.DeleteAudioAsync(upload.TemplateId, cancellationToken); return result.ToHttpResult(); } @@ -242,13 +248,12 @@ public static partial class ChannelEndpoints return Results.Text(sb.ToString(), "application/vnd.apple.mpegurl"); } - private static IResult PreviewSegment( - Guid id, - Guid templateId, - Guid variantId, - string file, - MediaPathResolver paths - ) + /// + /// Сегмент превью. Канал и блок в маршруте есть, но хендлеру не нужны: каталог превью + /// адресуется подблоком (см. BumperPreview.AssetId), поэтому в сигнатуре их нет — незаявленные + /// параметры маршрута просто не связываются. + /// + private static IResult PreviewSegment(Guid variantId, string file, MediaPathResolver paths) { if (!SegmentFiles.IsSegmentName(file)) return Results.NotFound(); @@ -300,6 +305,13 @@ public sealed record UpdateBumperVariantBody( int Weight ); +/// +/// Адрес загружаемого звука: канал и блок из маршрута плюс имя исходного файла из query (по нему +/// проверяется расширение). Свёрнуто в один параметр — кроме него хендлеру нужны ещё запрос, два +/// сервиса, диспетчер и токен отмены, и плоским списком сигнатура перестаёт читаться. +/// +public sealed record BumperAudioUpload(Guid Id, Guid TemplateId, string FileName); + /// Ограничения на загружаемый звук блока заставки (фон-картинка — через общий реестр). internal static class BumperFiles { diff --git a/backend/src/TeleWave.Api/Endpoints/ImageEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/ImageEndpoints.cs index f8d0b47..dd68624 100644 --- a/backend/src/TeleWave.Api/Endpoints/ImageEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/ImageEndpoints.cs @@ -15,7 +15,9 @@ public static class ImageEndpoints { private const long MaxBytes = 50L * 1024 * 1024; // 50 МБ - private static readonly IReadOnlySet AllowedExtensions = new HashSet( + // Тип поля — HashSet, а не IReadOnlySet: поле приватное, и через интерфейс Contains уходит + // в виртуальный вызов вместо прямого. + private static readonly HashSet AllowedExtensions = new( StringComparer.OrdinalIgnoreCase ) { diff --git a/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs index 6af816e..52bde5e 100644 --- a/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs @@ -51,8 +51,7 @@ public static class MediaEndpoints IMediaStorage storage, IMediaProcessingQueue queue, ISender sender, - IOptions mediaOptions, - IOptions storageOptions, + UploadLimits limits, CancellationToken cancellationToken ) { @@ -71,7 +70,7 @@ public static class MediaEndpoints ); var contentLength = request.ContentLength ?? 0; - if (contentLength > mediaOptions.Value.MaxUploadBytes) + if (contentLength > limits.MaxUploadBytes) return Results.Problem( title: MediaErrors.FileTooLarge.Code, detail: MediaErrors.FileTooLarge.Message, @@ -79,7 +78,7 @@ public static class MediaEndpoints ); var free = storage.GetAvailableFreeSpaceBytes(); - if (free - contentLength < storageOptions.Value.MinFreeSpaceBytes) + if (free - contentLength < limits.MinFreeSpaceBytes) return Results.Problem( title: MediaErrors.InsufficientStorage.Code, detail: MediaErrors.InsufficientStorage.Message, @@ -106,30 +105,20 @@ public static class MediaEndpoints ); } - /// - /// Все параметры со значениями по умолчанию: обязательный bool в query заставлял - /// минимальный API отвечать 400 на запросы без него — например, из выборок «все готовые ассеты», - /// которым сортировка не нужна. - /// private static async Task List( + [AsParameters] ListMediaFilter filter, ISender sender, - CancellationToken cancellationToken, - int page = 1, - int pageSize = 20, - MediaAssetStatus[]? status = null, - string? search = null, - string? sort = null, - bool desc = false + CancellationToken cancellationToken ) { var result = await sender.Send( new ListMediaAssetsQuery( - page <= 0 ? 1 : page, - pageSize <= 0 ? 20 : pageSize, - status ?? [], - search, - sort, - desc + filter.Page is > 0 ? filter.Page.Value : 1, + filter.PageSize is > 0 ? filter.PageSize.Value : 20, + filter.Status ?? [], + filter.Search, + filter.Sort, + filter.Desc ?? false ), cancellationToken ); @@ -204,6 +193,21 @@ public static class MediaEndpoints } } +/// +/// Фильтр и страница списка медиа. Все поля nullable намеренно: обязательный параметр в query +/// заставлял минимальный API отвечать 400 на запросы без него — например, из выборок «все готовые +/// ассеты», которым сортировка не нужна. Умолчания подставляет хендлер, а не объявление: при +/// AsParameters обязательность определяется nullable-типом, а не значением по умолчанию. +/// +public sealed record ListMediaFilter( + int? Page, + int? PageSize, + MediaAssetStatus[]? Status, + string? Search, + string? Sort, + bool? Desc +); + public sealed record UploadMediaResponse(Guid Id); public sealed record ImportManualInboxBody(IReadOnlyList Items, Guid ShowId); diff --git a/backend/src/TeleWave.Api/Endpoints/StreamingEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/StreamingEndpoints.cs index ecd8de3..c9af4c9 100644 --- a/backend/src/TeleWave.Api/Endpoints/StreamingEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/StreamingEndpoints.cs @@ -51,8 +51,12 @@ public static class StreamingEndpoints new ViewerFeaturesDto(await siteSettings.AreChannelNumbersEnabledAsync(cancellationToken)) ); + /// + /// Выдаёт cookie доступа к эфиру. Канал в маршруте есть для симметрии с остальными + /// эндпоинтами, но токен не привязан к каналу — он подтверждает зрителя, а не подписку на + /// конкретную ленту, поэтому в сигнатуре slug не нужен. + /// private static IResult Watch( - string slug, ICurrentUser currentUser, StreamTokenService tokens, HttpRequest request, diff --git a/backend/src/TeleWave.Api/Program.cs b/backend/src/TeleWave.Api/Program.cs index cbdc289..88d2c0f 100644 --- a/backend/src/TeleWave.Api/Program.cs +++ b/backend/src/TeleWave.Api/Program.cs @@ -61,6 +61,7 @@ builder.Services.Configure(options => }); builder.Services.AddHttpContextAccessor(); +builder.Services.AddSingleton(); builder.Services.AddApplication(); builder.Services.AddInfrastructure(builder.Configuration);