Normalize line endings to LF via .gitattributes

Репозиторий хранил фронтенд в CRLF, а часть бэкенда — вперемешку, хотя CI и Docker-сборка
работают под Linux. Прибиваем LF атрибутом `* text=auto eol=lf` и разово нормализуем дерево,
чтобы форматтеры не переписывали файлы целиком на каждом прогоне.

Коммит чисто механический: изменений содержимого нет, только концы строк.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-07-27 01:37:33 +03:00
co-authored by Claude Opus 5
parent 9d1c6d2fc3
commit 0442056367
109 changed files with 13469 additions and 13459 deletions
@@ -1,247 +1,247 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Broadcast.CreateChannel;
using TeleWave.Application.Broadcast.GetChannel;
using TeleWave.Application.Broadcast.GetSchedule;
using TeleWave.Application.Broadcast.ListChannels;
using TeleWave.Application.Broadcast.UpdateChannelSettings;
using TeleWave.Application.Broadcast.UpdateChannelTime;
using TeleWave.Application.Broadcast.UpdateViewerSettings;
using TeleWave.Application.Programming.Planning.Trace;
using TeleWave.Domain.Broadcast;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
/// <summary>
/// Админ-эндпоинты канала: создание, список, настройки, время и чтение расписания. Заставки —
/// в <c>ChannelEndpoints.Bumpers.cs</c>. Что и когда идёт в эфире, задаёт шаблон сетки
/// (<c>TemplateEndpoints</c>).
/// </summary>
public static partial class ChannelEndpoints
{
public static IEndpointRouteBuilder MapChannelEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/channels")
.WithTags("Admin.Channels")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapPost("", CreateChannel).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapGet("", ListChannels).Produces<IReadOnlyList<ChannelSummaryDto>>();
admin.MapGet("/{id:guid}", GetChannel).Produces<ChannelDto>();
admin
.MapPut("/{id:guid}/settings", UpdateSettings)
.Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{id:guid}/time", UpdateTime).Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{id:guid}/bumper/templates", AddBumperTemplate)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapPut("/{id:guid}/bumper/templates/{templateId:guid}", UpdateBumperTemplate)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/{id:guid}/bumper/templates/{templateId:guid}", RemoveBumperTemplate)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPut("/{id:guid}/bumper/templates/{templateId:guid}/audio", UploadTemplateAudio)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/{id:guid}/bumper/templates/{templateId:guid}/audio", ClearTemplateAudio)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPut(
"/{id:guid}/bumper/templates/{templateId:guid}/background",
SetTemplateBackground
)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete(
"/{id:guid}/bumper/templates/{templateId:guid}/background",
ClearTemplateBackground
)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{id:guid}/bumper/templates/{templateId:guid}/preview", RenderPreview)
.Produces(StatusCodes.Status204NoContent);
admin.MapGet(
"/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/index.m3u8",
PreviewPlaylist
);
admin.MapGet(
"/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/{file}",
PreviewSegment
);
admin
.MapPost("/{id:guid}/bumper/templates/{templateId:guid}/variants", AddBumperVariant)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapPut(
"/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}",
UpdateBumperVariant
)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete(
"/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}",
RemoveBumperVariant
)
.Produces(StatusCodes.Status204NoContent);
admin
.MapGet("/{id:guid}/schedule", GetSchedule)
.Produces<IReadOnlyList<ScheduleEntryDto>>();
admin
.MapPut("/{id:guid}/viewer", UpdateViewerSettings)
.Produces(StatusCodes.Status204NoContent);
// «Почему это здесь»: цепочка происхождения записи, записанная в момент генерации.
admin.MapGet("/entries/{entryId:guid}/trace", GetEntryTrace).Produces<EntryTraceDto>();
return app;
}
private static async Task<IResult> UpdateViewerSettings(
Guid id,
UpdateViewerSettingsBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateViewerSettingsCommand(
id,
body.LogoImageId,
body.LogoCorner,
body.LogoOpacity,
body.ShowClock,
body.AnalogFilterStrength
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> GetEntryTrace(
Guid entryId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetEntryTraceQuery(entryId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateChannel(
CreateChannelCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.IsSuccess
? Results.Created(
$"/api/admin/channels/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> ListChannels(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListChannelsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> GetChannel(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetChannelQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateTime(
Guid id,
UpdateChannelTimeBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateChannelTimeCommand(id, body.Number, body.UtcOffsetMinutes, body.DayStartTime),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateSettings(
Guid id,
UpdateChannelSettingsBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateChannelSettingsCommand(
id,
body.Name,
body.IsEnabled,
body.BumpersEnabled,
body.Bumper,
body.FillerAssetId
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> GetSchedule(
Guid id,
DateTimeOffset? from,
DateTimeOffset? to,
ISender sender,
CancellationToken cancellationToken
)
{
var fromUtc = from ?? DateTimeOffset.UtcNow;
var toUtc = to ?? fromUtc.AddDays(1);
var result = await sender.Send(
new GetChannelScheduleQuery(id, fromUtc, toUtc),
cancellationToken
);
return result.ToHttpResult();
}
}
/// <summary>Номер канала и его время: смещение от UTC и начало вещательных суток.</summary>
public sealed record UpdateChannelTimeBody(
int? Number,
int UtcOffsetMinutes,
TimeOnly DayStartTime
);
public sealed record UpdateChannelSettingsBody(
string Name,
bool IsEnabled,
bool BumpersEnabled,
BumperSettingsInput Bumper,
Guid? FillerAssetId
);
/// <summary>Оверлеи и аналоговый фильтр — как канал выглядит у зрителя (см. 6.8).</summary>
public sealed record UpdateViewerSettingsBody(
Guid? LogoImageId,
LogoCorner LogoCorner,
double LogoOpacity,
bool ShowClock,
double AnalogFilterStrength
);
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Broadcast.CreateChannel;
using TeleWave.Application.Broadcast.GetChannel;
using TeleWave.Application.Broadcast.GetSchedule;
using TeleWave.Application.Broadcast.ListChannels;
using TeleWave.Application.Broadcast.UpdateChannelSettings;
using TeleWave.Application.Broadcast.UpdateChannelTime;
using TeleWave.Application.Broadcast.UpdateViewerSettings;
using TeleWave.Application.Programming.Planning.Trace;
using TeleWave.Domain.Broadcast;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
/// <summary>
/// Админ-эндпоинты канала: создание, список, настройки, время и чтение расписания. Заставки —
/// в <c>ChannelEndpoints.Bumpers.cs</c>. Что и когда идёт в эфире, задаёт шаблон сетки
/// (<c>TemplateEndpoints</c>).
/// </summary>
public static partial class ChannelEndpoints
{
public static IEndpointRouteBuilder MapChannelEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/channels")
.WithTags("Admin.Channels")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapPost("", CreateChannel).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapGet("", ListChannels).Produces<IReadOnlyList<ChannelSummaryDto>>();
admin.MapGet("/{id:guid}", GetChannel).Produces<ChannelDto>();
admin
.MapPut("/{id:guid}/settings", UpdateSettings)
.Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{id:guid}/time", UpdateTime).Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{id:guid}/bumper/templates", AddBumperTemplate)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapPut("/{id:guid}/bumper/templates/{templateId:guid}", UpdateBumperTemplate)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/{id:guid}/bumper/templates/{templateId:guid}", RemoveBumperTemplate)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPut("/{id:guid}/bumper/templates/{templateId:guid}/audio", UploadTemplateAudio)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/{id:guid}/bumper/templates/{templateId:guid}/audio", ClearTemplateAudio)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPut(
"/{id:guid}/bumper/templates/{templateId:guid}/background",
SetTemplateBackground
)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete(
"/{id:guid}/bumper/templates/{templateId:guid}/background",
ClearTemplateBackground
)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{id:guid}/bumper/templates/{templateId:guid}/preview", RenderPreview)
.Produces(StatusCodes.Status204NoContent);
admin.MapGet(
"/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/index.m3u8",
PreviewPlaylist
);
admin.MapGet(
"/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/{file}",
PreviewSegment
);
admin
.MapPost("/{id:guid}/bumper/templates/{templateId:guid}/variants", AddBumperVariant)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapPut(
"/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}",
UpdateBumperVariant
)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete(
"/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}",
RemoveBumperVariant
)
.Produces(StatusCodes.Status204NoContent);
admin
.MapGet("/{id:guid}/schedule", GetSchedule)
.Produces<IReadOnlyList<ScheduleEntryDto>>();
admin
.MapPut("/{id:guid}/viewer", UpdateViewerSettings)
.Produces(StatusCodes.Status204NoContent);
// «Почему это здесь»: цепочка происхождения записи, записанная в момент генерации.
admin.MapGet("/entries/{entryId:guid}/trace", GetEntryTrace).Produces<EntryTraceDto>();
return app;
}
private static async Task<IResult> UpdateViewerSettings(
Guid id,
UpdateViewerSettingsBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateViewerSettingsCommand(
id,
body.LogoImageId,
body.LogoCorner,
body.LogoOpacity,
body.ShowClock,
body.AnalogFilterStrength
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> GetEntryTrace(
Guid entryId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetEntryTraceQuery(entryId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateChannel(
CreateChannelCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.IsSuccess
? Results.Created(
$"/api/admin/channels/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> ListChannels(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListChannelsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> GetChannel(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetChannelQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateTime(
Guid id,
UpdateChannelTimeBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateChannelTimeCommand(id, body.Number, body.UtcOffsetMinutes, body.DayStartTime),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateSettings(
Guid id,
UpdateChannelSettingsBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateChannelSettingsCommand(
id,
body.Name,
body.IsEnabled,
body.BumpersEnabled,
body.Bumper,
body.FillerAssetId
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> GetSchedule(
Guid id,
DateTimeOffset? from,
DateTimeOffset? to,
ISender sender,
CancellationToken cancellationToken
)
{
var fromUtc = from ?? DateTimeOffset.UtcNow;
var toUtc = to ?? fromUtc.AddDays(1);
var result = await sender.Send(
new GetChannelScheduleQuery(id, fromUtc, toUtc),
cancellationToken
);
return result.ToHttpResult();
}
}
/// <summary>Номер канала и его время: смещение от UTC и начало вещательных суток.</summary>
public sealed record UpdateChannelTimeBody(
int? Number,
int UtcOffsetMinutes,
TimeOnly DayStartTime
);
public sealed record UpdateChannelSettingsBody(
string Name,
bool IsEnabled,
bool BumpersEnabled,
BumperSettingsInput Bumper,
Guid? FillerAssetId
);
/// <summary>Оверлеи и аналоговый фильтр — как канал выглядит у зрителя (см. 6.8).</summary>
public sealed record UpdateViewerSettingsBody(
Guid? LogoImageId,
LogoCorner LogoCorner,
double LogoOpacity,
bool ShowClock,
double AnalogFilterStrength
);
@@ -1,216 +1,216 @@
using System.Text;
using LiteCqrs;
using Microsoft.Extensions.Options;
using TeleWave.Api.Common;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Media;
using TeleWave.Application.Media.Delete;
using TeleWave.Application.Media.ListMedia;
using TeleWave.Application.Media.ManualInbox;
using TeleWave.Application.Media.Register;
using TeleWave.Application.Media.Stats;
using TeleWave.Domain.Media;
using TeleWave.Infrastructure.Identity;
using TeleWave.Infrastructure.Media;
namespace TeleWave.Api.Endpoints;
public static class MediaEndpoints
{
public static IEndpointRouteBuilder MapMediaEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/media")
.WithTags("Admin.Media")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapPost("", Upload).Produces<UploadMediaResponse>(StatusCodes.Status201Created);
admin.MapGet("", List).Produces<PagedList<MediaAssetDto>>();
admin.MapGet("/stats", Stats).Produces<MediaStatsDto>();
admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent);
// Ручной inbox: сканером не разбирается — файлы выбирает админ и сразу указывает шоу.
admin.MapGet("/manual", ListManual).Produces<ManualInboxListDto>();
admin.MapPost("/manual/import", ImportManual).Produces<ImportManualInboxResultDto>();
// Просмотр обработанного ассета в админке (ролики, проверка серии). Публичная раздача идёт
// по stream-куке, здесь роут под JWT — плейлист и сегменты грузит hls.js с Bearer.
admin.MapGet("/{id:guid}/preview/index.m3u8", PreviewPlaylist);
admin.MapGet("/{id:guid}/preview/{file}", PreviewSegment);
return app;
}
/// <summary>
/// Потоковая загрузка: тело запроса — сырые байты файла, имя передаётся в query «fileName».
/// Файл стримится на диск без буферизации в память, затем регистрируется и уходит в обработку.
/// </summary>
private static async Task<IResult> Upload(
string fileName,
HttpRequest request,
IMediaStorage storage,
IMediaProcessingQueue queue,
ISender sender,
UploadLimits limits,
CancellationToken cancellationToken
)
{
if (string.IsNullOrWhiteSpace(fileName))
return Results.Problem(
title: MediaErrors.EmptyFileName.Code,
detail: MediaErrors.EmptyFileName.Message,
statusCode: StatusCodes.Status400BadRequest
);
if (!MediaFormats.IsAllowed(fileName))
return Results.Problem(
title: MediaErrors.UnsupportedFormat.Code,
detail: MediaErrors.UnsupportedFormat.Message,
statusCode: StatusCodes.Status400BadRequest
);
var contentLength = request.ContentLength ?? 0;
if (contentLength > limits.MaxUploadBytes)
return Results.Problem(
title: MediaErrors.FileTooLarge.Code,
detail: MediaErrors.FileTooLarge.Message,
statusCode: StatusCodes.Status400BadRequest
);
var free = storage.GetAvailableFreeSpaceBytes();
if (free - contentLength < limits.MinFreeSpaceBytes)
return Results.Problem(
title: MediaErrors.InsufficientStorage.Code,
detail: MediaErrors.InsufficientStorage.Message,
statusCode: StatusCodes.Status409Conflict
);
var extension = Path.GetExtension(fileName);
var token = await storage.SaveUploadAsync(request.Body, extension, cancellationToken);
var result = await sender.Send(
new RegisterMediaAssetCommand(token, MediaSource.Upload, fileName),
cancellationToken
);
if (!result.IsSuccess)
{
await storage.DeleteUploadAsync(token, cancellationToken);
return result.ToHttpResult();
}
queue.Enqueue(result.Value);
return Results.Created(
$"/api/admin/media/{result.Value}",
new UploadMediaResponse(result.Value)
);
}
private static async Task<IResult> List(
[AsParameters] ListMediaFilter filter,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ListMediaAssetsQuery(
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
);
return Results.Ok(result);
}
private static async Task<IResult> Stats(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new GetMediaStatsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> Delete(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteMediaAssetCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ListManual(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListManualInboxQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> ImportManual(
ImportManualInboxBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ImportManualInboxCommand(body.Items, body.ShowId),
cancellationToken
);
return result.ToHttpResult();
}
/// <summary>Плейлист ассета: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
private static IResult PreviewPlaylist(Guid id, MediaPathResolver paths)
{
if (SegmentFiles.TryResolveExisting(paths, id, "index.m3u8") is not { } indexPath)
return Results.NotFound();
var baseUrl = $"/api/admin/media/{id}/preview/";
var sb = new StringBuilder();
foreach (var line in File.ReadLines(indexPath))
{
var trimmed = line.Trim();
if (trimmed.Length == 0)
continue;
// Директивы — как есть; строки-сегменты (абсолютный путь от ffmpeg) → admin-URL.
sb.Append(trimmed.StartsWith('#') ? trimmed : baseUrl + Path.GetFileName(trimmed))
.Append('\n');
}
return Results.Text(sb.ToString(), "application/vnd.apple.mpegurl");
}
private static IResult PreviewSegment(Guid id, string file, MediaPathResolver paths)
{
if (!SegmentFiles.IsSegmentName(file))
return Results.NotFound();
if (SegmentFiles.TryResolveExisting(paths, id, file) is not { } path)
return Results.NotFound();
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
}
}
/// <summary>
/// Фильтр и страница списка медиа. Все поля nullable намеренно: обязательный параметр в query
/// заставлял минимальный API отвечать 400 на запросы без него — например, из выборок «все готовые
/// ассеты», которым сортировка не нужна. Умолчания подставляет хендлер, а не объявление: при
/// <c>AsParameters</c> обязательность определяется nullable-типом, а не значением по умолчанию.
/// </summary>
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<ManualImportItem> Items, Guid ShowId);
using System.Text;
using LiteCqrs;
using Microsoft.Extensions.Options;
using TeleWave.Api.Common;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Media;
using TeleWave.Application.Media.Delete;
using TeleWave.Application.Media.ListMedia;
using TeleWave.Application.Media.ManualInbox;
using TeleWave.Application.Media.Register;
using TeleWave.Application.Media.Stats;
using TeleWave.Domain.Media;
using TeleWave.Infrastructure.Identity;
using TeleWave.Infrastructure.Media;
namespace TeleWave.Api.Endpoints;
public static class MediaEndpoints
{
public static IEndpointRouteBuilder MapMediaEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/media")
.WithTags("Admin.Media")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapPost("", Upload).Produces<UploadMediaResponse>(StatusCodes.Status201Created);
admin.MapGet("", List).Produces<PagedList<MediaAssetDto>>();
admin.MapGet("/stats", Stats).Produces<MediaStatsDto>();
admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent);
// Ручной inbox: сканером не разбирается — файлы выбирает админ и сразу указывает шоу.
admin.MapGet("/manual", ListManual).Produces<ManualInboxListDto>();
admin.MapPost("/manual/import", ImportManual).Produces<ImportManualInboxResultDto>();
// Просмотр обработанного ассета в админке (ролики, проверка серии). Публичная раздача идёт
// по stream-куке, здесь роут под JWT — плейлист и сегменты грузит hls.js с Bearer.
admin.MapGet("/{id:guid}/preview/index.m3u8", PreviewPlaylist);
admin.MapGet("/{id:guid}/preview/{file}", PreviewSegment);
return app;
}
/// <summary>
/// Потоковая загрузка: тело запроса — сырые байты файла, имя передаётся в query «fileName».
/// Файл стримится на диск без буферизации в память, затем регистрируется и уходит в обработку.
/// </summary>
private static async Task<IResult> Upload(
string fileName,
HttpRequest request,
IMediaStorage storage,
IMediaProcessingQueue queue,
ISender sender,
UploadLimits limits,
CancellationToken cancellationToken
)
{
if (string.IsNullOrWhiteSpace(fileName))
return Results.Problem(
title: MediaErrors.EmptyFileName.Code,
detail: MediaErrors.EmptyFileName.Message,
statusCode: StatusCodes.Status400BadRequest
);
if (!MediaFormats.IsAllowed(fileName))
return Results.Problem(
title: MediaErrors.UnsupportedFormat.Code,
detail: MediaErrors.UnsupportedFormat.Message,
statusCode: StatusCodes.Status400BadRequest
);
var contentLength = request.ContentLength ?? 0;
if (contentLength > limits.MaxUploadBytes)
return Results.Problem(
title: MediaErrors.FileTooLarge.Code,
detail: MediaErrors.FileTooLarge.Message,
statusCode: StatusCodes.Status400BadRequest
);
var free = storage.GetAvailableFreeSpaceBytes();
if (free - contentLength < limits.MinFreeSpaceBytes)
return Results.Problem(
title: MediaErrors.InsufficientStorage.Code,
detail: MediaErrors.InsufficientStorage.Message,
statusCode: StatusCodes.Status409Conflict
);
var extension = Path.GetExtension(fileName);
var token = await storage.SaveUploadAsync(request.Body, extension, cancellationToken);
var result = await sender.Send(
new RegisterMediaAssetCommand(token, MediaSource.Upload, fileName),
cancellationToken
);
if (!result.IsSuccess)
{
await storage.DeleteUploadAsync(token, cancellationToken);
return result.ToHttpResult();
}
queue.Enqueue(result.Value);
return Results.Created(
$"/api/admin/media/{result.Value}",
new UploadMediaResponse(result.Value)
);
}
private static async Task<IResult> List(
[AsParameters] ListMediaFilter filter,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ListMediaAssetsQuery(
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
);
return Results.Ok(result);
}
private static async Task<IResult> Stats(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new GetMediaStatsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> Delete(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteMediaAssetCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ListManual(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListManualInboxQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> ImportManual(
ImportManualInboxBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ImportManualInboxCommand(body.Items, body.ShowId),
cancellationToken
);
return result.ToHttpResult();
}
/// <summary>Плейлист ассета: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
private static IResult PreviewPlaylist(Guid id, MediaPathResolver paths)
{
if (SegmentFiles.TryResolveExisting(paths, id, "index.m3u8") is not { } indexPath)
return Results.NotFound();
var baseUrl = $"/api/admin/media/{id}/preview/";
var sb = new StringBuilder();
foreach (var line in File.ReadLines(indexPath))
{
var trimmed = line.Trim();
if (trimmed.Length == 0)
continue;
// Директивы — как есть; строки-сегменты (абсолютный путь от ffmpeg) → admin-URL.
sb.Append(trimmed.StartsWith('#') ? trimmed : baseUrl + Path.GetFileName(trimmed))
.Append('\n');
}
return Results.Text(sb.ToString(), "application/vnd.apple.mpegurl");
}
private static IResult PreviewSegment(Guid id, string file, MediaPathResolver paths)
{
if (!SegmentFiles.IsSegmentName(file))
return Results.NotFound();
if (SegmentFiles.TryResolveExisting(paths, id, file) is not { } path)
return Results.NotFound();
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
}
}
/// <summary>
/// Фильтр и страница списка медиа. Все поля nullable намеренно: обязательный параметр в query
/// заставлял минимальный API отвечать 400 на запросы без него — например, из выборок «все готовые
/// ассеты», которым сортировка не нужна. Умолчания подставляет хендлер, а не объявление: при
/// <c>AsParameters</c> обязательность определяется nullable-типом, а не значением по умолчанию.
/// </summary>
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<ManualImportItem> Items, Guid ShowId);
@@ -1,55 +1,55 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Settings;
using TeleWave.Application.Settings.GetSiteSettings;
using TeleWave.Application.Settings.UpdateSiteSettings;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class SettingsEndpoints
{
public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/settings")
.WithTags("Admin.Settings")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", GetSettings).Produces<SiteSettingsDto>();
admin.MapPut("", UpdateSettings).Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> GetSettings(
ISender sender,
CancellationToken cancellationToken
)
{
var settings = await sender.Send(new GetSiteSettingsQuery(), cancellationToken);
return Results.Ok(settings);
}
private static async Task<IResult> UpdateSettings(
UpdateSiteSettingsBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateSiteSettingsCommand(
body.RegistrationEnabled,
body.PreferredAudioLanguages ?? "",
body.ChannelNumbersEnabled
),
cancellationToken
);
return result.ToHttpResult();
}
}
public sealed record UpdateSiteSettingsBody(
bool RegistrationEnabled,
string? PreferredAudioLanguages,
bool ChannelNumbersEnabled
);
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Settings;
using TeleWave.Application.Settings.GetSiteSettings;
using TeleWave.Application.Settings.UpdateSiteSettings;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class SettingsEndpoints
{
public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/settings")
.WithTags("Admin.Settings")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", GetSettings).Produces<SiteSettingsDto>();
admin.MapPut("", UpdateSettings).Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> GetSettings(
ISender sender,
CancellationToken cancellationToken
)
{
var settings = await sender.Send(new GetSiteSettingsQuery(), cancellationToken);
return Results.Ok(settings);
}
private static async Task<IResult> UpdateSettings(
UpdateSiteSettingsBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateSiteSettingsCommand(
body.RegistrationEnabled,
body.PreferredAudioLanguages ?? "",
body.ChannelNumbersEnabled
),
cancellationToken
);
return result.ToHttpResult();
}
}
public sealed record UpdateSiteSettingsBody(
bool RegistrationEnabled,
string? PreferredAudioLanguages,
bool ChannelNumbersEnabled
);
@@ -1,186 +1,186 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Library;
using TeleWave.Application.Library.AddEpisode;
using TeleWave.Application.Library.CreateShow;
using TeleWave.Application.Library.DeleteShow;
using TeleWave.Application.Library.GetShow;
using TeleWave.Application.Library.ListShows;
using TeleWave.Application.Library.RemoveEpisode;
using TeleWave.Application.Library.RenameShow;
using TeleWave.Application.Library.SetShowAudience;
using TeleWave.Application.Library.SetShowGenres;
using TeleWave.Application.Library.SetShowOriginalName;
using TeleWave.Domain.Library;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class ShowEndpoints
{
public static IEndpointRouteBuilder MapShowEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/shows")
.WithTags("Admin.Shows")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapPost("", CreateShow).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapGet("", ListShows).Produces<IReadOnlyList<ShowSummaryDto>>();
admin.MapGet("/{id:guid}", GetShow).Produces<ShowDto>();
admin.MapPut("/{id:guid}/name", Rename).Produces(StatusCodes.Status204NoContent);
admin
.MapPut("/{id:guid}/original-name", SetOriginalName)
.Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{id:guid}/audience", SetAudience).Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{id:guid}/genres", SetGenres).Produces(StatusCodes.Status204NoContent);
admin.MapDelete("/{id:guid}", DeleteShow).Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{id:guid}/episodes", AddEpisode)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapDelete("/{id:guid}/episodes/{episodeId:guid}", RemoveEpisode)
.Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> CreateShow(
CreateShowCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.IsSuccess
? Results.Created(
$"/api/admin/shows/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> ListShows(
ISender sender,
CancellationToken cancellationToken,
Guid? genreId = null,
bool interstitials = false
)
{
var result = await sender.Send(
new ListShowsQuery(genreId, interstitials),
cancellationToken
);
return Results.Ok(result);
}
private static async Task<IResult> GetShow(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetShowQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> Rename(
Guid id,
RenameShowBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new RenameShowCommand(id, body.Name), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> SetOriginalName(
Guid id,
SetShowOriginalNameBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new SetShowOriginalNameCommand(id, body.OriginalName),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> SetAudience(
Guid id,
SetShowAudienceBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new SetShowAudienceCommand(id, body.Audience),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> SetGenres(
Guid id,
SetShowGenresBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new SetShowGenresCommand(id, body.GenreIds, body.PrimaryGenreId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteShow(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteShowCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> AddEpisode(
Guid id,
AddEpisodeBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new AddEpisodeCommand(id, body.MediaAssetId),
cancellationToken
);
return result.IsSuccess
? Results.Created($"/api/admin/shows/{id}", new CreatedIdResponse(result.Value))
: result.ToHttpResult();
}
private static async Task<IResult> RemoveEpisode(
Guid id,
Guid episodeId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new RemoveEpisodeCommand(id, episodeId), cancellationToken);
return result.ToHttpResult();
}
}
public sealed record AddEpisodeBody(Guid MediaAssetId);
public sealed record RenameShowBody(string Name);
public sealed record SetShowOriginalNameBody(string? OriginalName);
/// <summary>Рейтинг шоу; null — снять проставленный.</summary>
public sealed record SetShowAudienceBody(ShowAudience? Audience);
public sealed record SetShowGenresBody(IReadOnlyList<Guid> GenreIds, Guid? PrimaryGenreId);
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Library;
using TeleWave.Application.Library.AddEpisode;
using TeleWave.Application.Library.CreateShow;
using TeleWave.Application.Library.DeleteShow;
using TeleWave.Application.Library.GetShow;
using TeleWave.Application.Library.ListShows;
using TeleWave.Application.Library.RemoveEpisode;
using TeleWave.Application.Library.RenameShow;
using TeleWave.Application.Library.SetShowAudience;
using TeleWave.Application.Library.SetShowGenres;
using TeleWave.Application.Library.SetShowOriginalName;
using TeleWave.Domain.Library;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class ShowEndpoints
{
public static IEndpointRouteBuilder MapShowEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/shows")
.WithTags("Admin.Shows")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapPost("", CreateShow).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapGet("", ListShows).Produces<IReadOnlyList<ShowSummaryDto>>();
admin.MapGet("/{id:guid}", GetShow).Produces<ShowDto>();
admin.MapPut("/{id:guid}/name", Rename).Produces(StatusCodes.Status204NoContent);
admin
.MapPut("/{id:guid}/original-name", SetOriginalName)
.Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{id:guid}/audience", SetAudience).Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{id:guid}/genres", SetGenres).Produces(StatusCodes.Status204NoContent);
admin.MapDelete("/{id:guid}", DeleteShow).Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{id:guid}/episodes", AddEpisode)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapDelete("/{id:guid}/episodes/{episodeId:guid}", RemoveEpisode)
.Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> CreateShow(
CreateShowCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.IsSuccess
? Results.Created(
$"/api/admin/shows/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> ListShows(
ISender sender,
CancellationToken cancellationToken,
Guid? genreId = null,
bool interstitials = false
)
{
var result = await sender.Send(
new ListShowsQuery(genreId, interstitials),
cancellationToken
);
return Results.Ok(result);
}
private static async Task<IResult> GetShow(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetShowQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> Rename(
Guid id,
RenameShowBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new RenameShowCommand(id, body.Name), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> SetOriginalName(
Guid id,
SetShowOriginalNameBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new SetShowOriginalNameCommand(id, body.OriginalName),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> SetAudience(
Guid id,
SetShowAudienceBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new SetShowAudienceCommand(id, body.Audience),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> SetGenres(
Guid id,
SetShowGenresBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new SetShowGenresCommand(id, body.GenreIds, body.PrimaryGenreId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteShow(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteShowCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> AddEpisode(
Guid id,
AddEpisodeBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new AddEpisodeCommand(id, body.MediaAssetId),
cancellationToken
);
return result.IsSuccess
? Results.Created($"/api/admin/shows/{id}", new CreatedIdResponse(result.Value))
: result.ToHttpResult();
}
private static async Task<IResult> RemoveEpisode(
Guid id,
Guid episodeId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new RemoveEpisodeCommand(id, episodeId), cancellationToken);
return result.ToHttpResult();
}
}
public sealed record AddEpisodeBody(Guid MediaAssetId);
public sealed record RenameShowBody(string Name);
public sealed record SetShowOriginalNameBody(string? OriginalName);
/// <summary>Рейтинг шоу; null — снять проставленный.</summary>
public sealed record SetShowAudienceBody(ShowAudience? Audience);
public sealed record SetShowGenresBody(IReadOnlyList<Guid> GenreIds, Guid? PrimaryGenreId);
@@ -1,191 +1,191 @@
using System.Globalization;
using System.Text;
using LiteCqrs;
using Microsoft.Extensions.Hosting;
using TeleWave.Api.Common;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Streaming;
using TeleWave.Application.Streaming.GetLivePlaylist;
using TeleWave.Application.Streaming.GetPublicEpg;
using TeleWave.Application.Streaming.ListPublicChannels;
using TeleWave.Infrastructure.Media;
using TeleWave.Infrastructure.Streaming;
namespace TeleWave.Api.Endpoints;
public static class StreamingEndpoints
{
private const string StreamCookieName = "tw_stream";
public static IEndpointRouteBuilder MapStreamingEndpoints(this IEndpointRouteBuilder app)
{
// Публичный API канала (Bearer): список, EPG, выдача stream-cookie.
var channels = app.MapGroup("/api/channels").WithTags("Channels").RequireAuthorization();
channels.MapGet("", ListChannels).Produces<IReadOnlyList<PublicChannelDto>>();
// Что включено на стороне зрителя: сейчас только переключение по номерам (см. 6.8).
channels.MapGet("/features", ViewerFeatures).Produces<ViewerFeaturesDto>();
channels.MapPost("/{slug}/watch", Watch).Produces(StatusCodes.Status204NoContent);
channels.MapGet("/{slug}/epg", Epg);
// Раздача эфира (cookie tw_stream): плейлист и сегменты — их грузит <video>/hls.js.
app.MapGet("/api/channels/{slug}/live.m3u8", LivePlaylist).WithTags("Streaming");
app.MapGet("/api/stream/{assetId:guid}/{file}", Segment).WithTags("Streaming");
return app;
}
private static async Task<IResult> ListChannels(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListPublicChannelsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> ViewerFeatures(
ISiteSettings siteSettings,
CancellationToken cancellationToken
) =>
Results.Ok(
new ViewerFeaturesDto(
await siteSettings.AreChannelNumbersEnabledAsync(cancellationToken)
)
);
/// <summary>
/// Выдаёт cookie доступа к эфиру. Канал в маршруте есть для симметрии с остальными
/// эндпоинтами, но токен не привязан к каналу — он подтверждает зрителя, а не подписку на
/// конкретную ленту, поэтому в сигнатуре slug не нужен.
/// </summary>
private static IResult Watch(
ICurrentUser currentUser,
StreamTokenService tokens,
HttpRequest request,
HttpResponse response,
IHostEnvironment env
)
{
if (currentUser.UserId is not { } userId)
return Results.Unauthorized();
var (token, expiresAt) = tokens.Issue(userId);
response.Cookies.Append(
StreamCookieName,
token,
new CookieOptions
{
HttpOnly = true,
// Вне Development — всегда Secure (прод за внешним TLS-прокси; request.IsHttps ненадёжен
// при неполной настройке ForwardedHeaders). См. UseSecureCookie в AuthEndpoints.
Secure = !env.IsDevelopment() || request.IsHttps,
SameSite = SameSiteMode.Strict,
Path = "/api",
Expires = expiresAt,
}
);
return Results.NoContent();
}
private static async Task<IResult> Epg(
string slug,
DateTimeOffset? from,
DateTimeOffset? to,
ISender sender,
CancellationToken cancellationToken
)
{
var fromUtc = from ?? DateTimeOffset.UtcNow;
var toUtc = to ?? fromUtc.AddHours(12);
var result = await sender.Send(
new GetPublicEpgQuery(slug, fromUtc, toUtc),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> LivePlaylist(
string slug,
HttpRequest request,
HttpResponse response,
StreamTokenService tokens,
IIdentityService identity,
ISender sender,
CancellationToken cancellationToken
)
{
// Плейлист hls.js перезагружает регулярно — здесь дёшево (1 запрос на перезагрузку) сверить,
// что зритель из токена ещё существует и не заблокирован. Так блокировка отражается почти сразу,
// не дожидаясь истечения короткого TTL cookie; сегменты этой проверки не делают (слишком часто).
if (tokens.Validate(request.Cookies[StreamCookieName]) is not { } userId)
return Results.Unauthorized();
var profile = await identity.GetProfileAsync(userId, cancellationToken);
if (profile is null || profile.IsBlocked)
return Results.Unauthorized();
var result = await sender.Send(
new GetLivePlaylistQuery(slug, DateTimeOffset.UtcNow),
cancellationToken
);
if (!result.IsSuccess)
return result.ToHttpResult();
if (result.Value.Segments.Count == 0)
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
response.Headers.CacheControl = "no-cache";
return Results.Text(Render(result.Value), "application/vnd.apple.mpegurl");
}
private static IResult Segment(
Guid assetId,
string file,
HttpRequest request,
HttpResponse response,
StreamTokenService tokens,
MediaPathResolver paths
)
{
if (tokens.Validate(request.Cookies[StreamCookieName]) is null)
return Results.Unauthorized();
if (!SegmentFiles.IsSegmentName(file))
return Results.NotFound();
if (SegmentFiles.TryResolveExisting(paths, assetId, file) is not { } path)
return Results.NotFound();
response.Headers.CacheControl = "public, max-age=31536000, immutable";
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
}
private static string Render(LivePlaylistDto playlist)
{
var extinf = playlist.TargetDuration.ToString("F6", CultureInfo.InvariantCulture);
var sb = new StringBuilder();
sb.Append("#EXTM3U\n");
sb.Append("#EXT-X-VERSION:3\n");
sb.Append(
CultureInfo.InvariantCulture,
$"#EXT-X-TARGETDURATION:{playlist.TargetDuration}\n"
);
sb.Append(
CultureInfo.InvariantCulture,
$"#EXT-X-MEDIA-SEQUENCE:{playlist.MediaSequence}\n"
);
foreach (var segment in playlist.Segments)
{
if (segment.Discontinuity)
sb.Append("#EXT-X-DISCONTINUITY\n");
sb.Append(CultureInfo.InvariantCulture, $"#EXTINF:{extinf},\n");
sb.Append(
CultureInfo.InvariantCulture,
$"/api/stream/{segment.AssetId:N}/seg{segment.LocalIndex:D5}.ts\n"
);
}
return sb.ToString();
}
}
/// <summary>Опции зрительской части, включённые глобально.</summary>
public sealed record ViewerFeaturesDto(bool ChannelNumbersEnabled);
using System.Globalization;
using System.Text;
using LiteCqrs;
using Microsoft.Extensions.Hosting;
using TeleWave.Api.Common;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Streaming;
using TeleWave.Application.Streaming.GetLivePlaylist;
using TeleWave.Application.Streaming.GetPublicEpg;
using TeleWave.Application.Streaming.ListPublicChannels;
using TeleWave.Infrastructure.Media;
using TeleWave.Infrastructure.Streaming;
namespace TeleWave.Api.Endpoints;
public static class StreamingEndpoints
{
private const string StreamCookieName = "tw_stream";
public static IEndpointRouteBuilder MapStreamingEndpoints(this IEndpointRouteBuilder app)
{
// Публичный API канала (Bearer): список, EPG, выдача stream-cookie.
var channels = app.MapGroup("/api/channels").WithTags("Channels").RequireAuthorization();
channels.MapGet("", ListChannels).Produces<IReadOnlyList<PublicChannelDto>>();
// Что включено на стороне зрителя: сейчас только переключение по номерам (см. 6.8).
channels.MapGet("/features", ViewerFeatures).Produces<ViewerFeaturesDto>();
channels.MapPost("/{slug}/watch", Watch).Produces(StatusCodes.Status204NoContent);
channels.MapGet("/{slug}/epg", Epg);
// Раздача эфира (cookie tw_stream): плейлист и сегменты — их грузит <video>/hls.js.
app.MapGet("/api/channels/{slug}/live.m3u8", LivePlaylist).WithTags("Streaming");
app.MapGet("/api/stream/{assetId:guid}/{file}", Segment).WithTags("Streaming");
return app;
}
private static async Task<IResult> ListChannels(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListPublicChannelsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> ViewerFeatures(
ISiteSettings siteSettings,
CancellationToken cancellationToken
) =>
Results.Ok(
new ViewerFeaturesDto(
await siteSettings.AreChannelNumbersEnabledAsync(cancellationToken)
)
);
/// <summary>
/// Выдаёт cookie доступа к эфиру. Канал в маршруте есть для симметрии с остальными
/// эндпоинтами, но токен не привязан к каналу — он подтверждает зрителя, а не подписку на
/// конкретную ленту, поэтому в сигнатуре slug не нужен.
/// </summary>
private static IResult Watch(
ICurrentUser currentUser,
StreamTokenService tokens,
HttpRequest request,
HttpResponse response,
IHostEnvironment env
)
{
if (currentUser.UserId is not { } userId)
return Results.Unauthorized();
var (token, expiresAt) = tokens.Issue(userId);
response.Cookies.Append(
StreamCookieName,
token,
new CookieOptions
{
HttpOnly = true,
// Вне Development — всегда Secure (прод за внешним TLS-прокси; request.IsHttps ненадёжен
// при неполной настройке ForwardedHeaders). См. UseSecureCookie в AuthEndpoints.
Secure = !env.IsDevelopment() || request.IsHttps,
SameSite = SameSiteMode.Strict,
Path = "/api",
Expires = expiresAt,
}
);
return Results.NoContent();
}
private static async Task<IResult> Epg(
string slug,
DateTimeOffset? from,
DateTimeOffset? to,
ISender sender,
CancellationToken cancellationToken
)
{
var fromUtc = from ?? DateTimeOffset.UtcNow;
var toUtc = to ?? fromUtc.AddHours(12);
var result = await sender.Send(
new GetPublicEpgQuery(slug, fromUtc, toUtc),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> LivePlaylist(
string slug,
HttpRequest request,
HttpResponse response,
StreamTokenService tokens,
IIdentityService identity,
ISender sender,
CancellationToken cancellationToken
)
{
// Плейлист hls.js перезагружает регулярно — здесь дёшево (1 запрос на перезагрузку) сверить,
// что зритель из токена ещё существует и не заблокирован. Так блокировка отражается почти сразу,
// не дожидаясь истечения короткого TTL cookie; сегменты этой проверки не делают (слишком часто).
if (tokens.Validate(request.Cookies[StreamCookieName]) is not { } userId)
return Results.Unauthorized();
var profile = await identity.GetProfileAsync(userId, cancellationToken);
if (profile is null || profile.IsBlocked)
return Results.Unauthorized();
var result = await sender.Send(
new GetLivePlaylistQuery(slug, DateTimeOffset.UtcNow),
cancellationToken
);
if (!result.IsSuccess)
return result.ToHttpResult();
if (result.Value.Segments.Count == 0)
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
response.Headers.CacheControl = "no-cache";
return Results.Text(Render(result.Value), "application/vnd.apple.mpegurl");
}
private static IResult Segment(
Guid assetId,
string file,
HttpRequest request,
HttpResponse response,
StreamTokenService tokens,
MediaPathResolver paths
)
{
if (tokens.Validate(request.Cookies[StreamCookieName]) is null)
return Results.Unauthorized();
if (!SegmentFiles.IsSegmentName(file))
return Results.NotFound();
if (SegmentFiles.TryResolveExisting(paths, assetId, file) is not { } path)
return Results.NotFound();
response.Headers.CacheControl = "public, max-age=31536000, immutable";
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
}
private static string Render(LivePlaylistDto playlist)
{
var extinf = playlist.TargetDuration.ToString("F6", CultureInfo.InvariantCulture);
var sb = new StringBuilder();
sb.Append("#EXTM3U\n");
sb.Append("#EXT-X-VERSION:3\n");
sb.Append(
CultureInfo.InvariantCulture,
$"#EXT-X-TARGETDURATION:{playlist.TargetDuration}\n"
);
sb.Append(
CultureInfo.InvariantCulture,
$"#EXT-X-MEDIA-SEQUENCE:{playlist.MediaSequence}\n"
);
foreach (var segment in playlist.Segments)
{
if (segment.Discontinuity)
sb.Append("#EXT-X-DISCONTINUITY\n");
sb.Append(CultureInfo.InvariantCulture, $"#EXTINF:{extinf},\n");
sb.Append(
CultureInfo.InvariantCulture,
$"/api/stream/{segment.AssetId:N}/seg{segment.LocalIndex:D5}.ts\n"
);
}
return sb.ToString();
}
}
/// <summary>Опции зрительской части, включённые глобально.</summary>
public sealed record ViewerFeaturesDto(bool ChannelNumbersEnabled);
@@ -1,296 +1,296 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Programming.Planning.ApplyTemplate;
using TeleWave.Application.Programming.Planning.Diff;
using TeleWave.Application.Programming.Planning.Preview;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Programming.Templates.CopyTemplate;
using TeleWave.Application.Programming.Templates.CreateSlot;
using TeleWave.Application.Programming.Templates.CreateTemplate;
using TeleWave.Application.Programming.Templates.DeleteSlot;
using TeleWave.Application.Programming.Templates.GetTemplate;
using TeleWave.Application.Programming.Templates.Layers;
using TeleWave.Application.Programming.Templates.UpdateSlot;
using TeleWave.Application.Programming.Templates.Validate;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
/// <summary>
/// Сетка канала: шаблон, слои, слоты. Правка ничего не двигает в эфире — она помечает шаблон
/// изменённым, а хвост пересобирается отдельной командой применения.
/// </summary>
public static class TemplateEndpoints
{
public static IEndpointRouteBuilder MapTemplateEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin")
.WithTags("Admin.Templates")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin
.MapGet("/channels/{channelId:guid}/template", GetTemplate)
.Produces<ScheduleTemplateDto>();
// Завести сетку каналу, у которого её нет (напр. пережившему снос старой ротации).
admin
.MapPost("/channels/{channelId:guid}/template", CreateTemplate)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapPut("/templates/{templateId:guid}", UpdateTemplate)
.Produces(StatusCodes.Status204NoContent);
// Применение правил к эфиру — отдельным действием: правка слотов эфир не двигает.
admin
.MapPost("/channels/{channelId:guid}/template/apply", ApplyTemplate)
.Produces<ApplyResultDto>();
// Предпросмотр — тот же генератор, но без записи и без продвижения курсоров.
admin
.MapGet("/channels/{channelId:guid}/template/preview", PreviewTemplate)
.Produces<SchedulePreviewDto>();
// Проверки по правилам — только по шаблону, без прогона генератора.
admin
.MapGet("/channels/{channelId:guid}/template/issues", ValidateTemplate)
.Produces<IReadOnlyList<TemplateIssueDto>>();
// Что изменится в эфире, если применить прямо сейчас.
admin
.MapGet("/channels/{channelId:guid}/template/diff", DiffTemplate)
.Produces<ScheduleDiffDto>();
admin
.MapPost(
"/channels/{channelId:guid}/template/copy-to/{targetChannelId:guid}",
CopyTemplate
)
.Produces<CopyTemplateResultDto>();
admin
.MapPost("/templates/{templateId:guid}/layers", CreateLayer)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapPut("/layers/{layerId:guid}", UpdateLayer)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/layers/{layerId:guid}", DeleteLayer)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/layers/{layerId:guid}/slots", CreateSlot)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapPut("/slots/{slotId:guid}", UpdateSlot).Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/slots/{slotId:guid}", DeleteSlot)
.Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> GetTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetChannelTemplateQuery(channelId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new CreateChannelTemplateCommand(channelId),
cancellationToken
);
return result.IsSuccess
? Results.Created(
$"/api/admin/channels/{channelId}/template",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> ApplyTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ApplyChannelTemplateCommand(channelId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> PreviewTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken,
int days = 1
)
{
var result = await sender.Send(
new PreviewScheduleQuery(channelId, days),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> ValidateTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ValidateTemplateQuery(channelId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DiffTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new PreviewApplyDiffQuery(channelId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CopyTemplate(
Guid channelId,
Guid targetChannelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new CopyTemplateCommand(channelId, targetChannelId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateTemplate(
Guid templateId,
UpdateTemplateBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateTemplateCommand(
templateId,
body.Name,
body.FallbackGroupId,
body.DefaultJunctionId,
body.Rules
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> CreateLayer(
Guid templateId,
CreateLayerBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new CreateLayerCommand(templateId, body.Name, body.Priority),
cancellationToken
);
return result.IsSuccess
? Results.Created(
$"/api/admin/layers/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> UpdateLayer(
Guid layerId,
UpdateLayerBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateLayerCommand(
layerId,
body.Name,
body.Priority,
body.Applicability,
body.IsEnabled
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteLayer(
Guid layerId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteLayerCommand(layerId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateSlot(
Guid layerId,
SlotInput input,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new CreateSlotCommand(layerId, input), cancellationToken);
return result.IsSuccess
? Results.Created(
$"/api/admin/slots/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> UpdateSlot(
Guid slotId,
SlotInput input,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new UpdateSlotCommand(slotId, input), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteSlot(
Guid slotId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteSlotCommand(slotId), cancellationToken);
return result.ToHttpResult();
}
}
public sealed record UpdateTemplateBody(
string Name,
Guid? FallbackGroupId,
Guid? DefaultJunctionId,
PlanningRules? Rules
);
public sealed record CreateLayerBody(string Name, int Priority);
public sealed record UpdateLayerBody(
string Name,
int Priority,
LayerApplicability? Applicability,
bool IsEnabled
);
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Programming.Planning.ApplyTemplate;
using TeleWave.Application.Programming.Planning.Diff;
using TeleWave.Application.Programming.Planning.Preview;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Programming.Templates.CopyTemplate;
using TeleWave.Application.Programming.Templates.CreateSlot;
using TeleWave.Application.Programming.Templates.CreateTemplate;
using TeleWave.Application.Programming.Templates.DeleteSlot;
using TeleWave.Application.Programming.Templates.GetTemplate;
using TeleWave.Application.Programming.Templates.Layers;
using TeleWave.Application.Programming.Templates.UpdateSlot;
using TeleWave.Application.Programming.Templates.Validate;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
/// <summary>
/// Сетка канала: шаблон, слои, слоты. Правка ничего не двигает в эфире — она помечает шаблон
/// изменённым, а хвост пересобирается отдельной командой применения.
/// </summary>
public static class TemplateEndpoints
{
public static IEndpointRouteBuilder MapTemplateEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin")
.WithTags("Admin.Templates")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin
.MapGet("/channels/{channelId:guid}/template", GetTemplate)
.Produces<ScheduleTemplateDto>();
// Завести сетку каналу, у которого её нет (напр. пережившему снос старой ротации).
admin
.MapPost("/channels/{channelId:guid}/template", CreateTemplate)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapPut("/templates/{templateId:guid}", UpdateTemplate)
.Produces(StatusCodes.Status204NoContent);
// Применение правил к эфиру — отдельным действием: правка слотов эфир не двигает.
admin
.MapPost("/channels/{channelId:guid}/template/apply", ApplyTemplate)
.Produces<ApplyResultDto>();
// Предпросмотр — тот же генератор, но без записи и без продвижения курсоров.
admin
.MapGet("/channels/{channelId:guid}/template/preview", PreviewTemplate)
.Produces<SchedulePreviewDto>();
// Проверки по правилам — только по шаблону, без прогона генератора.
admin
.MapGet("/channels/{channelId:guid}/template/issues", ValidateTemplate)
.Produces<IReadOnlyList<TemplateIssueDto>>();
// Что изменится в эфире, если применить прямо сейчас.
admin
.MapGet("/channels/{channelId:guid}/template/diff", DiffTemplate)
.Produces<ScheduleDiffDto>();
admin
.MapPost(
"/channels/{channelId:guid}/template/copy-to/{targetChannelId:guid}",
CopyTemplate
)
.Produces<CopyTemplateResultDto>();
admin
.MapPost("/templates/{templateId:guid}/layers", CreateLayer)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapPut("/layers/{layerId:guid}", UpdateLayer)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/layers/{layerId:guid}", DeleteLayer)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/layers/{layerId:guid}/slots", CreateSlot)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapPut("/slots/{slotId:guid}", UpdateSlot).Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/slots/{slotId:guid}", DeleteSlot)
.Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> GetTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetChannelTemplateQuery(channelId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new CreateChannelTemplateCommand(channelId),
cancellationToken
);
return result.IsSuccess
? Results.Created(
$"/api/admin/channels/{channelId}/template",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> ApplyTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ApplyChannelTemplateCommand(channelId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> PreviewTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken,
int days = 1
)
{
var result = await sender.Send(
new PreviewScheduleQuery(channelId, days),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> ValidateTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ValidateTemplateQuery(channelId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DiffTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new PreviewApplyDiffQuery(channelId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CopyTemplate(
Guid channelId,
Guid targetChannelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new CopyTemplateCommand(channelId, targetChannelId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateTemplate(
Guid templateId,
UpdateTemplateBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateTemplateCommand(
templateId,
body.Name,
body.FallbackGroupId,
body.DefaultJunctionId,
body.Rules
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> CreateLayer(
Guid templateId,
CreateLayerBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new CreateLayerCommand(templateId, body.Name, body.Priority),
cancellationToken
);
return result.IsSuccess
? Results.Created(
$"/api/admin/layers/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> UpdateLayer(
Guid layerId,
UpdateLayerBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateLayerCommand(
layerId,
body.Name,
body.Priority,
body.Applicability,
body.IsEnabled
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteLayer(
Guid layerId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteLayerCommand(layerId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateSlot(
Guid layerId,
SlotInput input,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new CreateSlotCommand(layerId, input), cancellationToken);
return result.IsSuccess
? Results.Created(
$"/api/admin/slots/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> UpdateSlot(
Guid slotId,
SlotInput input,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new UpdateSlotCommand(slotId, input), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteSlot(
Guid slotId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteSlotCommand(slotId), cancellationToken);
return result.ToHttpResult();
}
}
public sealed record UpdateTemplateBody(
string Name,
Guid? FallbackGroupId,
Guid? DefaultJunctionId,
PlanningRules? Rules
);
public sealed record CreateLayerBody(string Name, int Priority);
public sealed record UpdateLayerBody(
string Name,
int Priority,
LayerApplicability? Applicability,
bool IsEnabled
);
+148 -148
View File
@@ -1,148 +1,148 @@
using System.Net;
using System.Text.Json.Serialization;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Scalar.AspNetCore;
using Serilog;
using TeleWave.Api.Common;
using TeleWave.Api.Endpoints;
using TeleWave.Application;
using TeleWave.Infrastructure;
using TeleWave.Infrastructure.Identity;
using TeleWave.Infrastructure.Persistence;
var builder = WebApplication.CreateBuilder(args);
// Загрузка медиа стримится на диск; поднимаем лимит тела запроса Kestrel до максимума загрузки
// (иначе дефолтные ~30 МБ рубят большие файлы). Собственный контроль размера — в MediaEndpoints.
builder.WebHost.ConfigureKestrel(options =>
options.Limits.MaxRequestBodySize =
builder.Configuration.GetValue<long?>("Media:MaxUploadBytes") ?? 20L * 1024 * 1024 * 1024
);
// Структурное логирование (Serilog), конфигурация из appsettings/env.
builder.Services.AddSerilog(
(services, configuration) =>
configuration
.ReadFrom.Configuration(builder.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
);
// За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера), но ТОЛЬКО от явно
// перечисленных адресов/сетей прокси — иначе клиент может подделать свой IP/схему напрямую, минуя
// прокси. По умолчанию (без конфигурации) остаётся дефолт ASP.NET Core — доверие только loopback,
// а для прод-топологии прокси задаётся через ForwardedHeaders__KnownProxies/KnownNetworks (см. .env.example).
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
foreach (
var proxy in builder
.Configuration.GetSection("ForwardedHeaders:KnownProxies")
.Get<string[]>()
?? []
)
options.KnownProxies.Add(IPAddress.Parse(proxy));
foreach (
var network in builder
.Configuration.GetSection("ForwardedHeaders:KnownNetworks")
.Get<string[]>()
?? []
)
{
var parts = network.Split('/');
options.KnownIPNetworks.Add(
new System.Net.IPNetwork(IPAddress.Parse(parts[0]), int.Parse(parts[1]))
);
}
});
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<UploadLimits>();
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
var authPermitLimit = builder.Configuration.GetValue("RateLimiting:AuthPermitLimit", 20);
builder.Services.AddRateLimiter(options =>
{
// Партиционируем по IP клиента (реальный адрес доступен после UseForwardedHeaders): единый
// непартиционированный лимит превращается в DoS — один клиент исчерпывает окно логина для всех.
options.AddPolicy(
RateLimiting.AuthPolicy,
httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = authPermitLimit,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
}
)
);
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});
// Энумы сериализуются строками, не числами — самодокументируемый JSON.
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())
);
builder.Services.AddProblemDetails();
builder.Services.AddOpenApi();
builder.Services.AddHealthChecks().AddDbContextCheck<AppDbContext>();
var app = builder.Build();
// Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте.
await app.Services.ApplyMigrationsAsync();
await app.Services.SeedDataAsync();
app.UseForwardedHeaders();
app.UseSerilogRequestLogging();
app.UseExceptionHandler();
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
// Схему/UI API публикуем не в проде (или явным флагом Api:EnableOpenApi=true) — чтобы в продакшене
// не раскрывать полную карту эндпоинтов без необходимости.
if (app.Environment.IsDevelopment() || app.Configuration.GetValue("Api:EnableOpenApi", false))
{
app.MapOpenApi();
app.MapScalarApiReference();
}
app.MapHealthChecks("/health");
app.MapAuthEndpoints();
app.MapRoleEndpoints();
app.MapAdminUserEndpoints();
app.MapMediaEndpoints();
app.MapShowEndpoints();
app.MapGenreEndpoints();
app.MapInterstitialEndpoints();
app.MapCollectionEndpoints();
app.MapGroupEndpoints();
app.MapTemplateEndpoints();
app.MapJunctionEndpoints();
app.MapChannelEndpoints();
app.MapStreamingEndpoints();
app.MapMaintenanceEndpoints();
app.MapSettingsEndpoints();
app.MapMetadataEndpoints();
app.MapImageEndpoints();
// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов.
app.UseDefaultFiles();
app.UseStaticFiles();
app.MapFallbackToFile("index.html");
// Раньше здесь объявлялся публичный partial-класс Program, чтобы WebApplicationTestFactory видела
// сгенерированный класс. В ASP.NET Core 10 он и так публичный (ASP0027), объявление стало лишним.
await app.RunAsync();
using System.Net;
using System.Text.Json.Serialization;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Scalar.AspNetCore;
using Serilog;
using TeleWave.Api.Common;
using TeleWave.Api.Endpoints;
using TeleWave.Application;
using TeleWave.Infrastructure;
using TeleWave.Infrastructure.Identity;
using TeleWave.Infrastructure.Persistence;
var builder = WebApplication.CreateBuilder(args);
// Загрузка медиа стримится на диск; поднимаем лимит тела запроса Kestrel до максимума загрузки
// (иначе дефолтные ~30 МБ рубят большие файлы). Собственный контроль размера — в MediaEndpoints.
builder.WebHost.ConfigureKestrel(options =>
options.Limits.MaxRequestBodySize =
builder.Configuration.GetValue<long?>("Media:MaxUploadBytes") ?? 20L * 1024 * 1024 * 1024
);
// Структурное логирование (Serilog), конфигурация из appsettings/env.
builder.Services.AddSerilog(
(services, configuration) =>
configuration
.ReadFrom.Configuration(builder.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
);
// За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера), но ТОЛЬКО от явно
// перечисленных адресов/сетей прокси — иначе клиент может подделать свой IP/схему напрямую, минуя
// прокси. По умолчанию (без конфигурации) остаётся дефолт ASP.NET Core — доверие только loopback,
// а для прод-топологии прокси задаётся через ForwardedHeaders__KnownProxies/KnownNetworks (см. .env.example).
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
foreach (
var proxy in builder
.Configuration.GetSection("ForwardedHeaders:KnownProxies")
.Get<string[]>()
?? []
)
options.KnownProxies.Add(IPAddress.Parse(proxy));
foreach (
var network in builder
.Configuration.GetSection("ForwardedHeaders:KnownNetworks")
.Get<string[]>()
?? []
)
{
var parts = network.Split('/');
options.KnownIPNetworks.Add(
new System.Net.IPNetwork(IPAddress.Parse(parts[0]), int.Parse(parts[1]))
);
}
});
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<UploadLimits>();
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
var authPermitLimit = builder.Configuration.GetValue("RateLimiting:AuthPermitLimit", 20);
builder.Services.AddRateLimiter(options =>
{
// Партиционируем по IP клиента (реальный адрес доступен после UseForwardedHeaders): единый
// непартиционированный лимит превращается в DoS — один клиент исчерпывает окно логина для всех.
options.AddPolicy(
RateLimiting.AuthPolicy,
httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = authPermitLimit,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
}
)
);
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});
// Энумы сериализуются строками, не числами — самодокументируемый JSON.
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())
);
builder.Services.AddProblemDetails();
builder.Services.AddOpenApi();
builder.Services.AddHealthChecks().AddDbContextCheck<AppDbContext>();
var app = builder.Build();
// Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте.
await app.Services.ApplyMigrationsAsync();
await app.Services.SeedDataAsync();
app.UseForwardedHeaders();
app.UseSerilogRequestLogging();
app.UseExceptionHandler();
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
// Схему/UI API публикуем не в проде (или явным флагом Api:EnableOpenApi=true) — чтобы в продакшене
// не раскрывать полную карту эндпоинтов без необходимости.
if (app.Environment.IsDevelopment() || app.Configuration.GetValue("Api:EnableOpenApi", false))
{
app.MapOpenApi();
app.MapScalarApiReference();
}
app.MapHealthChecks("/health");
app.MapAuthEndpoints();
app.MapRoleEndpoints();
app.MapAdminUserEndpoints();
app.MapMediaEndpoints();
app.MapShowEndpoints();
app.MapGenreEndpoints();
app.MapInterstitialEndpoints();
app.MapCollectionEndpoints();
app.MapGroupEndpoints();
app.MapTemplateEndpoints();
app.MapJunctionEndpoints();
app.MapChannelEndpoints();
app.MapStreamingEndpoints();
app.MapMaintenanceEndpoints();
app.MapSettingsEndpoints();
app.MapMetadataEndpoints();
app.MapImageEndpoints();
// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов.
app.UseDefaultFiles();
app.UseStaticFiles();
app.MapFallbackToFile("index.html");
// Раньше здесь объявлялся публичный partial-класс Program, чтобы WebApplicationTestFactory видела
// сгенерированный класс. В ASP.NET Core 10 он и так публичный (ASP0027), объявление стало лишним.
await app.RunAsync();