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);