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.
This commit is contained in:
@@ -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` без новых
|
||||
предупреждений.
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
/// <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>
|
||||
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",
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -61,7 +61,8 @@
|
||||
"Default": "Information",
|
||||
"Override": {
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Warning"
|
||||
"Microsoft.EntityFrameworkCore": "Warning",
|
||||
"System.Net.Http.HttpClient": "Warning"
|
||||
}
|
||||
},
|
||||
"WriteTo": [ { "Name": "Console" } ],
|
||||
|
||||
@@ -20,10 +20,10 @@ public sealed class ValidationBehavior<TRequest, TResponse>(
|
||||
return await next();
|
||||
|
||||
var context = new ValidationContext<TRequest>(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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -26,6 +26,11 @@ public sealed class InboxScannerBackgroundService(
|
||||
private readonly MediaOptions _media = mediaOptions.Value;
|
||||
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)
|
||||
{
|
||||
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<bool> RegisterAsync(string fileName, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var sender = scope.ServiceProvider.GetRequiredService<ISender>();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -30,7 +30,8 @@ export async function refreshAccessToken(): Promise<boolean> {
|
||||
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<T>(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<T>(path, { ...options, skipRefresh: true })
|
||||
if (response.status === 401) {
|
||||
if (!options.skipRefresh) {
|
||||
const refreshed = await refreshAccessToken()
|
||||
if (refreshed) return apiRequest<T>(path, { ...options, skipRefresh: true })
|
||||
}
|
||||
// 401 и освежить токен нельзя/не помогло (включая повторный 401 уже после успешного refresh —
|
||||
// токен приняли, но прав нет / он тут же отозван): сессия мертва, чистим авторизацию.
|
||||
onUnauthorized?.()
|
||||
throw await parseError(response)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user