Refactor authentication and streaming cookie handling: implement secure cookie logic based on environment in AuthEndpoints and StreamingEndpoints, enhance rate limiting policy in Program.cs, and update logging configuration in appsettings.json. Fix validation behavior to use asynchronous validation methods and improve error handling in frontend components.
build / backend (push) Successful in 1m52s
build / frontend (push) Successful in 1m0s
tests / backend-tests (push) Successful in 2m27s

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