diff --git a/REVIEW.md b/REVIEW.md index 8ac4e4e..0255e01 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -29,44 +29,44 @@ ## 🟠 Среднее ### Безопасность -- [ ] **M1. Cookie без `Secure` за TLS-прокси** — `AuthEndpoints.cs:207`, `StreamingEndpoints.cs:63`. -- [ ] **M2. Ключи TMDb/OMDb в логах** — `TmdbMetadataProvider.cs:27`, `OmdbMetadataProvider.cs:25` (`System.Net.Http` не приглушён в Serilog). -- [ ] **M3. Stream-токен неотзывной, TTL 6ч, игнорирует userId/блокировку** — `StreamTokenService.cs:15`, `StreamingEndpoints.cs:98,123`. -- [ ] **M4. Rate-limiter глобальный (не партиционирован), только на `/api/auth`** — `Program.cs:66`. +- [x] **M1. Cookie без `Secure` за TLS-прокси** — `AuthEndpoints.cs:207`, `StreamingEndpoints.cs:63`. +- [x] **M2. Ключи TMDb/OMDb в логах** — `TmdbMetadataProvider.cs:27`, `OmdbMetadataProvider.cs:25` (`System.Net.Http` не приглушён в Serilog). +- [ ] **M3. Stream-токен неотзывной, TTL 6ч, игнорирует userId/блокировку** — `StreamTokenService.cs:15`, `StreamingEndpoints.cs:98,123`. _(дизайн-решение — отложено)_ +- [x] **M4. Rate-limiter глобальный (не партиционирован), только на `/api/auth`** — `Program.cs:66`. ### Архитектура / транзакции -- [ ] **M5. `ExecuteDeleteAsync` ломает границу UnitOfWork + файлы удаляются до коммита** — `DeleteShowMediaCommandHandler.cs:36`, `ClearAllMediaCommandHandler.cs`, `DeleteAllShowsCommandHandler.cs`. -- [ ] **M6. Query с побочным эффектом на ФС** — `RenderBumperPreviewQueryHandler.cs:18` (рендер файлов под видом запроса). -- [ ] **M7. Файловый/HTTP I/O до коммита** — Delete/Register/RefreshEpisodes хендлеры (orphan-файлы при откате). +- [ ] **M5. `ExecuteDeleteAsync` ломает границу UnitOfWork + файлы удаляются до коммита** — `DeleteShowMediaCommandHandler.cs:36`, `ClearAllMediaCommandHandler.cs`, `DeleteAllShowsCommandHandler.cs`. _(дизайн-решение — отложено)_ +- [ ] **M6. Query с побочным эффектом на ФС** — `RenderBumperPreviewQueryHandler.cs:18` (рендер файлов под видом запроса). _(дизайн-решение — отложено)_ +- [ ] **M7. Файловый/HTTP I/O до коммита** — Delete/Register/RefreshEpisodes хендлеры (orphan-файлы при откате). _(дизайн-решение — отложено)_ ### Планировщик / медиа -- [ ] **M8. Нет таймаута на ffmpeg/ffprobe** — `ProcessRunner.cs:63` (зависший процесс держит слот). -- [ ] **M9. Гонка при конкурентной генерации расписания канала** — `ScheduleGenerator.cs:62` (тик + regenerate → дубли записей). -- [ ] **M10. Рендер заставок синхронно внутри тика планировщика** — `ScheduleBumperResolver.cs:174`. -- [ ] **M11. Override через полночь не работает** — `SchedulePlannerModels.cs:40` (`EndMinute <= StartMinute` → пустое окно). +- [ ] **M8. Нет таймаута на ffmpeg/ffprobe** — `ProcessRunner.cs:63` (зависший процесс держит слот). _(нужен разумный таймаут — отложено на согласование)_ +- [ ] **M9. Гонка при конкурентной генерации расписания канала** — `ScheduleGenerator.cs:62` (тик + regenerate → дубли записей). _(дизайн-решение — отложено)_ +- [ ] **M10. Рендер заставок синхронно внутри тика планировщика** — `ScheduleBumperResolver.cs:174`. _(дизайн-решение — отложено)_ +- [x] **M11. Override через полночь не работает** — `SchedulePlannerModels.cs:40` (`EndMinute <= StartMinute` → пустое окно). ### Фронтенд -- [ ] **M12. Молчаливое проглатывание ошибки → вечный скелетон** — `AirPage.tsx:43`. -- [ ] **M13. Повторный 401 после refresh не разлогинивает** — `client.ts:80`. -- [ ] **M14. Клиентская пагинация поверх усечённого ответа** — `ShowDetail.tsx:42` (`pageSize:500`), `ChannelDetail.tsx:32` (`pageSize:100`). -- [ ] **M15. Дедуп загрузок только по имени файла** — `upload-store.ts:143`. +- [x] **M12. Молчаливое проглатывание ошибки → вечный скелетон** — `AirPage.tsx:43`. +- [x] **M13. Повторный 401 после refresh не разлогинивает** — `client.ts:80`. +- [ ] **M14. Клиентская пагинация поверх усечённого ответа** — `ShowDetail.tsx:42` (`pageSize:500`), `ChannelDetail.tsx:32` (`pageSize:100`). _(нужна серверная пагинация — отложено)_ +- [ ] **M15. Дедуп загрузок только по имени файла** — `upload-store.ts:143`. _(зависит от контракта серверной уникальности — отложено)_ --- ## 🟡 Низкое / прочее -- [ ] **L1. Синхронный I/O без CancellationToken в портах удаления** (`IMediaStorage`/`IImageStore`/`IBumperTemplateStorage`). -- [ ] **L2. `ValidationBehavior` вызывает `Validate` вместо `ValidateAsync`** — `ValidationBehavior.cs:24`. -- [ ] **L3. Инвариант «Single = 1 серия» проверяется в хендлере, а не в агрегате** — `Show.AddEpisode`. -- [ ] **L4. Двойной `SaveChanges` в командном пути ScheduleGenerator** — `ScheduleGenerator.cs:79,132`. -- [ ] **L5. HLS-плеер не восстанавливается после fatal network/media error** — `ChannelPlayer.tsx:106`. -- [ ] **L6. Бесконечная повторная регистрация «падающего» файла из inbox** — `InboxScannerBackgroundService.cs:92`. -- [ ] **L7. Возможное переполнение int в весах шоу** — `SchedulePlanner.cs:256,265`. -- [ ] **L8. OpenAPI/Scalar мапятся всегда, без гейта по окружению** — `Program.cs:108`. -- [ ] **L9. `AllowedHosts: "*"` и dev-креды БД в appsettings.json**. -- [ ] **L10. SSRF-поверхность в ImageDownloader** (без allowlist схемы/хоста) — `ImageDownloader.cs:16`. -- [ ] **L11. Документация (CLAUDE.md/README) отстала**: заявлено «каталог каналов и раздача видео не реализованы», хотя реализованы. -- [ ] **L12. Пробелы в тестах**: фоновые сервисы, ScheduleGenerator-оркестрация, media-конвейер, эндпоинты без тестов. +- [ ] **L1. Синхронный I/O без CancellationToken в портах удаления** (`IMediaStorage`/`IImageStore`/`IBumperTemplateStorage`). _(меняет интерфейсы портов — отложено)_ +- [x] **L2. `ValidationBehavior` вызывает `Validate` вместо `ValidateAsync`** — `ValidationBehavior.cs:24`. +- [ ] **L3. Инвариант «Single = 1 серия» проверяется в хендлере, а не в агрегате** — `Show.AddEpisode`. _(рефактор агрегата — отложено)_ +- [ ] **L4. Двойной `SaveChanges` в командном пути ScheduleGenerator** — `ScheduleGenerator.cs:79,132`. _(связано с M9 — отложено)_ +- [x] **L5. HLS-плеер не восстанавливается после fatal network/media error** — `ChannelPlayer.tsx:106` (+ симметричная очистка слушателей нативного HLS). +- [x] **L6. Бесконечная повторная регистрация «падающего» файла из inbox** — `InboxScannerBackgroundService.cs:92`. +- [x] **L7. Возможное переполнение int в весах шоу** — `SchedulePlanner.cs:256,265`. +- [ ] **L8. OpenAPI/Scalar мапятся всегда, без гейта по окружению** — `Program.cs:108`. _(возможно намеренно для внутреннего инструмента — решение за владельцем)_ +- [ ] **L9. `AllowedHosts: "*"` и dev-креды БД в appsettings.json**. _(dev-дефолты; решение за владельцем)_ +- [x] **L10. SSRF-поверхность в ImageDownloader** (без allowlist схемы/хоста) — `ImageDownloader.cs:16` (allowlist http/https). +- [ ] **L11. Документация (CLAUDE.md/README) отстала**: заявлено «каталог каналов и раздача видео не реализованы», хотя реализованы. _(нужна сверка с владельцем по объёму)_ +- [ ] **L12. Пробелы в тестах**: фоновые сервисы, ScheduleGenerator-оркестрация, media-конвейер, эндпоинты без тестов. _(частично: добавлены тесты на M11)_ --- @@ -86,3 +86,43 @@ цепочку. Неиспользуемый `EscapeText` удалён. Файлы чистятся в `finally`. Проверка: `dotnet build` — 0 warnings/0 errors (при `TreatWarningsAsErrors`); тесты 95 + 49 зелёные. + +### 2026-07-25 — механические средние/низкие (M1, M2, M4, M11, M12, M13, L2, L5, L6, L7, L10) + +Backend: +- **M1** — `AuthEndpoints.cs`/`StreamingEndpoints.cs`: cookie-флаг `Secure` вычисляется через + `UseSecureCookie` (`!env.IsDevelopment() || request.IsHttps`) вместо голого `request.IsHttps`. + Вне Development cookie всегда `Secure` — прод за внешним TLS-прокси больше не отдаёт refresh/stream + cookie в открытую при неполной настройке ForwardedHeaders. В Development HTTP-разработка сохранена. +- **M2** — `appsettings.json`: добавлен override `System.Net.Http.HttpClient: Warning` — URI запросов + к TMDb/OMDb (с `api_key` в query) больше не пишутся в лог на уровне Information. +- **M4** — `Program.cs`: rate-limiter `auth` переведён на партиционирование по IP клиента + (`RateLimitPartition.GetFixedWindowLimiter`) — один клиент больше не исчерпывает окно логина для всех. +- **M11** — `SchedulePlannerModels.cs` + `ProgrammingOverride.cs`: `Covers` для еженедельного override + поддерживает окно через полночь (`EndMinute <= StartMinute` → `[start,24:00)` в day + `[0,end)` на + следующий день); `end == start` — пустое окно; добавлены null-guards. Регресс-тесты в + `ProgrammingOverrideTests` (+2, всего 97 domain). +- **L2** — `ValidationBehavior.cs`: `ValidateAsync(context, cancellationToken)` вместо синхронного + `Validate` — async-правила FluentValidation и отмена работают. +- **L6** — `InboxScannerBackgroundService.cs`: файлы, стабильно отклоняемые командой регистрации, + помечаются (`_failed`) и не регистрируются повторно каждые два тика; метка снимается при исчезновении файла. +- **L7** — `SchedulePlanner.cs`: веса шоу/вариантов суммируются в `long` с clamp до `int.MaxValue` — + экстремальные вес/множитель не переполняют int (иначе взвешенный выбор молча вырождался в первого). +- **L10** — `ImageDownloader.cs`: allowlist схемы (только абсолютные http/https) — подменённый ответ + провайдера не заставит сервер дёрнуть `file://`/`ftp://` и т.п. + +Frontend: +- **M12** — `AirPage.tsx`: провал `watchChannel` показывает offline-панель с кнопкой ретрая (а не + бесконечный скелетон); ретрай (`attempt`) заново дёргает `watchChannel`. +- **M13** — `client.ts`: повторный 401 уже после успешного refresh вызывает `onUnauthorized()` — + мёртвая сессия чистит авторизацию, а не остаётся «залогиненной». Плюс guard на тело refresh-ответа + (`accessToken` должен быть строкой). +- **L5** — `ChannelPlayer.tsx`: fatal network/media HLS-ошибка сперва пробует восстановиться + (`startLoad`/`recoverMediaError`, до 3 попыток) и лишь затем уходит в offline; слушатели нативной + HLS-ветки снимаются в cleanup. + +Отложено (требует решения владельца/дизайна): M3, M5–M10, M14, M15, L1, L3, L4, L8, L9, L11, L12 — +причины помечены у пунктов. + +Проверка: `dotnet build` 0/0; тесты 97 + 49 зелёные; frontend `tsc` чистый; `oxlint` без новых +предупреждений. diff --git a/backend/src/TeleWave.Api/Endpoints/AuthEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/AuthEndpoints.cs index 42ae55e..a686b47 100644 --- a/backend/src/TeleWave.Api/Endpoints/AuthEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/AuthEndpoints.cs @@ -1,4 +1,5 @@ using LiteCqrs; +using Microsoft.Extensions.Hosting; using TeleWave.Api.Common; using TeleWave.Application.Auth; using TeleWave.Application.Auth.ChangePassword; @@ -62,6 +63,7 @@ public static class AuthEndpoints ISender sender, HttpRequest request, HttpResponse response, + IHostEnvironment env, CancellationToken cancellationToken ) { @@ -70,10 +72,10 @@ public static class AuthEndpoints return result.ToHttpResult(); SetRefreshCookie( - request, response, result.Value.RefreshToken, - result.Value.RefreshTokenExpiresAt + result.Value.RefreshTokenExpiresAt, + UseSecureCookie(request, env) ); return Results.Ok(ToLoginResponse(result.Value)); } @@ -83,6 +85,7 @@ public static class AuthEndpoints ISender sender, HttpRequest request, HttpResponse response, + IHostEnvironment env, CancellationToken cancellationToken ) { @@ -91,10 +94,10 @@ public static class AuthEndpoints return result.ToHttpResult(); SetRefreshCookie( - request, response, result.Value.RefreshToken, - result.Value.RefreshTokenExpiresAt + result.Value.RefreshTokenExpiresAt, + UseSecureCookie(request, env) ); return Results.Ok(ToLoginResponse(result.Value)); } @@ -103,6 +106,7 @@ public static class AuthEndpoints HttpRequest request, HttpResponse response, ISender sender, + IHostEnvironment env, CancellationToken cancellationToken ) { @@ -112,18 +116,19 @@ public static class AuthEndpoints ) return Results.Unauthorized(); + var secure = UseSecureCookie(request, env); var result = await sender.Send(new RefreshCommand(rawToken), cancellationToken); if (!result.IsSuccess) { - response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request)); + response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(secure)); return result.ToHttpResult(); } SetRefreshCookie( - request, response, result.Value.RefreshToken, - result.Value.RefreshTokenExpiresAt + result.Value.RefreshTokenExpiresAt, + secure ); return Results.Ok(ToLoginResponse(result.Value)); } @@ -135,6 +140,7 @@ public static class AuthEndpoints HttpRequest request, HttpResponse response, ISender sender, + IHostEnvironment env, CancellationToken cancellationToken ) { @@ -144,8 +150,9 @@ public static class AuthEndpoints ) await sender.Send(new LogoutCommand(rawToken), cancellationToken); - response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request)); - DeleteStreamCookie(request, response); + var secure = UseSecureCookie(request, env); + response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(secure)); + DeleteStreamCookie(response, secure); return Results.NoContent(); } @@ -179,45 +186,54 @@ public static class AuthEndpoints HttpRequest request, HttpResponse response, ISender sender, + IHostEnvironment env, CancellationToken cancellationToken ) { var result = await sender.Send(new DeleteMyAccountCommand(), cancellationToken); - response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(request)); - DeleteStreamCookie(request, response); + var secure = UseSecureCookie(request, env); + response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(secure)); + DeleteStreamCookie(response, secure); return result.ToHttpResult(); } private static void SetRefreshCookie( - HttpRequest request, HttpResponse response, string rawToken, - DateTimeOffset expiresAt + DateTimeOffset expiresAt, + bool secure ) { - var options = BuildCookieOptions(request); + var options = BuildCookieOptions(secure); options.Expires = expiresAt; response.Cookies.Append(RefreshCookieName, rawToken, options); } - private static CookieOptions BuildCookieOptions(HttpRequest request) => + private static CookieOptions BuildCookieOptions(bool secure) => new() { HttpOnly = true, - Secure = request.IsHttps, + Secure = secure, SameSite = SameSiteMode.Strict, Path = "/api/auth", }; + /// Ставить ли cookie с флагом Secure. Вне Development — всегда true: прод работает за + /// внешним TLS-прокси, а ненадёжен (при неполной настройке + /// ForwardedHeaders он false, и долгоживущий refresh-cookie ушёл бы без Secure). В Development + /// допускаем HTTP-разработку. + private static bool UseSecureCookie(HttpRequest request, IHostEnvironment env) => + !env.IsDevelopment() || request.IsHttps; + /// Гасит stream-cookie (tw_stream, Path=/api) — иначе после выхода эфир можно смотреть /// по прямой ссылке ещё до истечения токена. - private static void DeleteStreamCookie(HttpRequest request, HttpResponse response) => + private static void DeleteStreamCookie(HttpResponse response, bool secure) => response.Cookies.Delete( "tw_stream", new CookieOptions { HttpOnly = true, - Secure = request.IsHttps, + Secure = secure, SameSite = SameSiteMode.Strict, Path = "/api", } diff --git a/backend/src/TeleWave.Api/Endpoints/StreamingEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/StreamingEndpoints.cs index 08001c5..c0be05c 100644 --- a/backend/src/TeleWave.Api/Endpoints/StreamingEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/StreamingEndpoints.cs @@ -2,6 +2,7 @@ using System.Globalization; using System.Text; using System.Text.RegularExpressions; using LiteCqrs; +using Microsoft.Extensions.Hosting; using TeleWave.Api.Common; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Streaming; @@ -47,7 +48,8 @@ public static class StreamingEndpoints ICurrentUser currentUser, StreamTokenService tokens, HttpRequest request, - HttpResponse response + HttpResponse response, + IHostEnvironment env ) { if (currentUser.UserId is not { } userId) @@ -60,7 +62,9 @@ public static class StreamingEndpoints new CookieOptions { HttpOnly = true, - Secure = request.IsHttps, + // Вне Development — всегда Secure (прод за внешним TLS-прокси; request.IsHttps ненадёжен + // при неполной настройке ForwardedHeaders). См. UseSecureCookie в AuthEndpoints. + Secure = !env.IsDevelopment() || request.IsHttps, SameSite = SameSiteMode.Strict, Path = "/api", Expires = expiresAt, diff --git a/backend/src/TeleWave.Api/Program.cs b/backend/src/TeleWave.Api/Program.cs index c1e1295..0c94fa5 100644 --- a/backend/src/TeleWave.Api/Program.cs +++ b/backend/src/TeleWave.Api/Program.cs @@ -1,5 +1,6 @@ using System.Net; using System.Text.Json.Serialization; +using System.Threading.RateLimiting; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.RateLimiting; using Scalar.AspNetCore; @@ -63,19 +64,23 @@ builder.Services.AddHttpContextAccessor(); builder.Services.AddApplication(); builder.Services.AddInfrastructure(builder.Configuration); +var authPermitLimit = builder.Configuration.GetValue("RateLimiting:AuthPermitLimit", 20); builder.Services.AddRateLimiter(options => { - options.AddFixedWindowLimiter( + // Партиционируем по IP клиента (реальный адрес доступен после UseForwardedHeaders): единый + // непартиционированный лимит превращается в DoS — один клиент исчерпывает окно логина для всех. + options.AddPolicy( RateLimiting.AuthPolicy, - limiterOptions => - { - limiterOptions.PermitLimit = builder.Configuration.GetValue( - "RateLimiting:AuthPermitLimit", - 20 - ); - limiterOptions.Window = TimeSpan.FromMinutes(1); - limiterOptions.QueueLimit = 0; - } + httpContext => + RateLimitPartition.GetFixedWindowLimiter( + httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown", + _ => new FixedWindowRateLimiterOptions + { + PermitLimit = authPermitLimit, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0, + } + ) ); options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; }); diff --git a/backend/src/TeleWave.Api/appsettings.json b/backend/src/TeleWave.Api/appsettings.json index b7de0ce..38b1feb 100644 --- a/backend/src/TeleWave.Api/appsettings.json +++ b/backend/src/TeleWave.Api/appsettings.json @@ -61,7 +61,8 @@ "Default": "Information", "Override": { "Microsoft.AspNetCore": "Warning", - "Microsoft.EntityFrameworkCore": "Warning" + "Microsoft.EntityFrameworkCore": "Warning", + "System.Net.Http.HttpClient": "Warning" } }, "WriteTo": [ { "Name": "Console" } ], diff --git a/backend/src/TeleWave.Application/Common/Behaviors/ValidationBehavior.cs b/backend/src/TeleWave.Application/Common/Behaviors/ValidationBehavior.cs index add13d1..264444f 100644 --- a/backend/src/TeleWave.Application/Common/Behaviors/ValidationBehavior.cs +++ b/backend/src/TeleWave.Application/Common/Behaviors/ValidationBehavior.cs @@ -20,10 +20,10 @@ public sealed class ValidationBehavior( return await next(); var context = new ValidationContext(request); - var failures = validators - .Select(v => v.Validate(context)) - .SelectMany(r => r.Errors) - .ToList(); + var results = await Task.WhenAll( + validators.Select(v => v.ValidateAsync(context, cancellationToken)) + ); + var failures = results.SelectMany(r => r.Errors).ToList(); if (failures.Count == 0) return await next(); diff --git a/backend/src/TeleWave.Domain/Broadcast/ProgrammingOverride.cs b/backend/src/TeleWave.Domain/Broadcast/ProgrammingOverride.cs index f23ab33..ece36c1 100644 --- a/backend/src/TeleWave.Domain/Broadcast/ProgrammingOverride.cs +++ b/backend/src/TeleWave.Domain/Broadcast/ProgrammingOverride.cs @@ -77,11 +77,30 @@ public class ProgrammingOverride { if (Recurrence == OverrideRecurrence.Weekly) { + if ( + DayOfWeek is not { } day + || StartMinute is not { } start + || EndMinute is not { } end + ) + return false; + var utc = moment.UtcDateTime; var minuteOfDay = utc.Hour * 60 + utc.Minute; - return (int)utc.DayOfWeek == DayOfWeek - && minuteOfDay >= StartMinute - && minuteOfDay < EndMinute; + var today = (int)utc.DayOfWeek; + + if (end > start) + // Обычное окно в пределах одних суток [start, end). + return today == day && minuteOfDay >= start && minuteOfDay < end; + + if (end < start) + { + // Окно пересекает полночь: [start, 24:00) в день `day` и [00:00, end) на следующий день. + var nextDay = (day + 1) % 7; + return (today == day && minuteOfDay >= start) + || (today == nextDay && minuteOfDay < end); + } + + return false; // end == start — пустое окно } return moment >= StartsAtUtc && moment < EndsAtUtc; diff --git a/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlanner.cs b/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlanner.cs index 4c2e00a..839686f 100644 --- a/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlanner.cs +++ b/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlanner.cs @@ -195,12 +195,12 @@ public static class SchedulePlanner IRandomSource random ) { - var total = eligible.Sum(v => Math.Max(0, v.Weight)); + var total = eligible.Sum(v => (long)Math.Max(0, v.Weight)); if (total <= 0) return eligible[random.Next(eligible.Count)]; - var roll = random.Next(total); - var acc = 0; + var roll = random.Next((int)Math.Min(total, int.MaxValue)); + long acc = 0; foreach (var v in eligible) { acc += Math.Max(0, v.Weight); @@ -253,7 +253,9 @@ public static class SchedulePlanner && show.PreferredHours is { Count: > 0 } windows && windows.Any(w => w.Contains(hour)) ) - return show.Weight * show.PreferredWeightMultiplier; + // long-умножение + clamp: экстремальные вес/множитель не переполняют int + // (иначе отрицательный total молча вырождает взвешенный выбор в первого кандидата). + return (int)Math.Min((long)show.Weight * show.PreferredWeightMultiplier, int.MaxValue); return show.Weight; } @@ -262,12 +264,12 @@ public static class SchedulePlanner IRandomSource random ) { - var total = candidates.Sum(c => c.Weight); + var total = candidates.Sum(c => (long)c.Weight); if (total <= 0) return candidates[0].Show; - var roll = random.Next(total); - var acc = 0; + var roll = random.Next((int)Math.Min(total, int.MaxValue)); + long acc = 0; foreach (var (show, weight) in candidates) { acc += weight; diff --git a/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlannerModels.cs b/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlannerModels.cs index fbcf739..2b0a40d 100644 --- a/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlannerModels.cs +++ b/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlannerModels.cs @@ -39,11 +39,30 @@ public sealed record PlannerOverride( { if (Recurrence == OverrideRecurrence.Weekly) { + if ( + DayOfWeek is not { } day + || StartMinute is not { } start + || EndMinute is not { } end + ) + return false; + var utc = moment.UtcDateTime; var minuteOfDay = utc.Hour * 60 + utc.Minute; - return (int)utc.DayOfWeek == DayOfWeek - && minuteOfDay >= StartMinute - && minuteOfDay < EndMinute; + var today = (int)utc.DayOfWeek; + + if (end > start) + // Обычное окно в пределах одних суток [start, end). + return today == day && minuteOfDay >= start && minuteOfDay < end; + + if (end < start) + { + // Окно пересекает полночь: [start, 24:00) в день `day` и [00:00, end) на следующий день. + var nextDay = (day + 1) % 7; + return (today == day && minuteOfDay >= start) + || (today == nextDay && minuteOfDay < end); + } + + return false; // end == start — пустое окно } return moment >= StartsAtUtc && moment < EndsAtUtc; diff --git a/backend/src/TeleWave.Infrastructure/Media/ImageDownloader.cs b/backend/src/TeleWave.Infrastructure/Media/ImageDownloader.cs index 584b180..1c2b90c 100644 --- a/backend/src/TeleWave.Infrastructure/Media/ImageDownloader.cs +++ b/backend/src/TeleWave.Infrastructure/Media/ImageDownloader.cs @@ -10,10 +10,19 @@ public sealed class ImageDownloader(IHttpClientFactory httpFactory) : IImageDown CancellationToken cancellationToken ) { + // Пускаем только абсолютные http/https URL: URL приходит из ответа metadata-провайдера + // (постеры TMDb/OMDb), и скомпрометированный/подменённый ответ не должен заставить сервер + // дёрнуть file://, ftp:// или иную схему. + if ( + !Uri.TryCreate(url, UriKind.Absolute, out var uri) + || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) + ) + return null; + try { var client = httpFactory.CreateClient("metadata"); - using var response = await client.GetAsync(url, cancellationToken); + using var response = await client.GetAsync(uri, cancellationToken); if (!response.IsSuccessStatusCode) return null; diff --git a/backend/src/TeleWave.Infrastructure/Media/InboxScannerBackgroundService.cs b/backend/src/TeleWave.Infrastructure/Media/InboxScannerBackgroundService.cs index 7663ad7..511700c 100644 --- a/backend/src/TeleWave.Infrastructure/Media/InboxScannerBackgroundService.cs +++ b/backend/src/TeleWave.Infrastructure/Media/InboxScannerBackgroundService.cs @@ -26,6 +26,11 @@ public sealed class InboxScannerBackgroundService( private readonly MediaOptions _media = mediaOptions.Value; private readonly Dictionary _lastSizes = new(StringComparer.Ordinal); + // Файлы, регистрация которых стабильно отклоняется командой (например, неподдерживаемый контейнер): + // помечаем, чтобы не пытаться регистрировать их заново каждые два тика и не засорять лог. Забываются, + // когда файл исчезает из inbox (тогда одноимённый новый файл снова получит попытку). + private readonly HashSet _failed = new(StringComparer.Ordinal); + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { paths.EnsureDirectories(); @@ -67,6 +72,9 @@ public sealed class InboxScannerBackgroundService( var name = Path.GetFileName(path); present.Add(name); + if (_failed.Contains(name)) + continue; // уже пытались и получили отказ — ждём, пока файл уберут/заменят + long size; try { @@ -89,16 +97,20 @@ public sealed class InboxScannerBackgroundService( continue; } - await RegisterAsync(name, cancellationToken); + var registered = await RegisterAsync(name, cancellationToken); _lastSizes.Remove(name); + if (!registered) + _failed.Add(name); // команда отклонила файл — больше не пытаемся, пока он в inbox } - // Забываем исчезнувшие файлы, чтобы словарь не рос. + // Забываем исчезнувшие файлы, чтобы словари не росли (и одноимённый новый файл снова попробовался). foreach (var stale in _lastSizes.Keys.Where(k => !present.Contains(k)).ToList()) _lastSizes.Remove(stale); + foreach (var stale in _failed.Where(k => !present.Contains(k)).ToList()) + _failed.Remove(stale); } - private async Task RegisterAsync(string fileName, CancellationToken cancellationToken) + private async Task RegisterAsync(string fileName, CancellationToken cancellationToken) { await using var scope = scopeFactory.CreateAsyncScope(); var sender = scope.ServiceProvider.GetRequiredService(); @@ -116,14 +128,14 @@ public sealed class InboxScannerBackgroundService( result.Value, fileName ); + return true; } - else - { - logger.LogWarning( - "Не удалось зарегистрировать {File} из inbox: {Error}", - fileName, - result.Error.Code - ); - } + + logger.LogWarning( + "Не удалось зарегистрировать {File} из inbox: {Error}", + fileName, + result.Error.Code + ); + return false; } } diff --git a/backend/tests/TeleWave.Domain.Tests/Broadcast/ProgrammingOverrideTests.cs b/backend/tests/TeleWave.Domain.Tests/Broadcast/ProgrammingOverrideTests.cs index bfa6bfc..4ba329d 100644 --- a/backend/tests/TeleWave.Domain.Tests/Broadcast/ProgrammingOverrideTests.cs +++ b/backend/tests/TeleWave.Domain.Tests/Broadcast/ProgrammingOverrideTests.cs @@ -56,4 +56,30 @@ public class ProgrammingOverrideTests Assert.True(ovr.Covers(Epoch.AddDays(7).AddHours(5))); Assert.False(ovr.Covers(Epoch.AddDays(1).AddHours(5))); // другой день недели } + + [Fact] + public void Weekly_Covers_WindowCrossingMidnight() + { + // Epoch = 2026-01-01 — четверг (DayOfWeek=4). Окно 23:00–01:00 пересекает полночь на пятницу. + var day = (int)Epoch.UtcDateTime.DayOfWeek; + var ovr = NewChannel().AddWeeklyOverride(OverrideMode.Exclusive, day, 1380, 60); // 23:00–01:00 + + Assert.True(ovr.Covers(Epoch.AddMinutes(1380))); // чт 23:00 — начало + Assert.True(ovr.Covers(Epoch.AddMinutes(1439))); // чт 23:59 + Assert.True(ovr.Covers(Epoch.AddDays(1).AddMinutes(0))); // пт 00:00 (следующий день) + Assert.True(ovr.Covers(Epoch.AddDays(1).AddMinutes(59))); // пт 00:59 + Assert.False(ovr.Covers(Epoch.AddDays(1).AddMinutes(60))); // пт 01:00 — конец исключается + Assert.False(ovr.Covers(Epoch.AddMinutes(1379))); // чт 22:59 — до начала + Assert.False(ovr.Covers(Epoch.AddHours(12))); // чт полдень — вне окна + } + + [Fact] + public void Weekly_EmptyWindow_WhenStartEqualsEnd_CoversNothing() + { + var day = (int)Epoch.UtcDateTime.DayOfWeek; + var ovr = NewChannel().AddWeeklyOverride(OverrideMode.Exclusive, day, 600, 600); + + Assert.False(ovr.Covers(Epoch.AddMinutes(600))); + Assert.False(ovr.Covers(Epoch.AddMinutes(700))); + } } diff --git a/frontend/src/features/streaming/AirPage.tsx b/frontend/src/features/streaming/AirPage.tsx index abd23f8..11d544e 100644 --- a/frontend/src/features/streaming/AirPage.tsx +++ b/frontend/src/features/streaming/AirPage.tsx @@ -44,11 +44,18 @@ export function AirPage() { .then(() => { if (!cancelled) setWatchReady(true) }) - .catch(() => undefined) + .catch(() => { + // Выдача stream-cookie не удалась (403/500/сеть) — показываем offline-панель с кнопкой ретрая, + // а не бесконечный скелетон. Ретрай (attempt) заново дёрнет watchChannel. + if (!cancelled) { + setWatchReady(true) + setPlayerError(true) + } + }) return () => { cancelled = true } - }, [selected]) + }, [selected, attempt]) const { data: epg } = useQuery({ queryKey: ['air', 'epg', selected], diff --git a/frontend/src/features/streaming/ChannelPlayer.tsx b/frontend/src/features/streaming/ChannelPlayer.tsx index e24a60e..b12547e 100644 --- a/frontend/src/features/streaming/ChannelPlayer.tsx +++ b/frontend/src/features/streaming/ChannelPlayer.tsx @@ -97,24 +97,40 @@ export function ChannelPlayer({ }) } + // Слушатели нативной ветки — держим ссылки, чтобы снять их в cleanup (симметрично hls.destroy()). + const onNativeError = () => onUnavailable?.() + if (Hls.isSupported()) { hls = new Hls({ liveSyncDurationCount: 3, enableWorker: true, lowLatencyMode: false }) hlsRef.current = hls hls.loadSource(src) hls.attachMedia(video) hls.on(Hls.Events.MANIFEST_PARSED, startPlayback) + + // Живой эфир: краткий сетевой сбой сегмента или media-ошибку сперва пробуем восстановить + // (hls.js рекомендует startLoad / recoverMediaError), и только исчерпав попытки — уходим в offline. + let recoverAttempts = 0 hls.on(Hls.Events.ERROR, (_event, data) => { - if (data.fatal) { - hls?.destroy() - hls = null - hlsRef.current = null - onUnavailable?.() + if (!data.fatal) return + if (data.type === Hls.ErrorTypes.NETWORK_ERROR && recoverAttempts < 3) { + recoverAttempts += 1 + hls?.startLoad() + return } + if (data.type === Hls.ErrorTypes.MEDIA_ERROR && recoverAttempts < 3) { + recoverAttempts += 1 + hls?.recoverMediaError() + return + } + hls?.destroy() + hls = null + hlsRef.current = null + onUnavailable?.() }) } else if (video.canPlayType('application/vnd.apple.mpegurl')) { video.src = src video.addEventListener('loadedmetadata', startPlayback) - video.addEventListener('error', () => onUnavailable?.()) + video.addEventListener('error', onNativeError) } else { onUnavailable?.() } @@ -122,6 +138,8 @@ export function ChannelPlayer({ return () => { hls?.destroy() hlsRef.current = null + video.removeEventListener('loadedmetadata', startPlayback) + video.removeEventListener('error', onNativeError) } }, [slug, onUnavailable]) diff --git a/frontend/src/shared/api/client.ts b/frontend/src/shared/api/client.ts index 76f2eec..65095f7 100644 --- a/frontend/src/shared/api/client.ts +++ b/frontend/src/shared/api/client.ts @@ -30,7 +30,8 @@ export async function refreshAccessToken(): Promise { try { const response = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' }) if (!response.ok) return false - const data = (await response.json()) as { accessToken: string } + const data = (await response.json()) as { accessToken?: unknown } + if (typeof data?.accessToken !== 'string') return false setAccessToken(data.accessToken) return true } catch { @@ -77,9 +78,13 @@ export async function apiRequest(path: string, options: RequestOptions = {}): body: options.body !== undefined ? JSON.stringify(options.body) : undefined, }) - if (response.status === 401 && !options.skipRefresh) { - const refreshed = await refreshAccessToken() - if (refreshed) return apiRequest(path, { ...options, skipRefresh: true }) + if (response.status === 401) { + if (!options.skipRefresh) { + const refreshed = await refreshAccessToken() + if (refreshed) return apiRequest(path, { ...options, skipRefresh: true }) + } + // 401 и освежить токен нельзя/не помогло (включая повторный 401 уже после успешного refresh — + // токен приняли, но прав нет / он тут же отозван): сессия мертва, чистим авторизацию. onUnauthorized?.() throw await parseError(response) }