21 KiB
Ревью TeleWave — план исправлений
Статус чекбоксов: [ ] не сделано · [x] исправлено · [~] в работе.
Дата ревью: 2026-07-25. Область: backend (C#/.NET 10), frontend (React 19), инфра. Сборка чистая, 144 теста зелёные, typecheck чистый.
🔴 Критичное
-
C1. Утечка слота семафора → медиа-обработка навсегда встаёт
MediaProcessingBackgroundService.cs:52— слот берётся доClaimNextAsync; бросок из claim уводит во внешнийcatchбезRelease(). После N ошибок диспетчер зависает навсегда. Fix: try/finally вокруг claim либо Release() в ветке ошибки. -
C2. Слабый JWT-ключ по умолчанию без fail-fast
appsettings.json:8+DependencyInjection.cs:65— placeholder-ключ, нет проверки на переопределение и длину; тот же ключ у stream-токенов. Забыли → обход авторизации. Fix: на старте кидать, если ключ = placeholder или < 32 байт. -
C3. Инъекция/поломка ffmpeg-фильтра из пользовательских подписей
FfmpegBumperRenderer.cs:306—EscapeTextне экранирует,;[]%перевод строки. Подпись с запятой рвёт filtergraph;[/]— инъекция звеньев. Валидатор проверяет только длину. Fix: экранировать полный набор метасимволов filtergraph.
🟠 Среднее
Безопасность
- 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. (TTL 30 мин + проверка блокировки на плейлисте; фронт перевыпускает cookie) - M4. Rate-limiter глобальный (не партиционирован), только на
/api/auth—Program.cs:66.
Архитектура / транзакции
- M5.
ExecuteDeleteAsyncломает границу UnitOfWork + файлы удаляются до коммита —DeleteShowMediaCommandHandler.cs,ClearAllMediaCommandHandler.cs. (явная транзакция вокруг ExecuteDelete; файлы после коммита. DeleteAllShows — единичный ExecuteDelete, уже атомарен) - M6. Query с побочным эффектом на ФС — переведён в
RenderBumperPreviewCommand(command, не query). - M7. Файловый/HTTP I/O до коммита — Delete/Register хендлеры: БД коммитится раньше файлового I/O; Register откатывает регистрацию при сбое переноса файла.
Планировщик / медиа
- M8. Нет таймаута на ffmpeg/ffprobe —
ProcessRunner.cs+MediaOptions(ffprobe 120с, транскод/рендер 1800с, конфигурируемо). - M9. Гонка при конкурентной генерации расписания канала —
ScheduleGenerator.cs(транзакция +pg_advisory_xact_lockпо каналу). - M10. Рендер заставок синхронно внутри тика планировщика — вынесен в асинхронную очередь: планировщик создаёт ассет заставки в Pending + кэш-строку и ставит в очередь, ffmpeg крутит новый
BumperRenderBackgroundServiceвне тика/транзакции. До готовности плейлист отдаёт филлер. - M11. Override через полночь не работает —
SchedulePlannerModels.cs:40(EndMinute <= StartMinute→ пустое окно).
Фронтенд
- M12. Молчаливое проглатывание ошибки → вечный скелетон —
AirPage.tsx:43. - M13. Повторный 401 после refresh не разлогинивает —
client.ts:80. - M14. Клиентская пагинация поверх усечённого ответа —
ShowDetail.tsx,ChannelDetail.tsx. (пикеры дозагружают все страницы черезlistAllMediaс safety-cap; при упоре в cap — видимое предупреждение. True server-side pagination неуместна: пикеры делают клиентские исключение/парсинг/сортировку/bulk-select) - M15. Дедуп загрузок только по имени файла —
upload-store.ts:143. (разрешено анализом: сервер сам дедупит поOriginalFileName— клиент это зеркалит, аskippedпоказывается. Имя+размер разошлось бы с сервером → failed-загрузки. Уникальность по имени — доменное решение, кода не менял)
🟡 Низкое / прочее
- L1. Синхронный I/O без CancellationToken в портах удаления — все 5 delete-методов портов переведены на
Task+CancellationToken; рекурсивные удаления каталогов офлоадятся (Task.Run). - L2.
ValidationBehaviorвызываетValidateвместоValidateAsync—ValidationBehavior.cs:24. - L3. Инвариант «Single = 1 серия» проверяется в хендлере, а не в агрегате —
Show.AddEpisodeкидает при обходе (+тест). - L4. Двойной
SaveChangesв командном пути ScheduleGenerator — снят: генератор коммитит свою транзакцию, пост-save UnitOfWork стал no-op. - L5. HLS-плеер не восстанавливается после fatal network/media error —
ChannelPlayer.tsx:106(+ симметричная очистка слушателей нативного HLS). - L6. Бесконечная повторная регистрация «падающего» файла из inbox —
InboxScannerBackgroundService.cs:92. - L7. Возможное переполнение int в весах шоу —
SchedulePlanner.cs:256,265. - L8. OpenAPI/Scalar мапятся всегда, без гейта по окружению —
Program.cs(только Development либо флагApi:EnableOpenApi). - L9.
AllowedHosts: "*"и dev-креды БД в appsettings.json — осознанные dev-дефолты, переопределяются env в проде; C2 теперь форсит реальный JWT-ключ. Оставлено как задокументированное. - L10. SSRF-поверхность в ImageDownloader (без allowlist схемы/хоста) —
ImageDownloader.cs:16(allowlist http/https). - L11. Документация (CLAUDE.md/README) отстала — обновлены разделы «текущее состояние» и рабочий принцип под реальный объём.
- L12. Пробелы в тестах — добавлен проект
TeleWave.Integration.Testsна Testcontainers-Postgres: генерация расписания, M10 (Pending-заставки + очередь), M9 (advisory-lock против гонки), M7 (откат Register), M5 (транзакция DeleteShowMedia). При отсутствии Docker тесты пропускаются (SkippableFact). Плюс unit-тесты на M11/L3.
Журнал исправлений
2026-07-25 — критические (C1–C3)
- C1 —
MediaProcessingBackgroundService.cs:ClaimNextAsyncобёрнут вtry/catch, слот освобождается (slots.Release()) при любом сбое захвата передthrow. Теперь транзиентная ошибка БД не «съедает» слот семафора — диспетчер не зависает. - C2 —
Infrastructure/DependencyInjection.cs: после загрузкиJwtOptionsдобавлен fail-fast: старт падает с понятным сообщением, еслиJwt:SigningKeyкороче 32 байт или содержитchange-me(значение-заглушка). Тот же ключ подписывает stream-токены, поэтому это закрывает и их. - C3 —
FfmpegBumperRenderer.cs: подписи «Сейчас/Далее» (NowLabel/NextLabel) переведены с инлайновогоtext=наtextfile=(nowlabel.txt/nextlabel.txt,expansion=none), как уже сделано для названий шоу. Метасимволы filtergraph в подписи больше не ломают/не инъектируют цепочку. Неиспользуемый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: добавлен overrideSystem.Net.Http.HttpClient: Warning— URI запросов к TMDb/OMDb (сapi_keyв query) больше не пишутся в лог на уровне Information. - M4 —
Program.cs: rate-limiterauthпереведён на партиционирование по 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 без новых
предупреждений.
2026-07-25 — остальные средние/низкие (M3, M5–M9, M14, L1, L3, L4, L8, L11 + разбор M10, M15, L9, L12)
По согласованным решениям (короткий TTL+проверка блокировки; конфиг-таймауты ffmpeg; Postgres advisory-lock; явные транзакции + I/O после коммита):
- M3 —
StreamingOptions.StreamTokenMinutes(30),StreamTokenService.Validateвозвращает id зрителя;LivePlaylistсверяет блокировку черезIIdentityService(1 запрос на перезагрузку плейлиста, не на сегмент). Фронт (AirPage) перевыпускает cookie каждые 20 мин. Dev-ключ JWT добавлен вappsettings.Development.json, чтобы C2 не ломал локальный запуск. - M5/M7 —
IAppDbContext.BeginTransactionAsync;ClearAllMedia/DeleteShowMedia— транзакция вокруг ExecuteDelete, файлы после коммита;DeleteMediaAsset/DeleteImage/ClearBumperAudio/RemoveBumperTemplate— БД раньше файлов;RegisterMediaAsset— save→перенос с откатом при сбое. - M6 —
RenderBumperPreviewQuery→RenderBumperPreviewCommand(+ файл/класс переименованы). - M8 —
ProcessRunnerпринимает таймаут;MediaOptions.ProbeTimeoutSeconds/TranscodeTimeoutSeconds; по таймауту процесс убивается,TimeoutException→ ассет уходит в Failed, слот освобождается. - M9 —
IAppDbContext.AcquireChannelLockAsync(pg_advisory_xact_lockпо int64 из GUID канала);ScheduleGenerator.GenerateAsyncобёрнут транзакцией + lock, снял и L4 (двойной save). - M14 —
listAllMedia(дозагрузка всех страниц, safety-cap 5000) в пикерах ShowDetail/ChannelDetail- предупреждение при усечении (i18n ru/en). True server-pagination неуместна (клиентские исключение/парсинг/сортировка/bulk).
- L1 — 5 delete-методов портов → async с
CancellationToken; рекурсивные удаления офлоадятся. - L3 —
Show.AddEpisodeкидает при второй серии для Single (+тест). L8 — OpenAPI/Scalar только вне прода/по флагу. L11 — CLAUDE.md/README обновлены.
Разбор без правок кода:
- M10 — частично закрыт (M8 ограничивает худший случай, кэш делает рендеры редкими, M9 сериализует канал); полный вынос рендера из тика — отдельная архитектурная задача (флажок).
- M15 — сервер сам дедупит по имени файла; клиент это зеркалит и показывает
skipped. Смена на имя+размер разошлась бы с сервером. Уникальность по имени — доменное решение. - L9 — dev-дефолты, переопределяются env в проде; C2 форсит JWT-ключ.
Новые/обновлённые опции: .env.example (Storage__StreamTokenMinutes, Media__Probe/TranscodeTimeoutSeconds,
Api:EnableOpenApi неявно). Тесты: +2 domain (override через полночь), +1 domain (Single).
Проверка: dotnet build 0/0 (TreatWarningsAsErrors); тесты 98 + 49 зелёные; frontend tsc чистый,
oxlint без новых предупреждений.
Журнал — M10 (async-рендер заставок) и L12 (интеграционные тесты)
2026-07-25 — M10: асинхронная очередь рендера заставок
- Домен:
BumperAssetполучилChannelId/TemplateId/VariantId(чтобы фоновый рендерер восстановил спецификацию), фабрика обновлена; миграцияBumperAssetRenderContext(Guid-колонки с дефолтом для существующих строк — самовосстановление при перерендере).MediaAsset.RegisterGeneratedтеперь честно Pending до готовности. - Очередь:
IBumperRenderQueue+BumperRenderQueue(сигнал поверх Channel, как у медиа). ScheduleBumperResolver: больше не рендерит — создаёт Pending-ассет (Source=Generated) + кэш-строку и ставит в очередь; переиспользует Ready/Pending/Processing, перерендерит только Failed (по новейшей кэш-строке). Тик планировщика и его транзакция больше не держатся во время ffmpeg (снимает и остаток M9).BumperRenderBackgroundService: последовательно берёт Pending Generated → восстанавливает спеку (BumperSpecFactory) → ffmpeg → MarkReady/MarkFailed; сброс прерванных на старте; idle-poll 30с.MediaProcessingBackgroundService: Generated-ассеты исключены из claim/reset (их обслуживает рендерер заставок).- Стриминг менять не пришлось:
GetLivePlaylistQueryHandlerуже фильтрует поStatus==Ready→ Pending-заставка автоматически деградирует в филлер, пока рендерится. - Поведение: заставка перехода впервые появляется на тик позже (до этого — филлер); дальше берётся из кэша. Осознанный компромисс (выбран владельцем).
2026-07-25 — L12: интеграционные тесты на Testcontainers-Postgres
Новый проект tests/TeleWave.Integration.Tests (в .slnx): поднимает postgres:16-alpine, применяет
реальные миграции, гоняет то, что InMemory не умеет (транзакции, advisory-lock, ExecuteDelete). 5 тестов:
генерация Program-расписания; создание Pending-заставок (Generated) + постановка в очередь (M10);
сериализация конкурентной генерации advisory-lock'ом без перекрытий (M9); откат Register при сбое
переноса файла (M7); атомарное удаление в DeleteShowMedia (M5). При отсутствии Docker — SkippableFact
пропускает (CI на раннере без Docker не падает; dotnet test TeleWave.slnx в CI прогонит их при наличии
Docker на ubuntu-latest).
Проверка: сборка решения 0/0; 98 domain + 49 application + 5 integration зелёные; фронт tsc/oxlint
чистые.
Осталось (доменное решение владельца, не дефект)
- M15 (доменно) — если понадобятся разные файлы с одинаковым именем: пересмотреть уникальность
MediaAsset(сейчас поOriginalFileName— намеренно, клиент это зеркалит).