Compare commits

..
72 Commits
Author SHA1 Message Date
Leonid Pershin 6c18a9da79 Update configuration and enhance media processing: add stream token TTL and timeout settings in .env.example, improve error handling in media endpoints, and refactor command handlers for asynchronous operations. Update documentation to reflect current application state and features.
tests / backend-tests (push) Successful in 2m27s
build / backend (push) Successful in 2m19s
build / frontend (push) Successful in 53s
2026-07-25 23:14:55 +03:00
Leonid Pershin a261e261f0 Add RenderBumperPreviewCommandHandler: implement command handler for rendering bumper previews, including channel and template validation, audio duration handling, and rendering logic for bumper variants. 2026-07-25 23:14:46 +03:00
Leonid Pershin 058cbc6994 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
2026-07-25 21:18:31 +03:00
Leonid Pershin 53e0eeb776 Enhance JWT signing key validation and improve FfmpegBumperRenderer label handling: add checks for signing key length and placeholder values in DependencyInjection, and refactor label processing to read from files in FfmpegBumperRenderer. Update MediaProcessingBackgroundService to ensure slot release on claim failure.
build / backend (push) Successful in 2m24s
build / frontend (push) Successful in 54s
tests / backend-tests (push) Successful in 2m40s
2026-07-25 21:07:38 +03:00
Leonid Pershin 5bc6e144d2 Refactor ChannelEndpoints and ScheduleGenerator: consolidate endpoint logic into partial files, remove unused methods, and enhance dependency injection for scheduling. Update ChannelDetail component to streamline imports and improve UI structure.
build / backend (push) Successful in 2m17s
build / frontend (push) Successful in 52s
tests / backend-tests (push) Successful in 2m38s
2026-07-25 20:53:52 +03:00
Leonid Pershin 8484587313 Implement pagination for candidates and episodes in ShowDetail component: add Pager component for both lists, update state management for page navigation, and ensure safe page handling during filtering and updates.
tests / backend-tests (push) Successful in 2m10s
build / backend (push) Successful in 1m52s
build / frontend (push) Successful in 53s
2026-07-25 20:33:44 +03:00
Leonid Pershin 7694ff3388 Update CI workflows to use setup actions: replace container definitions with setup-dotnet and setup-node actions for backend and frontend jobs, ensuring consistent environment configuration for builds and tests.
build / backend (push) Successful in 2m11s
build / frontend (push) Successful in 49s
tests / backend-tests (push) Successful in 2m31s
2026-07-25 20:22:48 +03:00
Leonid Pershin 2165bfc459 Add coverage badge to README and update test workflow: include coverage badge in README for CI visibility and enhance test workflow to generate coverage reports and update the badge on main branch pushes.
build / backend (push) Failing after 5s
build / frontend (push) Successful in 52s
tests / backend-tests (push) Failing after 5s
2026-07-25 20:20:17 +03:00
Leonid Pershin 0fd9ab9bc2 Update README and package.json: add build and test badges to README for CI visibility, and specify pnpm as the package manager in package.json for consistency in dependency management.
build / backend (push) Failing after 1m28s
build / frontend (push) Successful in 1m39s
tests / backend-tests (push) Failing after 5s
2026-07-25 20:17:30 +03:00
Leonid Pershin 4611f90d11 Add project reference for TeleWave.Infrastructure: include TeleWave.Infrastructure.csproj in TeleWave.Application.Tests project to support additional testing capabilities. 2026-07-25 20:12:04 +03:00
Leonid Pershin ed240ef73c Add coverlet.collector package for test coverage: include in Directory.Packages.props and add references in TeleWave.Application.Tests and TeleWave.Domain.Tests projects for enhanced test reporting. 2026-07-25 19:58:56 +03:00
Leonid Pershin f57b7503ed Refactor code formatting and improve readability: standardize line breaks and indentation across various endpoint files, enhancing code clarity and maintainability. Update package version formatting in Directory.Packages.props for consistency. 2026-07-25 19:55:01 +03:00
Leonid Pershin 1f6fa6f1ae Add password reset functionality for admin users: implement ResetPassword endpoint, update AdminUserEndpoints to include password reset logic, and enhance user interface for password management. Update translations for new features and ensure proper error handling in the reset process. 2026-07-25 19:49:08 +03:00
Leonid Pershin d44aa44d95 Update UploadToShowDialog UI: increase dialog content width for better layout and adjust select trigger width for improved usability. 2026-07-25 19:27:00 +03:00
Leonid Pershin 7c1b807e5e Enhance UploadToShowDialog: add 'apply to all' functionality for batch show assignment, improve UI layout for better user experience, and update translations for new options. 2026-07-25 19:17:00 +03:00
Leonid Pershin da0ac79079 Update media processing options: enhance .env.example and MediaOptions.cs to include MaxParallelTranscodes for improved transcoding performance. Refactor MediaProcessingBackgroundService to utilize parallel processing slots, ensuring efficient handling of multiple media assets simultaneously. Update comments for clarity on new configurations and processing logic. 2026-07-25 19:01:46 +03:00
Leonid Pershin 9cefc6a198 Enhance show management: add OriginalName property to ShowSummaryDto, update ListShowsQueryHandler to include OriginalName, and improve upload functionality by allowing auto-detection of shows based on file names. Update translations for better user guidance on show detection. 2026-07-25 18:54:43 +03:00
Leonid Pershin 8718e8b6bf Enhance channel management: add support for preferred weight multipliers and preferred hours in channel shows, update related data models and API endpoints, and implement validation for new properties. Refactor scheduling logic to utilize preferred hours for weight adjustments during show planning. 2026-07-25 18:44:28 +03:00
Leonid Pershin 358adbff21 Enhance FfmpegBumperRenderer: introduce text width adjustment for dynamic font sizing, ensuring proper fitting of free text and titles within the specified width. Add FitSize method to calculate optimal font size based on text length and maximum width constraints. 2026-07-25 13:39:11 +03:00
Leonid Pershin 18325fdfcc Refactor bumper preview functionality: update API endpoints to support rendering previews for all bumper sub-blocks, modify related query and handler to generate individual asset previews, and enhance frontend components for improved user experience with variant-specific previews. Update translations for consistency in terminology. 2026-07-25 13:31:38 +03:00
Leonid Pershin a65bcf4258 Add bumper variant management: implement API endpoints for adding, updating, and removing bumper text variants, enhance data models to support variant details, and update scheduling logic to utilize variants. Refactor related components for improved bumper template handling and ensure proper error management for variant operations. 2026-07-25 13:24:11 +03:00
Leonid Pershin f640af1fc4 Enhance BumperTemplateEditor UI: add collapsible functionality for template details, improve user interaction with click-to-toggle feature, and ensure delete action does not trigger unintended collapses. Update state management for better control of template visibility. 2026-07-25 12:55:40 +03:00
Leonid Pershin a34d929330 Add rename show functionality: implement API endpoint for renaming shows, update Show model to support name changes, and enhance ShowMetadataCard UI for name input. Update translations for new UI elements to improve user experience. 2026-07-25 12:19:07 +03:00
Leonid Pershin 671bbb78bd Enhance ShowMetadataCard UI: update button labels for gallery selection, improve metadata search input with a dedicated label, and streamline provider selection functionality. Update translations for new UI elements to improve user experience. 2026-07-25 12:11:29 +03:00
Leonid Pershin afe6cc24aa Refactor ShowMetadataCard and API: remove obsolete poster upload functionality, streamline metadata saving process into a single mutation, and enhance UI for original name input. Update related state management and error handling for improved user experience. 2026-07-25 12:03:25 +03:00
Leonid Pershin 7efd616c29 Refactor bumper template background management: rename background upload endpoint to reflect new functionality, update related command and handler to use image ID instead of extension, and adjust data models to support image references. Remove obsolete background handling code and enhance UI components for improved image selection and display. 2026-07-25 11:56:36 +03:00
Leonid Pershin a970eae7d2 Implement image management enhancements: add functionality to relocate legacy images during startup, update image handling in show metadata, and refactor related API endpoints to utilize the new image storage system. Adjust database schema to support image references and update UI components for improved image selection and display. 2026-07-25 11:43:17 +03:00
Leonid Pershin 149cd153b9 Add image management functionality: introduce Image entity and related API endpoints, update database schema to support image storage, and enhance UI with a new gallery feature for image selection and upload. Update translations for gallery-related terms. 2026-07-25 11:27:34 +03:00
Leonid Pershin 72451f89a8 Add original name support for shows: implement API endpoint to set original name, update Show and ShowDto models to include original name field, and enhance UI components for managing original names in show metadata. Update validation and database schema accordingly. 2026-07-25 11:10:33 +03:00
Leonid Pershin 84c2867062 Refactor Channel endpoints and data models: replace jingle functionality with bumper templates, update related commands and handlers, and enhance API routes for managing bumper templates. Remove obsolete jingle-related code and adjust channel data structures to support new bumper template features. 2026-07-25 11:02:16 +03:00
Leonid Pershin 27571a4ab6 Refactor ChannelDetail component: replace Card components with CollapsibleCard for improved UI organization and user experience. Introduce CollapsibleCard functionality to manage expandable content sections. Update AirPage to enhance time display formatting for better readability. 2026-07-25 10:10:36 +03:00
Leonid Pershin 9a17715d0f Refactor ChannelPlayer component: remove play/pause functionality and related state management, update comments for clarity, and streamline audio control features for a live streaming experience. 2026-07-25 10:03:11 +03:00
Leonid Pershin 8ca585a5be Enhance metadata handling and UI feedback: add support for jingles in channel queries, implement error handling for metadata provider requests, and improve search functionality in ShowMetadataCard with user feedback for no results found. Update translations for new UI messages. 2026-07-25 09:58:59 +03:00
Leonid Pershin 7fb46b5e0d Implement episode metadata management: add functionality to refresh episode metadata from external sources, including new API endpoints and UI integration. Enhance Show and Episode models to support additional metadata fields, and update database schema accordingly. Update ShowDetail and ShowMetadataCard components to display refreshed episode information and provide user feedback on metadata updates. 2026-07-25 09:46:19 +03:00
Leonid Pershin 0d2dee815e Add metadata management for shows: implement metadata retrieval, application, and updates in the API and UI. Enhance Show and ShowDto models to include metadata fields, and update the database schema accordingly. Introduce new endpoints for metadata operations and integrate metadata display in the ShowDetail component. 2026-07-25 09:07:55 +03:00
Leonid Pershin ba023bc416 Enhance upload functionality in UploadStore and UI: implement retry mechanism for failed uploads, allowing users to manually retry uploads from the UploadSnackbar. Update translations to include retry options and improve error handling for transient issues during media uploads. 2026-07-25 08:38:12 +03:00
Leonid Pershin de7e27c80f Refactor ShowDetail component to utilize parseEpisodeName for episode parsing: replace legacy parsing logic with a new function that supports additional episode naming formats. Update sorting and display logic to reflect parsed episode data, enhancing the overall user experience. 2026-07-25 08:23:59 +03:00
Leonid Pershin 2dd8bf9724 Add upload to show functionality in MediaPanel: introduce file selection for uploading media files directly to a show, enhancing the upload store to support show associations. Update translations for new upload options and UI elements. 2026-07-25 08:16:05 +03:00
Leonid Pershin 4bebe64ff0 Enhance media upload functionality: add abort signal support for uploadMedia, enabling cancellation of ongoing uploads. Implement cancel and cancelAll methods in upload store to manage queued and active uploads. Update UploadSnackbar to include cancel buttons for individual and all uploads. Enhance i18n with new cancellation strings. 2026-07-25 08:06:54 +03:00
Leonid Pershin ca907762aa Add user creation functionality: implement API endpoint for creating new users, including request handling and response management. Update frontend to support user creation with a new form in the UsersPanel, integrating role selection and input validation. Enhance i18n for new user-related strings. 2026-07-25 07:54:51 +03:00
Leonid Pershin c53848477f Add settings endpoints and registration status check: implement new API endpoints for settings management, including registration status retrieval, and update the registration command handler to enforce registration rules based on site settings. 2026-07-25 07:48:17 +03:00
Leonid Pershin 1bdc3323ab Update ChannelDetail component: adjust SelectTrigger width for better UI consistency and accommodate longer text without wrapping. 2026-07-25 06:57:21 +03:00
Leonid Pershin 4eee98467d Implement audio settings persistence in ChannelPlayer: add functionality to read and store volume and mute state in localStorage, ensuring user preferences are retained across sessions. Update playback logic to respect saved audio settings and handle autoplay restrictions gracefully. 2026-07-25 06:48:37 +03:00
Leonid Pershin 19013a61dc Implement control visibility logic in ChannelPlayer: add functionality to hide and reveal controls based on user interaction, enhancing the user experience during video playback. This includes managing visibility on mouse movements and video play/pause events. 2026-07-25 06:43:19 +03:00
Leonid Pershin 0ac2973f46 Add double-click functionality to ChannelPlayer for fullscreen toggle: enhance user interaction by allowing users to enter fullscreen mode with a double-click on the player. 2026-07-25 06:39:58 +03:00
Leonid Pershin 041df061f9 Enhance ChannelPlayer component: add volume control functionality with a slider, update mute logic to respect last volume level, and integrate volume translations for improved user experience. 2026-07-25 01:03:36 +03:00
Leonid Pershin 5ad2746ddb Refactor ShowDetail component to display loaded seasons: update season counting logic to return an array of loaded seasons, enhance UI to show the number of loaded seasons and their details, and update translations for new season information. 2026-07-25 00:54:00 +03:00
Leonid Pershin 17fdf32a3c Update RootLayout component to adjust container width based on route: integrate useRouterState for dynamic max-width handling, allowing admin pages to have a wider layout while keeping public pages compact. 2026-07-25 00:50:26 +03:00
Leonid Pershin 0cfc72166a Add TV bumpers functionality: introduce configuration options for bumpers in .env.example and appsettings.json, enhance ChannelEndpoints to manage jingles and bumper assets, and update Channel and ScheduleEntry models to support bumper logic. Implement validation for bumper settings and integrate bumper handling in scheduling logic. 2026-07-25 00:45:22 +03:00
Leonid Pershin 622bf1e440 Implement stream cookie deletion in logout and account deletion endpoints: add DeleteStreamCookie method to remove the stream cookie upon user logout and account deletion, enhancing security by preventing access to streams after token expiration. 2026-07-24 23:15:08 +03:00
Leonid Pershin 0891b4e6a8 Add loudness normalization settings: introduce NormalizeLoudness and LoudnessTargetLufs options in configuration files, update FfmpegMediaProcessor to apply loudness normalization during media processing, and document changes in media storage guide. 2026-07-24 23:08:10 +03:00
Leonid Pershin 65f2a1d4ec Enhance media upload functionality: add skipped file tracking in upload store, update UploadSnackbar to display skipped duplicates, and improve UI responsiveness for upload status indicators. 2026-07-24 22:35:09 +03:00
Leonid Pershin 8f8ce5122a Refactor media upload handling in MediaPanel: remove direct upload logic and integrate useUploadStore for file enqueueing. Update translations for upload status and add UploadSnackbar component to display upload notifications in the UI. 2026-07-24 22:25:01 +03:00
Leonid Pershin 2523808e3b Enhance schedule and show management: add season and episode information to ScheduleEntryDto, update ListShowsQueryHandler to include season counts, and modify frontend components to display this new data. Improve EPG handling by consolidating program entries into blocks for better clarity in the streaming interface. 2026-07-24 22:18:50 +03:00
Leonid Pershin 517f11c897 Enhance ShowDetail component: display season and episode information alongside asset names in a more structured format, improving user experience. Update AirPage to simplify program labels by removing episode index display, ensuring clarity in the streaming interface. 2026-07-24 21:55:28 +03:00
Leonid Pershin 1b078cdfd3 Enhance ChannelPlayer component: implement custom controls for play/pause, mute, and fullscreen functionality, and update UI to reflect live status. Integrate i18n support for live translations in both Russian and English. 2026-07-24 21:22:19 +03:00
Leonid Pershin 7b5c601821 Configure entity key generation in AppDbContext: set Guid keys for domain entities to never be generated by EF, preventing unintended updates during child entity additions. 2026-07-24 21:14:26 +03:00
Leonid Pershin 4202c51a5b Add maintenance management features: implement maintenance endpoints in the API, enhance media upload handling to skip duplicate files, and update frontend routes and translations for maintenance operations. 2026-07-24 19:52:56 +03:00
Leonid Pershin 6e7db4a6a9 Refactor media asset handling: update ListMediaAssetsQuery to accept an array of statuses, modify MediaEndpoints to handle multiple statuses, and enhance MediaProcessingQueue for improved signal handling. Update frontend components to support new status filtering and improve user experience with asset selection. 2026-07-24 19:22:22 +03:00
Leonid Pershin 8281832e1d Update routing for admin channels and shows: add index routes for channels and shows, refactor components to use Outlet for nested routing, and enhance type definitions for new routes in the routing structure. 2026-07-24 19:08:17 +03:00
Leonid Pershin b7cdc4ad96 Refactor media upload handling in MediaPanel: implement support for multiple file uploads with progress tracking, update UI to display current upload status, and enhance translations for uploaded file count. 2026-07-24 18:52:15 +03:00
Leonid Pershin 7309a25764 Remove downloads management features: delete DownloadsEndpoints and related API logic, remove associated frontend components and routes, and update documentation to reflect the removal of the downloads directory from the application structure. 2026-07-24 18:18:22 +03:00
Leonid Pershin 44d10f667c Update qbittorrent service configuration in Docker: switch to host network mode for improved DHT functionality, adjust WEBUI_PORT to 8090, and remove port mappings to prevent conflicts with app services. 2026-07-24 17:19:38 +03:00
Leonid Pershin e7840ba919 Add torrent client integration and downloads management: introduce qbittorrent service in Docker setup, implement downloads endpoint in API, and enhance media storage to handle imported files. Update frontend routes and translations for downloads management. 2026-07-24 16:42:14 +03:00
Leonid Pershin 5853009d71 Enhance AirPage and ChannelPlayer components: add error handling for unavailable streams, implement retry functionality, and update translations for offline states. Refactor ChannelPlayer to accept an onUnavailable callback for better error management. 2026-07-24 16:27:22 +03:00
Leonid Pershin a2685fb602 Add media and channel management routes: introduce new routes for admin channels, media, and shows in the routing structure. Update navigation in the admin layout to include links for these new sections. Enhance type definitions for media assets and shows in the API types. Integrate HLS.js for improved streaming support. 2026-07-24 16:18:54 +03:00
Leonid Pershin 1bbfd15907 Enhance configuration and routing for streaming features: update .env.example and appsettings.json to include new scheduler and streaming options, add streaming endpoint mapping in Program.cs, and integrate streaming services in DependencyInjection. Introduce segment path resolution in MediaPathResolver for asset management. 2026-07-24 16:07:37 +03:00
Leonid Pershin 4fa9dae37f Add broadcast scheduling features: implement Show and Channel entities, enhance AppDbContext and DependencyInjection for broadcasting, and update API routing. Include migration for new database schema and update documentation for broadcast-related functionalities. 2026-07-24 08:57:08 +03:00
Leonid Pershin e15ecbdb29 Implement media storage and processing features: update configuration in .env.example and appsettings files, enhance Docker setup for media storage, and add media-related services and database entities. Include ffmpeg for media handling and adjust Kestrel settings for large file uploads. 2026-07-24 08:33:11 +03:00
Leonid Pershin 1dd6991174 Enhance CSS for improved theme support: add color-scheme properties for light and dark modes, and explicitly define styles for native select elements to ensure proper color rendering in both themes. 2026-07-24 06:56:16 +03:00
Leonid Pershin b67cc960a0 Update infrastructure to use external PostgreSQL server instead of Docker container. Modify .env.example and documentation to reflect new connection settings and deployment instructions. 2026-07-24 06:33:11 +03:00
Leonid Pershin 8a3eebc48f Initial commit: base slice (auth, roles, users, admin) scaffold
Backend: .NET 10 Clean Architecture + LiteCqrs.Net + EF Core/PostgreSQL +
Identity/JWT. Frontend: React 19 + Vite + TanStack Query/Router + Tailwind v4
with a retro CRT theme. Docker/compose deployment mirroring PnvPanel's
conventions, scoped down to the current base feature set.
2026-07-24 05:40:34 +03:00
505 changed files with 47690 additions and 138 deletions
+95
View File
@@ -0,0 +1,95 @@
# TeleWave — пример переменных окружения.
# Скопируй в .env и заполни значения. Ключи вида Section__Key биндятся в IOptions<T> ASP.NET Core.
# ВСЕ значения-секреты ниже обязательно заменить перед запуском (особенно *_PASSWORD, *SigningKey).
# ── PostgreSQL (внешний сервер, не в compose) ─────────────────────────────
# База и пользователь создаются заранее на самом сервере БД (см. README — раздел "Развёртывание").
ConnectionStrings__Default=Host=10.10.1.134;Port=5432;Database=telewave;Username=telewave;Password=change-me-strong-db-password
# ── JWT ───────────────────────────────────────────────────────────────────
Jwt__Issuer=TeleWave
Jwt__Audience=TeleWave
Jwt__SigningKey=change-me-min-32-chars-random-secret
Jwt__AccessTokenMinutes=15
Jwt__RefreshTokenDays=30
# ── Сид администратора (создаётся при первом старте, если не существует) ───
# Логин в систему — по username. Email в системе не используется.
AdminSeed__Username=admin
AdminSeed__Password=change-me-strong-admin-password
# ── Rate limiting ────────────────────────────────────────────────────────
# Лимит запросов/мин на auth-эндпоинты (login/register/refresh). По умолчанию 20.
# RateLimiting__AuthPermitLimit=20
# ── Хранилище медиа ────────────────────────────────────────────────────────
# RootPath — путь ВНУТРИ контейнера; на хосте это bind-mount тома (см. docs/server-storage-setup.md
# и volumes в docker-compose.yml). Не меняй RootPath без синхронного изменения тома.
Storage__RootPath=/media
Storage__SegmentSeconds=2
# Сколько сегментов держать в скользящем окне live-плейлиста.
Storage__LiveWindowSegments=10
Storage__KeepOriginals=false
# Порог свободного места, ниже которого загрузка отклоняется (10 ГБ).
Storage__MinFreeSpaceBytes=10737418240
# TTL stream-токена (cookie tw_stream), минуты. Короткий срок ограничивает окно доступа после
# блокировки/логаута; фронт перевыпускает cookie, пока идёт просмотр. По умолчанию 30.
# Storage__StreamTokenMinutes=30
# ── Планировщик эфира ──────────────────────────────────────────────────────
# На сколько дней вперёд держать расписание; сколько часов прошлого хранить; период тика.
Scheduler__HorizonDays=3
Scheduler__RetentionHours=24
Scheduler__TickMinutes=30
# ── Обработка медиа (ffmpeg) ───────────────────────────────────────────────
Media__FfmpegPath=/usr/bin/ffmpeg
Media__FfprobePath=/usr/bin/ffprobe
# Максимальный размер загружаемого файла (20 ГБ).
Media__MaxUploadBytes=21474836480
# Потоки одного ffmpeg (0 — авто/все ядра).
Media__TranscodeThreads=3
# Сколько файлов транскодировать одновременно (1 — по умолчанию, как раньше).
# Тюнинг под ядра: обычно TranscodeThreads × MaxParallelTranscodes ≈ число ядер.
# 4 vCPU: Threads=3, Parallel=1 (или Threads=2, Parallel=2)
# 8 vCPU: Threads=4, Parallel=2 ← лучший throughput на пачках серий
# (или Threads=6, Parallel=1 — если важнее скорость одного файла)
# Память: один 1080p-транскод ~0.5 ГБ; Parallel=2 требует ~1 ГБ + база API.
Media__MaxParallelTranscodes=1
# Период опроса каталога inbox/ сканером, секунды.
Media__InboxScanSeconds=15
# Нормализация громкости при обработке (EBU R128 loudnorm) — все ролики одинаковой громкости.
Media__NormalizeLoudness=true
# Целевая громкость, LUFS (−16 типично для стриминга; тише — уменьшить).
Media__LoudnessTargetLufs=-16
# Таймауты вызовов ffprobe/ffmpeg, секунды (0 — без таймаута): не дают зависшему процессу вечно
# держать слот параллелизма/тик планировщика. Поднять TranscodeTimeoutSeconds для очень длинных файлов.
# Media__ProbeTimeoutSeconds=120
# Media__TranscodeTimeoutSeconds=1800
# ── ТВ-заставки «Сейчас/Далее» (bumpers) ───────────────────────────────────
# Общие для всех каналов параметры рендера. Оформление и правила (цвета, подписи, длительность,
# интервал, только-между-разными) настраиваются на каждом канале в админке.
# Bumpers__Width=1280
# Bumpers__Height=720
# Bumpers__FontFileSans=/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf
# Bumpers__FontFileSerif=/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf
# Меняй при правке ЛОГИКИ рендера, чтобы пересобрать уже отрендеренные заставки.
# Bumpers__TemplateVersion=1
# ── Метаданные шоу/серий (TMDb / OMDb) ─────────────────────────────────────
# Ключи бесплатные: TMDb — themoviedb.org (Settings → API, v3 key), OMDb — omdbapi.com.
# Без ключа источник просто не показывается в админке (ручной режим работает всегда).
Metadata__Language=ru-RU
Metadata__Tmdb__ApiKey=
Metadata__Omdb__ApiKey=
# ── ASP.NET Core ──────────────────────────────────────────────────────────
ASPNETCORE_ENVIRONMENT=Production
ASPNETCORE_HTTP_PORTS=8080
# ── Доверенные прокси (X-Forwarded-For/Proto) ──────────────────────────────
# TLS терминируется вне compose внешним прокси/шлюзом. По умолчанию доверяется только loopback.
# Если прокси стоит не на loopback, перечисли его через запятую.
# ForwardedHeaders__KnownProxies=203.0.113.10
# ForwardedHeaders__KnownNetworks=172.18.0.0/16
+43
View File
@@ -0,0 +1,43 @@
name: build
on:
push:
branches: [main]
pull_request:
jobs:
backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: Restore
working-directory: backend
run: dotnet restore TeleWave.slnx
- name: Build (Release)
working-directory: backend
run: dotnet build TeleWave.slnx -c Release --no-restore
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Enable pnpm
run: corepack enable
- name: Install
working-directory: frontend
run: pnpm install --frozen-lockfile
- name: Lint
working-directory: frontend
run: pnpm lint
- name: Typecheck
working-directory: frontend
run: pnpm typecheck
- name: Build
working-directory: frontend
run: pnpm build
+57
View File
@@ -0,0 +1,57 @@
name: tests
on:
push:
branches: [main]
pull_request:
jobs:
backend-tests:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: Restore
working-directory: backend
run: dotnet restore TeleWave.slnx
- name: Test + coverage
working-directory: backend
run: >
dotnet test TeleWave.slnx
-c Release
--no-restore
--collect:"XPlat Code Coverage"
--results-directory ./coverage
--logger "console;verbosity=normal"
# Бейдж покрытия обновляем только для main (не для PR/форков): генерируем SVG и
# force-пушим в отдельную orphan-ветку badges — README ссылается на её raw-URL.
- name: Coverage badge
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
working-directory: backend
env:
TOKEN: ${{ secrets.COVERAGE_TOKEN || secrets.GITEA_TOKEN }}
run: |
dotnet tool install --global dotnet-reportgenerator-globaltool
export PATH="$PATH:$HOME/.dotnet/tools"
reportgenerator \
"-reports:coverage/**/coverage.cobertura.xml" \
"-targetdir:coverage/badges" \
"-reporttypes:Badges" \
"-assemblyfilters:+TeleWave.Domain;+TeleWave.Application" \
"-classfilters:-System.*"
BADGE=coverage/badges/badge_linecoverage.svg
test -f "$BADGE" || { echo "badge not generated"; exit 1; }
WORK=$(mktemp -d)
cp "$BADGE" "$WORK/coverage.svg"
cd "$WORK"
git init -q -b badges
git add coverage.svg
git -c user.name=gitea-actions -c user.email=actions@local \
commit -q -m "coverage badge [skip ci]"
git push -f \
"https://gitea-actions:${TOKEN}@gitea.hsrv.site/mrleo1nid/TeleWave.git" badges
+82
View File
@@ -0,0 +1,82 @@
# ---> VisualStudioCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
# ---> VisualStudio
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
.vs/
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
*.VisualState.xml
TestResult.xml
nunit-*.xml
BenchmarkDotNet.Artifacts/
project.lock.json
project.fragment.lock.json
artifacts/
*.pdb
*.log
*.tlog
# NuGet Packages
*.nupkg
*.snupkg
**/[Pp]ackages/*
!**/[Pp]ackages/build/
# Others
*.pfx
*.publishsettings
# Node
node_modules/
dist/
dist-ssr/
# Local environment files (secrets) — keep .env.example, ignore real .env
.env
.env.local
.env.*.local
# Локальное медиахранилище для dev-запуска (Storage__RootPath в appsettings.Development.json)
.dev-media/
+149
View File
@@ -0,0 +1,149 @@
# CLAUDE.md
Инструкции для Claude Code при работе в этом репозитории.
## Что это
**TeleWave** — сервис онлайн-каналов: пользователи смотрят сетку каналов, видео отдаётся из
хранилища на сервере. Админ управляет каналами и пользователями.
> Текущее состояние — **рабочий вертикальный срез**: вход/регистрация, роли, пользователи, админка;
> библиотека шоу/серий, загрузка и обработка медиа (ffmpeg → HLS-сегменты), реестр изображений,
> метаданные (TMDb/OMDb), каталог каналов, планировщик эфира (реклама, ТВ-заставки-переходы,
> weekly-override'ы) и live-раздача HLS с публичным просмотром сетки. Telegram-бот сознательно
> не делаем. Дальнейшие крупные направления (напр. многоэкземплярное развёртывание, новые доменные
> фичи) — по сверке с пользователем.
Архитектура и код-конвенции — прямое зеркало [`D:\Github\PnvPanel`](../PnvPanel) (тот же автор,
тот же стек), но домен урезан под текущий объём фичи.
## Стек
- **Backend**: C# / .NET 10, ASP.NET Core Web API (Minimal API), Clean Architecture, CQRS через
[`LiteCqrs.Net`](https://github.com/mrleo1nid/LiteCqrs.Net) (NuGet-пакет, лёгкая
CQRS-библиотека, альтернатива MediatR с явным разделением Command/Query — см. её README).
EF Core 10 + Npgsql, ASP.NET Core Identity + JWT (access + refresh), FluentValidation, Serilog.
Маппинг DTO вручную. OpenAPI — нативный `Microsoft.AspNetCore.OpenApi` + Scalar UI.
- **Frontend**: React 19 + Vite + TS, TanStack Query/Router, shadcn-стиль поверх Radix + Tailwind
v4, Zustand (только auth+тема), react-hook-form + zod. pnpm, oxlint.
- **Инфра**: единый Docker-образ (API + статика SPA); PostgreSQL — **внешний сервер**, не в
compose (в отличие от PnvPanel).
## Архитектура — жёсткие правила
Слои и направление зависимостей: **Api → Infrastructure → Application → Domain** (внутрь).
- **Domain** — без внешних зависимостей. Сейчас единственная сущность — `Auth/RefreshToken`
(rich model: приватные сеттеры, фабричный `Issue(...)`, поведенческий `Revoke(...)`).
- **Application** — CQRS-хендлеры, DTO, валидаторы, **порты** (интерфейсы:
`IAppDbContext`, `ICurrentUser`, `IIdentityService`, `IJwtTokenService`, `IRefreshTokenService`,
`IRoleService`). Зависит только от Domain — никаких `Npgsql`/`AspNetCore.Identity`, только их
абстракции.
- **Infrastructure** — реализации портов: EF Core (`AppDbContext : IdentityDbContext<AppUser,
AppRole, Guid>`), `IdentityService`/`RoleService`/`JwtTokenService`/`RefreshTokenService`,
идемпотентный `DbInitializer` (сидинг ролей + админа).
- **Api** — Minimal API эндпоинты (`Endpoints/*Endpoints.cs`), middleware, DI composition root
(`Program.cs`).
Обязательно:
- Команды меняют состояние в транзакции (`UnitOfWorkBehavior`); запросы только читают. Диспетчер —
из `LiteCqrs.Net` (`ISender`/`ICommandHandler`/`IQueryHandler`), регистрация через
`AddLiteCqrs(...)` в `TeleWave.Application/DependencyInjection.cs`.
- Управляемые ошибки — через `Result<T>` (`Common/Models/Result.cs`), не исключениями.
- Валидация — FluentValidation через `ValidationBehavior`; хендлер не перепроверяет формат ввода.
- Всё I/O асинхронно, `CancellationToken` пробрасывается до EF/HTTP. Никаких `.Result`/`.Wait()`.
- Nullable reference types включены; `Directory.Build.props` включает
`TreatWarningsAsErrors=true` — предупреждения анализаторов не игнорировать.
## Домен: роли, пользователи, аутентификация
Полноценной доменной модели (каналы/видео) пока нет — см. заметку в начале файла. Текущие
инварианты:
- **Роли** (`AppRole : IdentityRole<Guid>`, `Infrastructure/Identity/`) — динамические,
без квот (в отличие от PnvPanel: тут нет `MaxIpLimit`/`BillingEnabled` — при появлении
доменных фич квоты/лимиты добавляются на `AppRole`/`AppUser` по мере необходимости, не заранее).
У пользователя ровно одна роль. Системные роли `admin`/`user` (`IsSystem=true`) нельзя
переименовать/удалить — проверяется в `RoleService`. Смену роли (`ChangeUserRoleCommand`)
запрещено делать так, чтобы не осталось ни одного `admin` (`RoleErrors.CannotRemoveLastAdmin`).
- **Регистрация** — открытая, без гейта активации (в отличие от PnvPanel): `RegisterCommandHandler`
создаёт пользователя с ролью `user` и сразу выдаёт токены (auto-login). Если в будущем
понадобится модерация новых пользователей — добавлять отдельным полем/флагом по аналогии с
`IsActivated` в PnvPanel, не переиспользовать `IsBlocked`.
- **Блокировка** (`AppUser.IsBlocked`) — админ блокирует/разблокирует пользователя; вход
запрещён (`LoginCommandHandler`/`RefreshCommandHandler` проверяют флаг). Админ не может
заблокировать/удалить самого себя (`UserErrors.CannotBlockSelf`/`CannotDeleteSelf`).
- **Вход по `UserName`** (email не используется, SMTP не нужен). JWT — access (короткий TTL) +
refresh (httpOnly cookie `tw_refresh_token`, `SameSite=Strict`, ротация с обнаружением повторного
использования отозванного токена — см. `RefreshTokenService.RotateAsync`).
- **Сидинг**: идемпотентный `DbInitializer` создаёт системные роли + админа из env
(`AdminSeed__Username`/`Password`) при старте. Источник примера env — `.env.example`.
## Единый контейнер
- Один образ: Api раздаёт REST (`/api`) и статику SPA из `wwwroot` (fallback на `index.html`).
Один origin, база API — относительный `/api`.
- Multi-stage Dockerfile: node (фронт) → dotnet sdk (publish + копирование в `wwwroot`) → aspnet
runtime. Никакого gosu/root-drop и Data Protection key-ring — секретов на диске пока нет
(JWT-ключ — обычная конфигурация, не шифруемый секрет at-rest); если появятся зашифрованные
данные (например API-ключи внешних сервисов) — добавлять Data Protection по образцу PnvPanel.
- docker-compose: только `app` — PostgreSQL живёт вне compose (внешний сервер/хост, адрес и
креды — в `ConnectionStrings__Default` из `.env`; база и пользователь на нём создаются
заранее вручную, приложение их не сидит). Миграции применяются авто на старте
(`ApplyMigrationsAsync` в `Program.cs`) — прав `CREATEDB` не требуется, только DDL/DML в
уже существующей базе.
- Не вводи отдельный nginx-контейнер для статики — ломает требование единого контейнера.
- **TLS — внешний**; `app` отдаёт HTTP + доверяет `X-Forwarded-*` (`ForwardedHeaders`).
## Соглашения по коду
- `<Verb><Noun>Command`/`<Get|List><Noun>Query` + `Handler`/`Validator` (валидатор — где есть что
проверить). Папки — по фичам (`Auth/Login/`, `Admin/Roles/CreateRole/`, ...).
- DTO — суффикс `Dto`; тела запросов Api-эндпоинтов, не совпадающие с командой 1:1, — `Body`
(`sealed record` внизу файла эндпоинта).
- Один публичный тип на файл = имя файла (кроме `Body` в файле эндпоинтов).
- Ошибки — статические каталоги `XErrors` (`AuthErrors`, `RoleErrors`, `UserErrors`) с
`Error.Validation/NotFound/Conflict/Unauthorized/Forbidden(...)`.
- Секреты не логировать; логи — Serilog.
- Ошибки API — единый `application/problem+json` (см. `Api/Common/ResultExtensions.cs`).
- **Изображения — через общий реестр `Image`** (домен `Domain/Images`, галерея `features/admin/images`).
Любой новый функционал, где загружается или выбирается картинка, должен использовать контрол
`ImageGallery`/`GalleryBrowser` (пикер по категориям `ImageCategory`) и хранить ссылку на `ImageId`,
а не заводить своё файловое поле. Автоматически полученные картинки (например постер из метадаты)
тоже регистрируются как `Image`. Файлы лежат под `images/{id}{ext}`, отдаются по `/api/images/{id}`.
## Команды
Backend (из `backend/`):
```bash
dotnet build
dotnet test tests/TeleWave.Domain.Tests tests/TeleWave.Application.Tests
dotnet run --project src/TeleWave.Api
dotnet ef migrations add <Name> --project src/TeleWave.Infrastructure --startup-project src/TeleWave.Api
dotnet ef database update --project src/TeleWave.Infrastructure --startup-project src/TeleWave.Api
```
Frontend (из `frontend/`):
```bash
pnpm install
pnpm dev
pnpm build
pnpm lint && pnpm typecheck
```
Инфраструктура:
```bash
docker compose up -d --build # только api; postgres — внешний, см. ConnectionStrings__Default
```
> Окружение: Windows, основная оболочка — **PowerShell**. Для POSIX-скриптов есть Bash-инструмент.
## Рабочие принципы
- Базовый вертикальный срез (каналы, планировщик, раздача HLS, обработка медиа) уже реализован —
правь его по месту. Но новые крупные направления с непринятыми архитектурными решениями (напр.
многоэкземплярное развёртывание, транскод-профили, новые доменные подсистемы) начинай только после
сверки с пользователем. При неоднозначности — вопрос пользователю, не предположение.
- Соблюдай границы слоёв — главный инвариант проекта, как и в PnvPanel.
- Не коммить и не пуши без явной просьбы.
- Отвечай пользователю на русском.
+39
View File
@@ -0,0 +1,39 @@
# syntax=docker/dockerfile:1
# ── Stage 1: сборка фронтенда (Vite → dist) ───────────────────────────────
FROM node:22-alpine AS frontend
WORKDIR /app/frontend
RUN corepack enable
COPY frontend/package.json frontend/pnpm-lock.yaml ./
RUN corepack prepare pnpm@11.9.0 --activate && pnpm install --frozen-lockfile
COPY frontend/ ./
RUN pnpm build
# ── Stage 2: publish бэкенда, статика фронта в wwwroot ────────────────────
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS backend
WORKDIR /src
COPY backend/Directory.Build.props backend/Directory.Packages.props ./backend/
COPY backend/src/TeleWave.Domain/TeleWave.Domain.csproj ./backend/src/TeleWave.Domain/
COPY backend/src/TeleWave.Application/TeleWave.Application.csproj ./backend/src/TeleWave.Application/
COPY backend/src/TeleWave.Infrastructure/TeleWave.Infrastructure.csproj ./backend/src/TeleWave.Infrastructure/
COPY backend/src/TeleWave.Api/TeleWave.Api.csproj ./backend/src/TeleWave.Api/
RUN dotnet restore backend/src/TeleWave.Api/TeleWave.Api.csproj
COPY backend/ ./backend/
# Статика собранного фронта → wwwroot (перекрывает заглушку)
COPY --from=frontend /app/frontend/dist/ ./backend/src/TeleWave.Api/wwwroot/
RUN dotnet publish backend/src/TeleWave.Api/TeleWave.Api.csproj -c Release -o /app/publish --no-restore
# ── Stage 3: runtime ──────────────────────────────────────────────────────
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
# curl — для HEALTHCHECK; ffmpeg — нормализация и нарезка медиа при загрузке (см. Media__FfmpegPath);
# fonts-dejavu-core — кириллический TTF для текста на ТВ-заставках (drawtext, см. Bumpers__FontFile).
RUN apt-get update && apt-get install -y --no-install-recommends curl ffmpeg fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
ENV ASPNETCORE_ENVIRONMENT=Production \
ASPNETCORE_HTTP_PORTS=8080
EXPOSE 8080
COPY --from=backend /app/publish ./
HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=5 \
CMD curl -f http://localhost:8080/health || exit 1
ENTRYPOINT ["dotnet", "TeleWave.Api.dll"]
+65
View File
@@ -0,0 +1,65 @@
# TeleWave
[![build](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions/workflows/build.yml/badge.svg)](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions?workflow=build.yml)
[![tests](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions/workflows/test.yml/badge.svg)](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions?workflow=test.yml)
[![coverage](https://gitea.hsrv.site/mrleo1nid/TeleWave/raw/branch/badges/coverage.svg)](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions?workflow=test.yml)
**TeleWave** — сервис онлайн-каналов: пользователи смотрят сетку каналов, видео отдаётся из
хранилища на сервере, админ управляет каналами и пользователями.
> Текущее состояние — **рабочий вертикальный срез**: вход/регистрация, роли, пользователи, админка;
> библиотека шоу/серий, загрузка и обработка медиа (ffmpeg → HLS), метаданные (TMDb/OMDb), каталог
> каналов, планировщик эфира (реклама, ТВ-заставки, weekly-override'ы) и live-раздача HLS с публичным
> просмотром сетки. Приложение (фронт + бек) поставляется **единым Docker-образом**; PostgreSQL —
> **внешний**, не поднимается через compose.
## Стек
| Слой | Технологии |
| -------- | ---------------------------------------------------------------------------------------- |
| Backend | C# / .NET 10, ASP.NET Core Web API, Clean Architecture, CQRS ([LiteCqrs.Net](https://github.com/mrleo1nid/LiteCqrs.Net)), EF Core |
| БД | PostgreSQL (Npgsql) |
| Auth | ASP.NET Core Identity + JWT (access + refresh, ротация) |
| Frontend | React 19 + Vite + TypeScript, TanStack Query/Router, shadcn/ui + Tailwind v4 (ретро-CRT тема) |
| Упаковка | Единый Docker-образ (API + статика SPA); PostgreSQL — внешний сервер |
## Развёртывание
Postgres не входит в compose — база и пользователь создаются заранее на внешнем сервере БД:
```sql
-- на сервере БД (psql -h <хост БД> -U postgres)
CREATE USER telewave WITH PASSWORD 'change-me-strong-db-password';
CREATE DATABASE telewave OWNER telewave;
```
Затем на хосте с приложением:
```bash
cp .env.example .env
# заполнить ConnectionStrings__Default (хост/база/юзер/пароль БД), Jwt__SigningKey, AdminSeed__*
docker compose up -d --build
# → http://localhost:8085 (админ — логин/пароль из .env, AdminSeed__Username/Password)
```
## Локальная разработка
```bash
# backend, из backend/
dotnet build && dotnet test tests/TeleWave.Domain.Tests tests/TeleWave.Application.Tests
dotnet run --project src/TeleWave.Api
# frontend, из frontend/
pnpm install
pnpm dev # проксирует /api на localhost:8080
```
## Документация
Пример переменных окружения (сид админа, БД, JWT) — [`.env.example`](.env.example).
Инструкции для AI-ассистента (Claude Code) — в [`CLAUDE.md`](CLAUDE.md) (стек, архитектурные
правила, доменные инварианты, соглашения по коду).
## Лицензия
Не определена.
+178
View File
@@ -0,0 +1,178 @@
# Ревью TeleWave — план исправлений
Статус чекбоксов: `[ ]` не сделано · `[x]` исправлено · `[~]` в работе.
Дата ревью: 2026-07-25. Область: backend (C#/.NET 10), frontend (React 19), инфра.
Сборка чистая, 144 теста зелёные, typecheck чистый.
---
## 🔴 Критичное
- [x] **C1. Утечка слота семафора → медиа-обработка навсегда встаёт**
`MediaProcessingBackgroundService.cs:52` — слот берётся до `ClaimNextAsync`; бросок из claim
уводит во внешний `catch` без `Release()`. После N ошибок диспетчер зависает навсегда.
_Fix: try/finally вокруг claim либо Release() в ветке ошибки._
- [x] **C2. Слабый JWT-ключ по умолчанию без fail-fast**
`appsettings.json:8` + `DependencyInjection.cs:65` — placeholder-ключ, нет проверки на
переопределение и длину; тот же ключ у stream-токенов. Забыли → обход авторизации.
_Fix: на старте кидать, если ключ = placeholder или < 32 байт._
- [x] **C3. Инъекция/поломка ffmpeg-фильтра из пользовательских подписей**
`FfmpegBumperRenderer.cs:306``EscapeText` не экранирует `,` `;` `[` `]` `%` перевод строки.
Подпись с запятой рвёт filtergraph; `[`/`]` — инъекция звеньев. Валидатор проверяет только длину.
_Fix: экранировать полный набор метасимволов filtergraph._
---
## 🟠 Среднее
### Безопасность
- [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).
- [x] **M3. Stream-токен неотзывной, TTL 6ч, игнорирует userId/блокировку**`StreamTokenService.cs:15`, `StreamingEndpoints.cs:98,123`. _(TTL 30 мин + проверка блокировки на плейлисте; фронт перевыпускает cookie)_
- [x] **M4. Rate-limiter глобальный (не партиционирован), только на `/api/auth`**`Program.cs:66`.
### Архитектура / транзакции
- [x] **M5. `ExecuteDeleteAsync` ломает границу UnitOfWork + файлы удаляются до коммита**`DeleteShowMediaCommandHandler.cs`, `ClearAllMediaCommandHandler.cs`. _(явная транзакция вокруг ExecuteDelete; файлы после коммита. DeleteAllShows — единичный ExecuteDelete, уже атомарен)_
- [x] **M6. Query с побочным эффектом на ФС** — переведён в `RenderBumperPreviewCommand` (command, не query).
- [x] **M7. Файловый/HTTP I/O до коммита** — Delete/Register хендлеры: БД коммитится раньше файлового I/O; Register откатывает регистрацию при сбое переноса файла.
### Планировщик / медиа
- [x] **M8. Нет таймаута на ffmpeg/ffprobe**`ProcessRunner.cs` + `MediaOptions` (ffprobe 120с, транскод/рендер 1800с, конфигурируемо).
- [x] **M9. Гонка при конкурентной генерации расписания канала**`ScheduleGenerator.cs` (транзакция + `pg_advisory_xact_lock` по каналу).
- [~] **M10. Рендер заставок синхронно внутри тика планировщика**`ScheduleBumperResolver.cs:174`. _(частично: M8 ограничивает худший случай, кэш делает рендеры редкими, M9 сериализует канал. Полный вынос рендера из тика — отдельная архитектурная задача, флажок ниже.)_
- [x] **M11. Override через полночь не работает**`SchedulePlannerModels.cs:40` (`EndMinute <= StartMinute` → пустое окно).
### Фронтенд
- [x] **M12. Молчаливое проглатывание ошибки → вечный скелетон**`AirPage.tsx:43`.
- [x] **M13. Повторный 401 после refresh не разлогинивает**`client.ts:80`.
- [x] **M14. Клиентская пагинация поверх усечённого ответа**`ShowDetail.tsx`, `ChannelDetail.tsx`. _(пикеры дозагружают все страницы через `listAllMedia` с safety-cap; при упоре в cap — видимое предупреждение. True server-side pagination неуместна: пикеры делают клиентские исключение/парсинг/сортировку/bulk-select)_
- [x] **M15. Дедуп загрузок только по имени файла**`upload-store.ts:143`. _(разрешено анализом: сервер сам дедупит по `OriginalFileName` — клиент это зеркалит, а `skipped` показывается. Имя+размер разошлось бы с сервером → failed-загрузки. Уникальность по имени — доменное решение, кода не менял)_
---
## 🟡 Низкое / прочее
- [x] **L1. Синхронный I/O без CancellationToken в портах удаления** — все 5 delete-методов портов переведены на `Task`+`CancellationToken`; рекурсивные удаления каталогов офлоадятся (`Task.Run`).
- [x] **L2. `ValidationBehavior` вызывает `Validate` вместо `ValidateAsync`**`ValidationBehavior.cs:24`.
- [x] **L3. Инвариант «Single = 1 серия» проверяется в хендлере, а не в агрегате**`Show.AddEpisode` кидает при обходе (+тест).
- [x] **L4. Двойной `SaveChanges` в командном пути ScheduleGenerator** — снят: генератор коммитит свою транзакцию, пост-save UnitOfWork стал no-op.
- [x] **L5. HLS-плеер не восстанавливается после fatal network/media error**`ChannelPlayer.tsx:106` (+ симметричная очистка слушателей нативного HLS).
- [x] **L6. Бесконечная повторная регистрация «падающего» файла из inbox**`InboxScannerBackgroundService.cs:92`.
- [x] **L7. Возможное переполнение int в весах шоу**`SchedulePlanner.cs:256,265`.
- [x] **L8. OpenAPI/Scalar мапятся всегда, без гейта по окружению**`Program.cs` (только Development либо флаг `Api:EnableOpenApi`).
- [x] **L9. `AllowedHosts: "*"` и dev-креды БД в appsettings.json** — осознанные dev-дефолты, переопределяются env в проде; C2 теперь форсит реальный JWT-ключ. Оставлено как задокументированное.
- [x] **L10. SSRF-поверхность в ImageDownloader** (без allowlist схемы/хоста) — `ImageDownloader.cs:16` (allowlist http/https).
- [x] **L11. Документация (CLAUDE.md/README) отстала** — обновлены разделы «текущее состояние» и рабочий принцип под реальный объём.
- [~] **L12. Пробелы в тестах** — частично: добавлены тесты на M11 (override через полночь), L3 (Single). Полное покрытие фоновых сервисов/оркестрации/эндпоинтов — отдельная задача.
---
## Журнал исправлений
### 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`: добавлен 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` без новых
предупреждений.
### 2026-07-25 — остальные средние/низкие (M3, M5M9, 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 (полностью)** — вынести рендер ТВ-заставок из тика планировщика: генерировать расписание с
плейсхолдерами и рендерить ассеты асинхронно, чтобы один канал не задерживал достройку остальных и
чтобы не держать транзакцию/advisory-lock во время ffmpeg.
- **L12 (полностью)** — интеграционные тесты фоновых сервисов (media-конвейер, планировщик),
оркестрации `ScheduleGenerator`, эндпоинтов (нужен реальный/контейнерный Postgres — InMemory не
тянет транзакции/advisory-lock/raw SQL).
- **M15 (доменно)** — если нужны разные файлы с одинаковым именем: пересмотреть уникальность
`MediaAsset` (сейчас по `OriginalFileName`).
+70
View File
@@ -0,0 +1,70 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.5.2.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "backend", "backend", "{1AE8ACA6-933B-BF2A-3671-3E2EAC007D16}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{0F9113EE-888A-26D2-68B0-4A7D0A2A8745}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeleWave.Api", "backend\src\TeleWave.Api\TeleWave.Api.csproj", "{637772B7-C13F-21C4-0C66-BA606166F604}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeleWave.Application", "backend\src\TeleWave.Application\TeleWave.Application.csproj", "{128E7BA6-6F6C-30A8-1C85-66DDE98604B6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeleWave.Domain", "backend\src\TeleWave.Domain\TeleWave.Domain.csproj", "{5157D309-1C3D-BE91-5E8F-42B9CBB9AFBE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeleWave.Infrastructure", "backend\src\TeleWave.Infrastructure\TeleWave.Infrastructure.csproj", "{9D8E3873-E632-F209-1AA0-619B3FC683AC}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{F57642F3-C37C-D174-720E-6A6AAD5BEE22}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeleWave.Application.Tests", "backend\tests\TeleWave.Application.Tests\TeleWave.Application.Tests.csproj", "{E27F38DB-A6A4-A33F-0C57-9885DD209034}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeleWave.Domain.Tests", "backend\tests\TeleWave.Domain.Tests\TeleWave.Domain.Tests.csproj", "{EA5A4356-A5CE-7650-74A5-53D50CB50872}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{637772B7-C13F-21C4-0C66-BA606166F604}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{637772B7-C13F-21C4-0C66-BA606166F604}.Debug|Any CPU.Build.0 = Debug|Any CPU
{637772B7-C13F-21C4-0C66-BA606166F604}.Release|Any CPU.ActiveCfg = Release|Any CPU
{637772B7-C13F-21C4-0C66-BA606166F604}.Release|Any CPU.Build.0 = Release|Any CPU
{128E7BA6-6F6C-30A8-1C85-66DDE98604B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{128E7BA6-6F6C-30A8-1C85-66DDE98604B6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{128E7BA6-6F6C-30A8-1C85-66DDE98604B6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{128E7BA6-6F6C-30A8-1C85-66DDE98604B6}.Release|Any CPU.Build.0 = Release|Any CPU
{5157D309-1C3D-BE91-5E8F-42B9CBB9AFBE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5157D309-1C3D-BE91-5E8F-42B9CBB9AFBE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5157D309-1C3D-BE91-5E8F-42B9CBB9AFBE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5157D309-1C3D-BE91-5E8F-42B9CBB9AFBE}.Release|Any CPU.Build.0 = Release|Any CPU
{9D8E3873-E632-F209-1AA0-619B3FC683AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9D8E3873-E632-F209-1AA0-619B3FC683AC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9D8E3873-E632-F209-1AA0-619B3FC683AC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9D8E3873-E632-F209-1AA0-619B3FC683AC}.Release|Any CPU.Build.0 = Release|Any CPU
{E27F38DB-A6A4-A33F-0C57-9885DD209034}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E27F38DB-A6A4-A33F-0C57-9885DD209034}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E27F38DB-A6A4-A33F-0C57-9885DD209034}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E27F38DB-A6A4-A33F-0C57-9885DD209034}.Release|Any CPU.Build.0 = Release|Any CPU
{EA5A4356-A5CE-7650-74A5-53D50CB50872}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EA5A4356-A5CE-7650-74A5-53D50CB50872}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EA5A4356-A5CE-7650-74A5-53D50CB50872}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EA5A4356-A5CE-7650-74A5-53D50CB50872}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{0F9113EE-888A-26D2-68B0-4A7D0A2A8745} = {1AE8ACA6-933B-BF2A-3671-3E2EAC007D16}
{637772B7-C13F-21C4-0C66-BA606166F604} = {0F9113EE-888A-26D2-68B0-4A7D0A2A8745}
{128E7BA6-6F6C-30A8-1C85-66DDE98604B6} = {0F9113EE-888A-26D2-68B0-4A7D0A2A8745}
{5157D309-1C3D-BE91-5E8F-42B9CBB9AFBE} = {0F9113EE-888A-26D2-68B0-4A7D0A2A8745}
{9D8E3873-E632-F209-1AA0-619B3FC683AC} = {0F9113EE-888A-26D2-68B0-4A7D0A2A8745}
{F57642F3-C37C-D174-720E-6A6AAD5BEE22} = {1AE8ACA6-933B-BF2A-3671-3E2EAC007D16}
{E27F38DB-A6A4-A33F-0C57-9885DD209034} = {F57642F3-C37C-D174-720E-6A6AAD5BEE22}
{EA5A4356-A5CE-7650-74A5-53D50CB50872} = {F57642F3-C37C-D174-720E-6A6AAD5BEE22}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {CD8641E5-D878-4C5E-BE1C-314F2280EC6E}
EndGlobalSection
EndGlobal
+14
View File
@@ -0,0 +1,14 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<AnalysisLevel>latest</AnalysisLevel>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<NoWarn>$(NoWarn);CA1711;CA1716;CA1848;CA1873</NoWarn>
</PropertyGroup>
</Project>
+47
View File
@@ -0,0 +1,47 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="FluentValidation" Version="12.1.1" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
<PackageVersion Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.11" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.10" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.10" />
<PackageVersion
Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore"
Version="10.0.10"
/>
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.10" />
<!-- Держим 2.x: Microsoft.AspNetCore.OpenApi 10.0.10 сам зависит от Microsoft.OpenApi >=2.0.0
(nuspec) и его Roslyn source generator (XmlCommentGenerator) скомпилирован под 2.x API —
3.x меняет IOpenApiMediaType.Example на read-only и ломает генерацию (CS0200). -->
<PackageVersion Include="Microsoft.OpenApi" Version="2.11.0" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.16" />
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.10" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageVersion>
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.10" />
<PackageVersion
Include="Microsoft.Extensions.DependencyInjection.Abstractions"
Version="10.0.10"
/>
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.10" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.21.0" />
<PackageVersion Include="LiteCqrs.Net" Version="1.0.1" />
<!-- Тестирование -->
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="NSubstitute" Version="6.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.10" />
</ItemGroup>
</Project>
+12
View File
@@ -0,0 +1,12 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/TeleWave.Api/TeleWave.Api.csproj" />
<Project Path="src/TeleWave.Application/TeleWave.Application.csproj" />
<Project Path="src/TeleWave.Domain/TeleWave.Domain.csproj" />
<Project Path="src/TeleWave.Infrastructure/TeleWave.Infrastructure.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/TeleWave.Domain.Tests/TeleWave.Domain.Tests.csproj" />
<Project Path="tests/TeleWave.Application.Tests/TeleWave.Application.Tests.csproj" />
</Folder>
</Solution>
@@ -0,0 +1,4 @@
namespace TeleWave.Api.Common;
/// <summary>Единый ответ на создание сущности — её идентификатор.</summary>
public sealed record CreatedIdResponse(Guid Id);
@@ -0,0 +1,6 @@
namespace TeleWave.Api.Common;
public static class RateLimiting
{
public const string AuthPolicy = "auth";
}
@@ -0,0 +1,27 @@
using TeleWave.Application.Common.Models;
namespace TeleWave.Api.Common;
public static class ResultExtensions
{
public static IResult ToHttpResult(this Result result) =>
result.IsSuccess ? Results.NoContent() : ToProblem(result.Error);
public static IResult ToHttpResult<T>(this Result<T> result) =>
result.IsSuccess ? Results.Ok(result.Value) : ToProblem(result.Error);
public static IResult ToProblem(this Error error)
{
var statusCode = error.Type switch
{
ErrorType.Validation => StatusCodes.Status400BadRequest,
ErrorType.Unauthorized => StatusCodes.Status401Unauthorized,
ErrorType.Forbidden => StatusCodes.Status403Forbidden,
ErrorType.NotFound => StatusCodes.Status404NotFound,
ErrorType.Conflict => StatusCodes.Status409Conflict,
_ => StatusCodes.Status422UnprocessableEntity,
};
return Results.Problem(title: error.Code, detail: error.Message, statusCode: statusCode);
}
}
@@ -0,0 +1,135 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Admin.Users.BlockUser;
using TeleWave.Application.Admin.Users.CreateUser;
using TeleWave.Application.Admin.Users.DeleteUser;
using TeleWave.Application.Admin.Users.GetUser;
using TeleWave.Application.Admin.Users.ListUsers;
using TeleWave.Application.Admin.Users.ResetPassword;
using TeleWave.Application.Admin.Users.UnblockUser;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class AdminUserEndpoints
{
public static IEndpointRouteBuilder MapAdminUserEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/users")
.WithTags("Admin.Users")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", ListUsers).Produces<PagedList<UserSummaryDto>>();
admin.MapPost("", CreateUser).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapGet("/{id:guid}", GetUser).Produces<UserSummaryDto>();
admin.MapPost("/{id:guid}/block", BlockUser).Produces(StatusCodes.Status204NoContent);
admin.MapPost("/{id:guid}/unblock", UnblockUser).Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{id:guid}/password", ResetPassword)
.Produces(StatusCodes.Status204NoContent);
admin.MapDelete("/{id:guid}", DeleteUser).Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> ListUsers(
int page,
int pageSize,
string? search,
Guid? roleId,
bool? isBlocked,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ListUsersQuery(
page <= 0 ? 1 : page,
pageSize <= 0 ? 20 : pageSize,
search,
roleId,
isBlocked
),
cancellationToken
);
return Results.Ok(result);
}
private static async Task<IResult> CreateUser(
CreateUserBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new CreateUserCommand(body.UserName, body.Password, body.RoleId),
cancellationToken
);
return result.IsSuccess
? Results.Created(
$"/api/admin/users/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> GetUser(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetUserQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> BlockUser(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new BlockUserCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UnblockUser(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new UnblockUserCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ResetPassword(
Guid id,
ResetPasswordBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ResetUserPasswordCommand(id, body.NewPassword),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteUser(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteUserCommand(id), cancellationToken);
return result.ToHttpResult();
}
}
public sealed record CreateUserBody(string UserName, string Password, Guid RoleId);
public sealed record ResetPasswordBody(string NewPassword);
@@ -0,0 +1,250 @@
using LiteCqrs;
using Microsoft.Extensions.Hosting;
using TeleWave.Api.Common;
using TeleWave.Application.Auth;
using TeleWave.Application.Auth.ChangePassword;
using TeleWave.Application.Auth.ChangeUserName;
using TeleWave.Application.Auth.DeleteMyAccount;
using TeleWave.Application.Auth.Login;
using TeleWave.Application.Auth.Logout;
using TeleWave.Application.Auth.Me;
using TeleWave.Application.Auth.Refresh;
using TeleWave.Application.Auth.Register;
using TeleWave.Application.Settings.GetSiteSettings;
namespace TeleWave.Api.Endpoints;
public static class AuthEndpoints
{
private const string RefreshCookieName = "tw_refresh_token";
public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/auth")
.WithTags("Auth")
.RequireRateLimiting(RateLimiting.AuthPolicy);
group.MapGet("/registration", RegistrationStatus).Produces<RegistrationStatusDto>();
group.MapPost("/register", Register).Produces<AuthResponseDto>();
group.MapPost("/login", Login).Produces<AuthResponseDto>();
group.MapPost("/refresh", Refresh).Produces<AuthResponseDto>();
group
.MapPost("/logout", Logout)
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent);
group
.MapPost("/change-password", ChangePassword)
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent);
group
.MapPost("/change-username", ChangeUserName)
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent);
group.MapGet("/me", Me).RequireAuthorization().Produces<CurrentUserDto>();
group
.MapDelete("/me", DeleteMe)
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> RegistrationStatus(
ISender sender,
CancellationToken cancellationToken
)
{
var settings = await sender.Send(new GetSiteSettingsQuery(), cancellationToken);
return Results.Ok(new RegistrationStatusDto(settings.RegistrationEnabled));
}
private static async Task<IResult> Register(
RegisterCommand command,
ISender sender,
HttpRequest request,
HttpResponse response,
IHostEnvironment env,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
if (!result.IsSuccess)
return result.ToHttpResult();
SetRefreshCookie(
response,
result.Value.RefreshToken,
result.Value.RefreshTokenExpiresAt,
UseSecureCookie(request, env)
);
return Results.Ok(ToLoginResponse(result.Value));
}
private static async Task<IResult> Login(
LoginCommand command,
ISender sender,
HttpRequest request,
HttpResponse response,
IHostEnvironment env,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
if (!result.IsSuccess)
return result.ToHttpResult();
SetRefreshCookie(
response,
result.Value.RefreshToken,
result.Value.RefreshTokenExpiresAt,
UseSecureCookie(request, env)
);
return Results.Ok(ToLoginResponse(result.Value));
}
private static async Task<IResult> Refresh(
HttpRequest request,
HttpResponse response,
ISender sender,
IHostEnvironment env,
CancellationToken cancellationToken
)
{
if (
!request.Cookies.TryGetValue(RefreshCookieName, out var rawToken)
|| string.IsNullOrEmpty(rawToken)
)
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(secure));
return result.ToHttpResult();
}
SetRefreshCookie(
response,
result.Value.RefreshToken,
result.Value.RefreshTokenExpiresAt,
secure
);
return Results.Ok(ToLoginResponse(result.Value));
}
internal static AuthResponseDto ToLoginResponse(AuthResult auth) =>
new(auth.AccessToken, auth.AccessTokenExpiresAt, auth.User);
private static async Task<IResult> Logout(
HttpRequest request,
HttpResponse response,
ISender sender,
IHostEnvironment env,
CancellationToken cancellationToken
)
{
if (
request.Cookies.TryGetValue(RefreshCookieName, out var rawToken)
&& !string.IsNullOrEmpty(rawToken)
)
await sender.Send(new LogoutCommand(rawToken), cancellationToken);
var secure = UseSecureCookie(request, env);
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(secure));
DeleteStreamCookie(response, secure);
return Results.NoContent();
}
private static async Task<IResult> ChangePassword(
ChangePasswordCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ChangeUserName(
ChangeUserNameCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> Me(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new GetCurrentUserQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteMe(
HttpRequest request,
HttpResponse response,
ISender sender,
IHostEnvironment env,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteMyAccountCommand(), cancellationToken);
var secure = UseSecureCookie(request, env);
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(secure));
DeleteStreamCookie(response, secure);
return result.ToHttpResult();
}
private static void SetRefreshCookie(
HttpResponse response,
string rawToken,
DateTimeOffset expiresAt,
bool secure
)
{
var options = BuildCookieOptions(secure);
options.Expires = expiresAt;
response.Cookies.Append(RefreshCookieName, rawToken, options);
}
private static CookieOptions BuildCookieOptions(bool secure) =>
new()
{
HttpOnly = true,
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(HttpResponse response, bool secure) =>
response.Cookies.Delete(
"tw_stream",
new CookieOptions
{
HttpOnly = true,
Secure = secure,
SameSite = SameSiteMode.Strict,
Path = "/api",
}
);
}
public sealed record AuthResponseDto(
string AccessToken,
DateTimeOffset ExpiresAt,
CurrentUserDto User
);
/// <summary>Публичный ответ: включена ли открытая регистрация (для страниц входа/регистрации).</summary>
public sealed record RegistrationStatusDto(bool Enabled);
@@ -0,0 +1,343 @@
using System.Text;
using System.Text.RegularExpressions;
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast;
using TeleWave.Infrastructure.Media;
namespace TeleWave.Api.Endpoints;
/// <summary>Эндпоинты ТВ-заставок канала: блоки (стиль/аудио/фон), подблоки и рендер превью.</summary>
public static partial class ChannelEndpoints
{
private static readonly Regex BumperSegmentFileName = new(
@"^seg\d{1,6}\.ts$",
RegexOptions.Compiled
);
private static async Task<IResult> AddBumperTemplate(
Guid id,
AddBumperTemplateBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new AddBumperTemplateCommand(id, body.Name),
cancellationToken
);
return result.IsSuccess
? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value))
: result.ToHttpResult();
}
private static async Task<IResult> UpdateBumperTemplate(
Guid id,
Guid templateId,
UpdateBumperTemplateBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateBumperTemplateCommand(
id,
templateId,
body.Name,
body.BackgroundColor,
body.BackgroundColor2,
body.AccentColor,
body.TextColor
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> RemoveBumperTemplate(
Guid id,
Guid templateId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new RemoveBumperTemplateCommand(id, templateId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> UploadTemplateAudio(
Guid id,
Guid templateId,
string fileName,
HttpRequest request,
IBumperTemplateStorage storage,
IAudioProbe probe,
ISender sender,
CancellationToken cancellationToken
)
{
if (ResolveBumperExtension(fileName, request, BumperFiles.AudioExtensions) is not { } ext)
return ChannelErrors.InvalidBumperFile.ToProblem();
await storage.SaveAudioAsync(templateId, ext, request.Body, cancellationToken);
// Длина заставки идёт по длине звука — замеряем ffprobe (при неудаче 0 → дефолтная длина).
var path = storage.AudioPath(templateId, ext);
var duration = path is null
? null
: await probe.TryGetDurationAsync(path, cancellationToken);
var result = await sender.Send(
new SetBumperTemplateAudioCommand(id, templateId, ext, duration?.TotalSeconds ?? 0),
cancellationToken
);
if (!result.IsSuccess)
await storage.DeleteAudioAsync(templateId, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ClearTemplateAudio(
Guid id,
Guid templateId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ClearBumperTemplateAudioCommand(id, templateId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> SetTemplateBackground(
Guid id,
Guid templateId,
SetBumperTemplateBackgroundBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new SetBumperTemplateBackgroundCommand(id, templateId, body.ImageId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> ClearTemplateBackground(
Guid id,
Guid templateId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ClearBumperTemplateBackgroundCommand(id, templateId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> AddBumperVariant(
Guid id,
Guid templateId,
AddBumperVariantBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new AddBumperTextVariantCommand(id, templateId, body.Name),
cancellationToken
);
return result.IsSuccess
? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value))
: result.ToHttpResult();
}
private static async Task<IResult> UpdateBumperVariant(
Guid id,
Guid templateId,
Guid variantId,
UpdateBumperVariantBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateBumperTextVariantCommand(
id,
templateId,
variantId,
body.Name,
body.Kind,
body.NowLabel,
body.NextLabel,
body.Line1,
body.Line2,
body.Trigger,
body.Weight
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> RemoveBumperVariant(
Guid id,
Guid templateId,
Guid variantId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new RemoveBumperTextVariantCommand(id, templateId, variantId),
cancellationToken
);
return result.ToHttpResult();
}
/// <summary>Синхронно рендерит пример заставки блока (несколько секунд ffmpeg).</summary>
private static async Task<IResult> RenderPreview(
Guid id,
Guid templateId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new RenderBumperPreviewCommand(id, templateId),
cancellationToken
);
return result.IsSuccess ? Results.NoContent() : result.ToHttpResult();
}
/// <summary>Плейлист превью подблока: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
private static IResult PreviewPlaylist(
Guid id,
Guid templateId,
Guid variantId,
MediaPathResolver paths
)
{
var previewId = BumperPreview.AssetId(variantId);
string indexPath;
try
{
indexPath = paths.SegmentPath(previewId, "index.m3u8");
}
catch (UnauthorizedAccessException)
{
return Results.NotFound();
}
if (!File.Exists(indexPath))
return Results.NotFound();
var baseUrl =
$"/api/admin/channels/{id}/bumper/templates/{templateId}/preview/{variantId}/";
var sb = new StringBuilder();
foreach (var line in File.ReadLines(indexPath))
{
var trimmed = line.Trim();
if (trimmed.Length == 0)
continue;
// Комментарии/директивы — как есть; строки-сегменты (абсолютный путь от ffmpeg) → admin-URL.
sb.Append(trimmed.StartsWith('#') ? trimmed : baseUrl + Path.GetFileName(trimmed))
.Append('\n');
}
return Results.Text(sb.ToString(), "application/vnd.apple.mpegurl");
}
private static IResult PreviewSegment(
Guid id,
Guid templateId,
Guid variantId,
string file,
MediaPathResolver paths
)
{
if (!BumperSegmentFileName.IsMatch(file))
return Results.NotFound();
var previewId = BumperPreview.AssetId(variantId);
string path;
try
{
path = paths.SegmentPath(previewId, file);
}
catch (UnauthorizedAccessException)
{
return Results.NotFound();
}
if (!File.Exists(path))
return Results.NotFound();
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
}
/// <summary>Проверяет расширение файла (по allowlist) и размер (Content-Length). Возвращает
/// нормализованное расширение (с точкой, нижний регистр) или null при отказе.</summary>
private static string? ResolveBumperExtension(
string fileName,
HttpRequest request,
IReadOnlySet<string> allowedExtensions
)
{
if (request.ContentLength is > BumperFiles.MaxBytes or 0 or null)
return null;
var ext = Path.GetExtension(fileName).ToLowerInvariant();
return allowedExtensions.Contains(ext) ? ext : null;
}
}
public sealed record AddBumperTemplateBody(string Name);
public sealed record UpdateBumperTemplateBody(
string Name,
string BackgroundColor,
string BackgroundColor2,
string AccentColor,
string TextColor
);
public sealed record SetBumperTemplateBackgroundBody(Guid ImageId);
public sealed record AddBumperVariantBody(string Name);
public sealed record UpdateBumperVariantBody(
string Name,
BumperTextKind Kind,
string NowLabel,
string NextLabel,
string Line1,
string Line2,
BumperTrigger Trigger,
int Weight
);
/// <summary>Ограничения на загружаемый звук блока заставки (фон-картинка — через общий реестр).</summary>
internal static class BumperFiles
{
public const long MaxBytes = 200L * 1024 * 1024; // 200 МБ
public static readonly IReadOnlySet<string> AudioExtensions = new HashSet<string>(
StringComparer.OrdinalIgnoreCase
)
{
".mp3",
".m4a",
".aac",
".ogg",
".opus",
".wav",
};
}
@@ -0,0 +1,62 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Broadcast.CreateOverride;
using TeleWave.Application.Broadcast.DeleteOverride;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Api.Endpoints;
/// <summary>Эндпоинты временных override'ов / марафонов канала (разовые и еженедельные).</summary>
public static partial class ChannelEndpoints
{
private static async Task<IResult> CreateOverride(
Guid id,
CreateOverrideBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new CreateProgrammingOverrideCommand(
id,
body.Mode,
body.Recurrence,
body.StartsAtUtc,
body.EndsAtUtc,
body.DayOfWeek,
body.StartMinute,
body.EndMinute,
body.Shows
),
cancellationToken
);
return result.IsSuccess
? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value))
: result.ToHttpResult();
}
private static async Task<IResult> DeleteOverride(
Guid id,
Guid overrideId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new DeleteProgrammingOverrideCommand(id, overrideId),
cancellationToken
);
return result.ToHttpResult();
}
}
public sealed record CreateOverrideBody(
OverrideMode Mode,
OverrideRecurrence Recurrence,
DateTimeOffset? StartsAtUtc,
DateTimeOffset? EndsAtUtc,
int? DayOfWeek,
int? StartMinute,
int? EndMinute,
IReadOnlyList<OverrideShowInput> Shows
);
@@ -0,0 +1,122 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Broadcast.AddChannelAd;
using TeleWave.Application.Broadcast.AddChannelShow;
using TeleWave.Application.Broadcast.RemoveChannelAd;
using TeleWave.Application.Broadcast.RemoveChannelShow;
using TeleWave.Application.Broadcast.UpdateChannelShow;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Api.Endpoints;
/// <summary>Эндпоинты канала: шоу в ротации и рекламный пул.</summary>
public static partial class ChannelEndpoints
{
private static async Task<IResult> AddShow(
Guid id,
AddChannelShowBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new AddChannelShowCommand(
id,
body.ShowId,
body.Weight,
body.BlockMode,
body.BlockValue
),
cancellationToken
);
return result.IsSuccess
? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value))
: result.ToHttpResult();
}
private static async Task<IResult> UpdateShow(
Guid id,
Guid channelShowId,
UpdateChannelShowBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateChannelShowCommand(
id,
channelShowId,
body.Weight,
body.BlockMode,
body.BlockValue,
body.IsEnabled,
body.PreferredWeightMultiplier,
body.PreferredHours ?? []
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> RemoveShow(
Guid id,
Guid channelShowId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new RemoveChannelShowCommand(id, channelShowId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> AddAd(
Guid id,
AddChannelAdBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new AddChannelAdCommand(id, body.MediaAssetId),
cancellationToken
);
return result.IsSuccess
? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value))
: result.ToHttpResult();
}
private static async Task<IResult> RemoveAd(
Guid id,
Guid channelAdId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new RemoveChannelAdCommand(id, channelAdId),
cancellationToken
);
return result.ToHttpResult();
}
}
public sealed record AddChannelShowBody(
Guid ShowId,
int Weight,
BlockMode BlockMode,
int BlockValue
);
public sealed record UpdateChannelShowBody(
int Weight,
BlockMode BlockMode,
int BlockValue,
bool IsEnabled,
int PreferredWeightMultiplier,
IReadOnlyList<HourWindowInput> PreferredHours
);
public sealed record AddChannelAdBody(Guid MediaAssetId);
@@ -0,0 +1,217 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Broadcast.CreateChannel;
using TeleWave.Application.Broadcast.GetChannel;
using TeleWave.Application.Broadcast.GetSchedule;
using TeleWave.Application.Broadcast.ListChannels;
using TeleWave.Application.Broadcast.RegenerateSchedule;
using TeleWave.Application.Broadcast.UpdateChannelSettings;
using TeleWave.Domain.Broadcast;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
/// <summary>
/// Админ-эндпоинты канала. Реализация разнесена по partial-файлам под-ресурсов:
/// <c>ChannelEndpoints.Shows.cs</c> (шоу+реклама), <c>ChannelEndpoints.Bumpers.cs</c>
/// (блоки/подблоки/файлы/preview), <c>ChannelEndpoints.Overrides.cs</c> (override'ы). Здесь —
/// регистрация всех маршрутов и хендлеры уровня канала (создание/список/настройки/расписание).
/// </summary>
public static partial class ChannelEndpoints
{
public static IEndpointRouteBuilder MapChannelEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/channels")
.WithTags("Admin.Channels")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapPost("", CreateChannel).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapGet("", ListChannels).Produces<IReadOnlyList<ChannelSummaryDto>>();
admin.MapGet("/{id:guid}", GetChannel).Produces<ChannelDto>();
admin
.MapPut("/{id:guid}/settings", UpdateSettings)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{id:guid}/shows", AddShow)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapPut("/{id:guid}/shows/{channelShowId:guid}", UpdateShow)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/{id:guid}/shows/{channelShowId:guid}", RemoveShow)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{id:guid}/ads", AddAd)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapDelete("/{id:guid}/ads/{channelAdId:guid}", RemoveAd)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{id:guid}/bumper/templates", AddBumperTemplate)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapPut("/{id:guid}/bumper/templates/{templateId:guid}", UpdateBumperTemplate)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/{id:guid}/bumper/templates/{templateId:guid}", RemoveBumperTemplate)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPut("/{id:guid}/bumper/templates/{templateId:guid}/audio", UploadTemplateAudio)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/{id:guid}/bumper/templates/{templateId:guid}/audio", ClearTemplateAudio)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPut(
"/{id:guid}/bumper/templates/{templateId:guid}/background",
SetTemplateBackground
)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete(
"/{id:guid}/bumper/templates/{templateId:guid}/background",
ClearTemplateBackground
)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{id:guid}/bumper/templates/{templateId:guid}/preview", RenderPreview)
.Produces(StatusCodes.Status204NoContent);
admin.MapGet(
"/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/index.m3u8",
PreviewPlaylist
);
admin.MapGet(
"/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/{file}",
PreviewSegment
);
admin
.MapPost("/{id:guid}/bumper/templates/{templateId:guid}/variants", AddBumperVariant)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapPut(
"/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}",
UpdateBumperVariant
)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete(
"/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}",
RemoveBumperVariant
)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{id:guid}/overrides", CreateOverride)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapDelete("/{id:guid}/overrides/{overrideId:guid}", DeleteOverride)
.Produces(StatusCodes.Status204NoContent);
admin.MapPost("/{id:guid}/regenerate", Regenerate).Produces(StatusCodes.Status204NoContent);
admin
.MapGet("/{id:guid}/schedule", GetSchedule)
.Produces<IReadOnlyList<ScheduleEntryDto>>();
return app;
}
private static async Task<IResult> CreateChannel(
CreateChannelCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.IsSuccess
? Results.Created(
$"/api/admin/channels/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> ListChannels(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListChannelsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> GetChannel(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetChannelQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateSettings(
Guid id,
UpdateChannelSettingsBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateChannelSettingsCommand(
id,
body.Name,
body.IsEnabled,
body.AdInsertion,
body.AdsPerBreak,
body.BumpersEnabled,
body.Bumper,
body.FillerAssetId
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> Regenerate(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new RegenerateChannelScheduleCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetSchedule(
Guid id,
DateTimeOffset? from,
DateTimeOffset? to,
ISender sender,
CancellationToken cancellationToken
)
{
var fromUtc = from ?? DateTimeOffset.UtcNow;
var toUtc = to ?? fromUtc.AddDays(1);
var result = await sender.Send(
new GetChannelScheduleQuery(id, fromUtc, toUtc),
cancellationToken
);
return result.ToHttpResult();
}
}
public sealed record UpdateChannelSettingsBody(
string Name,
bool IsEnabled,
AdInsertion AdInsertion,
int AdsPerBreak,
bool BumpersEnabled,
BumperSettingsInput Bumper,
Guid? FillerAssetId
);
@@ -0,0 +1,129 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Images;
using TeleWave.Application.Images.DeleteImage;
using TeleWave.Application.Images.GetImageFile;
using TeleWave.Application.Images.ListImages;
using TeleWave.Application.Images.UploadImage;
using TeleWave.Domain.Images;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class ImageEndpoints
{
private const long MaxBytes = 50L * 1024 * 1024; // 50 МБ
private static readonly IReadOnlySet<string> AllowedExtensions = new HashSet<string>(
StringComparer.OrdinalIgnoreCase
)
{
".jpg",
".jpeg",
".png",
".webp",
".bmp",
".gif",
};
public static IEndpointRouteBuilder MapImageEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/images")
.WithTags("Admin.Images")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", ListImages).Produces<IReadOnlyList<ImageDto>>();
admin.MapPost("", UploadImage).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapDelete("/{id:guid}", DeleteImage).Produces(StatusCodes.Status204NoContent);
// Публичная отдача файла (для <img> у зрителя и в админке).
app.MapGet("/api/images/{id:guid}", ServeImage).WithTags("Images");
return app;
}
private static async Task<IResult> ListImages(
ImageCategory category,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListImagesQuery(category), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UploadImage(
ImageCategory category,
string fileName,
HttpRequest request,
IImageStore storage,
ISender sender,
CancellationToken cancellationToken
)
{
if (request.ContentLength is > MaxBytes or 0 or null)
return ImageErrors.InvalidFile.ToProblem();
var ext = Path.GetExtension(fileName).ToLowerInvariant();
if (!AllowedExtensions.Contains(ext))
return ImageErrors.InvalidFile.ToProblem();
var created = await sender.Send(
new UploadImageCommand(category, ext, fileName),
cancellationToken
);
if (!created.IsSuccess)
return created.ToHttpResult();
try
{
await storage.SaveAsync(created.Value, ext, request.Body, cancellationToken);
}
catch
{
// Файл не сохранился — не оставляем висячую запись реестра.
await sender.Send(new DeleteImageCommand(created.Value), cancellationToken);
throw;
}
return Results.Created(
$"/api/images/{created.Value}",
new CreatedIdResponse(created.Value)
);
}
private static async Task<IResult> DeleteImage(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteImageCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ServeImage(
Guid id,
HttpResponse response,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetImageFileQuery(id), cancellationToken);
if (!result.IsSuccess)
return Results.NotFound();
response.Headers.CacheControl = "public, max-age=86400";
return Results.File(result.Value, ContentTypeFor(Path.GetExtension(result.Value)));
}
private static string ContentTypeFor(string extension) =>
extension.ToLowerInvariant() switch
{
".png" => "image/png",
".webp" => "image/webp",
".gif" => "image/gif",
".bmp" => "image/bmp",
_ => "image/jpeg",
};
}
@@ -0,0 +1,52 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Maintenance.ClearAllMedia;
using TeleWave.Application.Maintenance.DeleteAllShows;
using TeleWave.Application.Maintenance.DeleteShowMedia;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class MaintenanceEndpoints
{
public static IEndpointRouteBuilder MapMaintenanceEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/maintenance")
.WithTags("Admin.Maintenance")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapPost("/clear-media", ClearMedia).Produces<int>();
admin.MapPost("/clear-shows", ClearShows).Produces<int>();
admin.MapPost("/shows/{showId:guid}/clear-media", ClearShowMedia).Produces<int>();
return app;
}
private static async Task<IResult> ClearMedia(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ClearAllMediaCommand(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ClearShows(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteAllShowsCommand(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ClearShowMedia(
Guid showId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteShowMediaCommand(showId), cancellationToken);
return result.ToHttpResult();
}
}
@@ -0,0 +1,140 @@
using LiteCqrs;
using Microsoft.Extensions.Options;
using TeleWave.Api.Common;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Media;
using TeleWave.Application.Media.Delete;
using TeleWave.Application.Media.GetMedia;
using TeleWave.Application.Media.ListMedia;
using TeleWave.Application.Media.Register;
using TeleWave.Domain.Media;
using TeleWave.Infrastructure.Identity;
using TeleWave.Infrastructure.Media;
namespace TeleWave.Api.Endpoints;
public static class MediaEndpoints
{
public static IEndpointRouteBuilder MapMediaEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/media")
.WithTags("Admin.Media")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapPost("", Upload).Produces<UploadMediaResponse>(StatusCodes.Status201Created);
admin.MapGet("", List).Produces<PagedList<MediaAssetDto>>();
admin.MapGet("/{id:guid}", Get).Produces<MediaAssetDto>();
admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent);
return app;
}
/// <summary>
/// Потоковая загрузка: тело запроса — сырые байты файла, имя передаётся в query «fileName».
/// Файл стримится на диск без буферизации в память, затем регистрируется и уходит в обработку.
/// </summary>
private static async Task<IResult> Upload(
string fileName,
HttpRequest request,
IMediaStorage storage,
IMediaProcessingQueue queue,
ISender sender,
IOptions<MediaOptions> mediaOptions,
IOptions<StorageOptions> storageOptions,
CancellationToken cancellationToken
)
{
if (string.IsNullOrWhiteSpace(fileName))
return Results.Problem(
title: MediaErrors.EmptyFileName.Code,
detail: MediaErrors.EmptyFileName.Message,
statusCode: StatusCodes.Status400BadRequest
);
if (!MediaFormats.IsAllowed(fileName))
return Results.Problem(
title: MediaErrors.UnsupportedFormat.Code,
detail: MediaErrors.UnsupportedFormat.Message,
statusCode: StatusCodes.Status400BadRequest
);
var contentLength = request.ContentLength ?? 0;
if (contentLength > mediaOptions.Value.MaxUploadBytes)
return Results.Problem(
title: MediaErrors.FileTooLarge.Code,
detail: MediaErrors.FileTooLarge.Message,
statusCode: StatusCodes.Status400BadRequest
);
var free = storage.GetAvailableFreeSpaceBytes();
if (free - contentLength < storageOptions.Value.MinFreeSpaceBytes)
return Results.Problem(
title: MediaErrors.InsufficientStorage.Code,
detail: MediaErrors.InsufficientStorage.Message,
statusCode: StatusCodes.Status409Conflict
);
var extension = Path.GetExtension(fileName);
var token = await storage.SaveUploadAsync(request.Body, extension, cancellationToken);
var result = await sender.Send(
new RegisterMediaAssetCommand(token, MediaSource.Upload, fileName),
cancellationToken
);
if (!result.IsSuccess)
{
await storage.DeleteUploadAsync(token, cancellationToken);
return result.ToHttpResult();
}
queue.Enqueue(result.Value);
return Results.Created(
$"/api/admin/media/{result.Value}",
new UploadMediaResponse(result.Value)
);
}
private static async Task<IResult> List(
int page,
int pageSize,
MediaAssetStatus[]? status,
string? search,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ListMediaAssetsQuery(
page <= 0 ? 1 : page,
pageSize <= 0 ? 20 : pageSize,
status ?? [],
search
),
cancellationToken
);
return Results.Ok(result);
}
private static async Task<IResult> Get(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetMediaAssetQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> Delete(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteMediaAssetCommand(id), cancellationToken);
return result.ToHttpResult();
}
}
public sealed record UploadMediaResponse(Guid Id);
@@ -0,0 +1,192 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Images.DeleteImage;
using TeleWave.Application.Images.UploadImage;
using TeleWave.Application.Metadata;
using TeleWave.Application.Metadata.ApplyShowMetadata;
using TeleWave.Application.Metadata.ClearShowMetadata;
using TeleWave.Application.Metadata.GetProviders;
using TeleWave.Application.Metadata.RefreshEpisodes;
using TeleWave.Application.Metadata.SearchShows;
using TeleWave.Application.Metadata.SetShowPoster;
using TeleWave.Application.Metadata.UpdateShowMetadata;
using TeleWave.Domain.Images;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class MetadataEndpoints
{
private const long MaxPosterBytes = 10L * 1024 * 1024; // 10 МБ
private static readonly IReadOnlySet<string> PosterExtensions = new HashSet<string>(
StringComparer.OrdinalIgnoreCase
)
{
".jpg",
".jpeg",
".png",
".webp",
};
public static IEndpointRouteBuilder MapMetadataEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/metadata")
.WithTags("Admin.Metadata")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("/providers", GetProviders).Produces<IReadOnlyList<string>>();
admin.MapGet("/search", Search).Produces<IReadOnlyList<MetadataCandidate>>();
admin.MapPost("/shows/{showId:guid}/apply", Apply).Produces(StatusCodes.Status204NoContent);
admin.MapPut("/shows/{showId:guid}", Update).Produces(StatusCodes.Status204NoContent);
admin.MapDelete("/shows/{showId:guid}", Clear).Produces(StatusCodes.Status204NoContent);
admin
.MapPut("/shows/{showId:guid}/poster", UploadPoster)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPut("/shows/{showId:guid}/poster-image", SetPosterImage)
.Produces(StatusCodes.Status204NoContent);
admin.MapPost("/shows/{showId:guid}/refresh-episodes", RefreshEpisodes).Produces<int>();
// Постеры шоу и кадры серий теперь в общем реестре и отдаются по /api/images/{id}.
return app;
}
private static async Task<IResult> GetProviders(
ISender sender,
CancellationToken cancellationToken
)
{
var providers = await sender.Send(new GetMetadataProvidersQuery(), cancellationToken);
return Results.Ok(providers);
}
private static async Task<IResult> Search(
string provider,
string query,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new SearchShowMetadataQuery(provider, query),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> Apply(
Guid showId,
ApplyMetadataBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ApplyShowMetadataCommand(showId, body.Provider, body.ExternalId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> Update(
Guid showId,
UpdateMetadataBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateShowMetadataCommand(showId, body.Description, body.Year),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> Clear(
Guid showId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ClearShowMetadataCommand(showId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UploadPoster(
Guid showId,
string fileName,
HttpRequest request,
IImageStore imageStore,
ISender sender,
CancellationToken cancellationToken
)
{
var ext = Path.GetExtension(fileName).ToLowerInvariant();
if (
request.ContentLength is > MaxPosterBytes or 0 or null
|| !PosterExtensions.Contains(ext)
)
return MetadataErrors.InvalidPoster.ToProblem();
// Регистрируем постер в общем реестре (категория ShowPoster) и привязываем к шоу.
var created = await sender.Send(
new UploadImageCommand(ImageCategory.ShowPoster, ext, fileName),
cancellationToken
);
if (!created.IsSuccess)
return created.ToHttpResult();
try
{
await imageStore.SaveAsync(created.Value, ext, request.Body, cancellationToken);
}
catch
{
await sender.Send(new DeleteImageCommand(created.Value), cancellationToken);
throw;
}
var result = await sender.Send(
new SetShowPosterCommand(showId, created.Value),
cancellationToken
);
if (!result.IsSuccess)
await sender.Send(new DeleteImageCommand(created.Value), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> SetPosterImage(
Guid showId,
SetPosterImageBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new SetShowPosterCommand(showId, body.ImageId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> RefreshEpisodes(
Guid showId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new RefreshShowEpisodesMetadataCommand(showId),
cancellationToken
);
return result.ToHttpResult();
}
}
public sealed record ApplyMetadataBody(string Provider, string ExternalId);
public sealed record UpdateMetadataBody(string? Description, int? Year);
public sealed record SetPosterImageBody(Guid? ImageId);
@@ -0,0 +1,89 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Admin.Roles.ChangeUserRole;
using TeleWave.Application.Admin.Roles.CreateRole;
using TeleWave.Application.Admin.Roles.DeleteRole;
using TeleWave.Application.Admin.Roles.ListRoles;
using TeleWave.Application.Admin.Roles.UpdateRole;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class RoleEndpoints
{
public static IEndpointRouteBuilder MapRoleEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin")
.WithTags("Admin.Roles")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("/roles", ListRoles).Produces<IReadOnlyList<RoleDto>>();
admin.MapPost("/roles", CreateRole).Produces<RoleDto>();
admin.MapPut("/roles/{id:guid}", UpdateRole).Produces<RoleDto>();
admin.MapDelete("/roles/{id:guid}", DeleteRole).Produces(StatusCodes.Status204NoContent);
admin
.MapPatch("/users/{id:guid}/role", ChangeUserRole)
.Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> ListRoles(
ISender sender,
CancellationToken cancellationToken
)
{
var roles = await sender.Send(new ListRolesQuery(), cancellationToken);
return Results.Ok(roles);
}
private static async Task<IResult> CreateRole(
CreateRoleCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateRole(
Guid id,
UpdateRoleBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new UpdateRoleCommand(id, body.Name), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteRole(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteRoleCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ChangeUserRole(
Guid id,
ChangeUserRoleBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ChangeUserRoleCommand(id, body.RoleId),
cancellationToken
);
return result.ToHttpResult();
}
}
public sealed record UpdateRoleBody(string Name);
public sealed record ChangeUserRoleBody(Guid RoleId);
@@ -0,0 +1,47 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Settings;
using TeleWave.Application.Settings.GetSiteSettings;
using TeleWave.Application.Settings.UpdateSiteSettings;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class SettingsEndpoints
{
public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/settings")
.WithTags("Admin.Settings")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", GetSettings).Produces<SiteSettingsDto>();
admin.MapPut("", UpdateSettings).Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> GetSettings(
ISender sender,
CancellationToken cancellationToken
)
{
var settings = await sender.Send(new GetSiteSettingsQuery(), cancellationToken);
return Results.Ok(settings);
}
private static async Task<IResult> UpdateSettings(
UpdateSiteSettingsBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateSiteSettingsCommand(body.RegistrationEnabled),
cancellationToken
);
return result.ToHttpResult();
}
}
public sealed record UpdateSiteSettingsBody(bool RegistrationEnabled);
@@ -0,0 +1,143 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Library;
using TeleWave.Application.Library.AddEpisode;
using TeleWave.Application.Library.CreateShow;
using TeleWave.Application.Library.DeleteShow;
using TeleWave.Application.Library.GetShow;
using TeleWave.Application.Library.ListShows;
using TeleWave.Application.Library.RemoveEpisode;
using TeleWave.Application.Library.RenameShow;
using TeleWave.Application.Library.SetShowOriginalName;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class ShowEndpoints
{
public static IEndpointRouteBuilder MapShowEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/shows")
.WithTags("Admin.Shows")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapPost("", CreateShow).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapGet("", ListShows).Produces<IReadOnlyList<ShowSummaryDto>>();
admin.MapGet("/{id:guid}", GetShow).Produces<ShowDto>();
admin.MapPut("/{id:guid}/name", Rename).Produces(StatusCodes.Status204NoContent);
admin
.MapPut("/{id:guid}/original-name", SetOriginalName)
.Produces(StatusCodes.Status204NoContent);
admin.MapDelete("/{id:guid}", DeleteShow).Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{id:guid}/episodes", AddEpisode)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapDelete("/{id:guid}/episodes/{episodeId:guid}", RemoveEpisode)
.Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> CreateShow(
CreateShowCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.IsSuccess
? Results.Created(
$"/api/admin/shows/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> ListShows(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListShowsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> GetShow(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetShowQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> Rename(
Guid id,
RenameShowBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new RenameShowCommand(id, body.Name), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> SetOriginalName(
Guid id,
SetShowOriginalNameBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new SetShowOriginalNameCommand(id, body.OriginalName),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteShow(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteShowCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> AddEpisode(
Guid id,
AddEpisodeBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new AddEpisodeCommand(id, body.MediaAssetId),
cancellationToken
);
return result.IsSuccess
? Results.Created($"/api/admin/shows/{id}", new CreatedIdResponse(result.Value))
: result.ToHttpResult();
}
private static async Task<IResult> RemoveEpisode(
Guid id,
Guid episodeId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new RemoveEpisodeCommand(id, episodeId), cancellationToken);
return result.ToHttpResult();
}
}
public sealed record AddEpisodeBody(Guid MediaAssetId);
public sealed record RenameShowBody(string Name);
public sealed record SetShowOriginalNameBody(string? OriginalName);
@@ -0,0 +1,184 @@
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;
using TeleWave.Application.Streaming.GetLivePlaylist;
using TeleWave.Application.Streaming.GetPublicEpg;
using TeleWave.Application.Streaming.ListPublicChannels;
using TeleWave.Infrastructure.Media;
using TeleWave.Infrastructure.Streaming;
namespace TeleWave.Api.Endpoints;
public static class StreamingEndpoints
{
private const string StreamCookieName = "tw_stream";
private static readonly Regex SegmentFileName = new(@"^seg\d{1,6}\.ts$", RegexOptions.Compiled);
public static IEndpointRouteBuilder MapStreamingEndpoints(this IEndpointRouteBuilder app)
{
// Публичный API канала (Bearer): список, EPG, выдача stream-cookie.
var channels = app.MapGroup("/api/channels").WithTags("Channels").RequireAuthorization();
channels.MapGet("", ListChannels).Produces<IReadOnlyList<PublicChannelDto>>();
channels.MapPost("/{slug}/watch", Watch).Produces(StatusCodes.Status204NoContent);
channels.MapGet("/{slug}/epg", Epg);
// Раздача эфира (cookie tw_stream): плейлист и сегменты — их грузит <video>/hls.js.
app.MapGet("/api/channels/{slug}/live.m3u8", LivePlaylist).WithTags("Streaming");
app.MapGet("/api/stream/{assetId:guid}/{file}", Segment).WithTags("Streaming");
return app;
}
private static async Task<IResult> ListChannels(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListPublicChannelsQuery(), cancellationToken);
return Results.Ok(result);
}
private static IResult Watch(
string slug,
ICurrentUser currentUser,
StreamTokenService tokens,
HttpRequest request,
HttpResponse response,
IHostEnvironment env
)
{
if (currentUser.UserId is not { } userId)
return Results.Unauthorized();
var (token, expiresAt) = tokens.Issue(userId);
response.Cookies.Append(
StreamCookieName,
token,
new CookieOptions
{
HttpOnly = true,
// Вне Development — всегда Secure (прод за внешним TLS-прокси; request.IsHttps ненадёжен
// при неполной настройке ForwardedHeaders). См. UseSecureCookie в AuthEndpoints.
Secure = !env.IsDevelopment() || request.IsHttps,
SameSite = SameSiteMode.Strict,
Path = "/api",
Expires = expiresAt,
}
);
return Results.NoContent();
}
private static async Task<IResult> Epg(
string slug,
DateTimeOffset? from,
DateTimeOffset? to,
ISender sender,
CancellationToken cancellationToken
)
{
var fromUtc = from ?? DateTimeOffset.UtcNow;
var toUtc = to ?? fromUtc.AddHours(12);
var result = await sender.Send(
new GetPublicEpgQuery(slug, fromUtc, toUtc),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> LivePlaylist(
string slug,
HttpRequest request,
HttpResponse response,
StreamTokenService tokens,
IIdentityService identity,
ISender sender,
CancellationToken cancellationToken
)
{
// Плейлист hls.js перезагружает регулярно — здесь дёшево (1 запрос на перезагрузку) сверить,
// что зритель из токена ещё существует и не заблокирован. Так блокировка отражается почти сразу,
// не дожидаясь истечения короткого TTL cookie; сегменты этой проверки не делают (слишком часто).
if (tokens.Validate(request.Cookies[StreamCookieName]) is not { } userId)
return Results.Unauthorized();
var profile = await identity.GetProfileAsync(userId, cancellationToken);
if (profile is null || profile.IsBlocked)
return Results.Unauthorized();
var result = await sender.Send(
new GetLivePlaylistQuery(slug, DateTimeOffset.UtcNow),
cancellationToken
);
if (!result.IsSuccess)
return result.ToHttpResult();
if (result.Value.Segments.Count == 0)
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
response.Headers.CacheControl = "no-cache";
return Results.Text(Render(result.Value), "application/vnd.apple.mpegurl");
}
private static IResult Segment(
Guid assetId,
string file,
HttpRequest request,
HttpResponse response,
StreamTokenService tokens,
MediaPathResolver paths
)
{
if (tokens.Validate(request.Cookies[StreamCookieName]) is null)
return Results.Unauthorized();
if (!SegmentFileName.IsMatch(file))
return Results.NotFound();
string path;
try
{
path = paths.SegmentPath(assetId, file);
}
catch (UnauthorizedAccessException)
{
return Results.NotFound();
}
if (!File.Exists(path))
return Results.NotFound();
response.Headers.CacheControl = "public, max-age=31536000, immutable";
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
}
private static string Render(LivePlaylistDto playlist)
{
var extinf = playlist.TargetDuration.ToString("F6", CultureInfo.InvariantCulture);
var sb = new StringBuilder();
sb.Append("#EXTM3U\n");
sb.Append("#EXT-X-VERSION:3\n");
sb.Append(
CultureInfo.InvariantCulture,
$"#EXT-X-TARGETDURATION:{playlist.TargetDuration}\n"
);
sb.Append(
CultureInfo.InvariantCulture,
$"#EXT-X-MEDIA-SEQUENCE:{playlist.MediaSequence}\n"
);
foreach (var segment in playlist.Segments)
{
if (segment.Discontinuity)
sb.Append("#EXT-X-DISCONTINUITY\n");
sb.Append(CultureInfo.InvariantCulture, $"#EXTINF:{extinf},\n");
sb.Append(
CultureInfo.InvariantCulture,
$"/api/stream/{segment.AssetId:N}/seg{segment.LocalIndex:D5}.ts\n"
);
}
return sb.ToString();
}
}
+143
View File
@@ -0,0 +1,143 @@
using System.Net;
using System.Text.Json.Serialization;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Scalar.AspNetCore;
using Serilog;
using TeleWave.Api.Common;
using TeleWave.Api.Endpoints;
using TeleWave.Application;
using TeleWave.Infrastructure;
using TeleWave.Infrastructure.Identity;
using TeleWave.Infrastructure.Persistence;
var builder = WebApplication.CreateBuilder(args);
// Загрузка медиа стримится на диск; поднимаем лимит тела запроса Kestrel до максимума загрузки
// (иначе дефолтные ~30 МБ рубят большие файлы). Собственный контроль размера — в MediaEndpoints.
builder.WebHost.ConfigureKestrel(options =>
options.Limits.MaxRequestBodySize =
builder.Configuration.GetValue<long?>("Media:MaxUploadBytes") ?? 20L * 1024 * 1024 * 1024
);
// Структурное логирование (Serilog), конфигурация из appsettings/env.
builder.Services.AddSerilog(
(services, configuration) =>
configuration
.ReadFrom.Configuration(builder.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
);
// За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера), но ТОЛЬКО от явно
// перечисленных адресов/сетей прокси — иначе клиент может подделать свой IP/схему напрямую, минуя
// прокси. По умолчанию (без конфигурации) остаётся дефолт ASP.NET Core — доверие только loopback;
// для прод-топологии прокси задаётся через ForwardedHeaders__KnownProxies/KnownNetworks (см. .env.example).
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
foreach (
var proxy in builder
.Configuration.GetSection("ForwardedHeaders:KnownProxies")
.Get<string[]>()
?? []
)
options.KnownProxies.Add(IPAddress.Parse(proxy));
foreach (
var network in builder
.Configuration.GetSection("ForwardedHeaders:KnownNetworks")
.Get<string[]>()
?? []
)
{
var parts = network.Split('/');
options.KnownIPNetworks.Add(
new System.Net.IPNetwork(IPAddress.Parse(parts[0]), int.Parse(parts[1]))
);
}
});
builder.Services.AddHttpContextAccessor();
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
var authPermitLimit = builder.Configuration.GetValue("RateLimiting:AuthPermitLimit", 20);
builder.Services.AddRateLimiter(options =>
{
// Партиционируем по IP клиента (реальный адрес доступен после UseForwardedHeaders): единый
// непартиционированный лимит превращается в DoS — один клиент исчерпывает окно логина для всех.
options.AddPolicy(
RateLimiting.AuthPolicy,
httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = authPermitLimit,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
}
)
);
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});
// Энумы сериализуются строками, не числами — самодокументируемый JSON.
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())
);
builder.Services.AddProblemDetails();
builder.Services.AddOpenApi();
builder.Services.AddHealthChecks().AddDbContextCheck<AppDbContext>();
var app = builder.Build();
// Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте.
await app.Services.ApplyMigrationsAsync();
await app.Services.RelocateLegacyImagesAsync();
await app.Services.SeedDataAsync();
app.UseForwardedHeaders();
app.UseSerilogRequestLogging();
app.UseExceptionHandler();
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
// Схему/UI API публикуем не в проде (или явным флагом Api:EnableOpenApi=true) — чтобы в продакшене
// не раскрывать полную карту эндпоинтов без необходимости.
if (app.Environment.IsDevelopment() || app.Configuration.GetValue("Api:EnableOpenApi", false))
{
app.MapOpenApi();
app.MapScalarApiReference();
}
app.MapHealthChecks("/health");
app.MapAuthEndpoints();
app.MapRoleEndpoints();
app.MapAdminUserEndpoints();
app.MapMediaEndpoints();
app.MapShowEndpoints();
app.MapChannelEndpoints();
app.MapStreamingEndpoints();
app.MapMaintenanceEndpoints();
app.MapSettingsEndpoints();
app.MapMetadataEndpoints();
app.MapImageEndpoints();
// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов.
app.UseDefaultFiles();
app.UseStaticFiles();
app.MapFallbackToFile("index.html");
app.Run();
/// <summary>Делает неявный класс Program доступным для WebApplicationFactory&lt;Program&gt; в интеграционных тестах.</summary>
public partial class Program;
@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:8080",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<ProjectReference Include="..\TeleWave.Infrastructure\TeleWave.Infrastructure.csproj" />
<ProjectReference Include="..\TeleWave.Application\TeleWave.Application.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" />
<PackageReference Include="Microsoft.OpenApi" />
<PackageReference Include="Scalar.AspNetCore" />
<PackageReference Include="Serilog.AspNetCore" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
@@ -0,0 +1,18 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Jwt": {
"SigningKey": "telewave-development-only-signing-key-not-for-production-0001"
},
"AdminSeed": {
"Username": "admin",
"Password": "Passw0rd!Dev"
},
"Storage": {
"RootPath": ".dev-media"
}
}
+72
View File
@@ -0,0 +1,72 @@
{
"ConnectionStrings": {
"Default": "Host=localhost;Port=5432;Database=telewave;Username=telewave;Password=telewave"
},
"Jwt": {
"Issuer": "TeleWave",
"Audience": "TeleWave",
"SigningKey": "change-me-min-32-chars-random-secret",
"AccessTokenMinutes": 15,
"RefreshTokenDays": 30
},
"AdminSeed": {
"Username": "",
"Password": ""
},
"Storage": {
"RootPath": "/media",
"SegmentSeconds": 2,
"LiveWindowSegments": 10,
"KeepOriginals": false,
"MinFreeSpaceBytes": 10737418240
},
"Scheduler": {
"HorizonDays": 3,
"RetentionHours": 24,
"TickMinutes": 30
},
"Media": {
"FfmpegPath": "ffmpeg",
"FfprobePath": "ffprobe",
"MaxUploadBytes": 21474836480,
"TranscodeThreads": 3,
"InboxScanSeconds": 15,
"NormalizeLoudness": true,
"LoudnessTargetLufs": -16
},
"Bumpers": {
"Width": 1280,
"Height": 720,
"FontFileSans": "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"FontFileSerif": "/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf",
"TemplateVersion": 1
},
"Metadata": {
"Language": "ru-RU",
"Tmdb": {
"ApiKey": "",
"BaseUrl": "https://api.themoviedb.org/3",
"ImageBaseUrl": "https://image.tmdb.org/t/p",
"PosterSize": "w500",
"StillSize": "w300"
},
"Omdb": {
"ApiKey": "",
"BaseUrl": "https://www.omdbapi.com"
}
},
"Serilog": {
"Using": [ "Serilog.Sinks.Console" ],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning",
"System.Net.Http.HttpClient": "Warning"
}
},
"WriteTo": [ { "Name": "Console" } ],
"Enrich": [ "FromLogContext" ]
},
"AllowedHosts": "*"
}
@@ -0,0 +1,7 @@
<!doctype html>
<html>
<head><meta charset="utf-8" /><title>TeleWave</title></head>
<body>
<p>Frontend build not present yet — run the Vite build to populate wwwroot.</p>
</body>
</html>
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Roles.ChangeUserRole;
public sealed record ChangeUserRoleCommand(Guid UserId, Guid RoleId) : ICommand<Result>;
@@ -0,0 +1,14 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Roles.ChangeUserRole;
public sealed class ChangeUserRoleCommandHandler(IRoleService roleService)
: ICommandHandler<ChangeUserRoleCommand, Result>
{
public Task<Result> Handle(
ChangeUserRoleCommand command,
CancellationToken cancellationToken
) => roleService.ChangeUserRoleAsync(command.UserId, command.RoleId, cancellationToken);
}
@@ -0,0 +1,7 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Roles.CreateRole;
public sealed record CreateRoleCommand(string Name) : ICommand<Result<RoleDto>>;
@@ -0,0 +1,14 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Roles.CreateRole;
public sealed class CreateRoleCommandHandler(IRoleService roleService)
: ICommandHandler<CreateRoleCommand, Result<RoleDto>>
{
public Task<Result<RoleDto>> Handle(
CreateRoleCommand command,
CancellationToken cancellationToken
) => roleService.CreateRoleAsync(command.Name, cancellationToken);
}
@@ -0,0 +1,11 @@
using FluentValidation;
namespace TeleWave.Application.Admin.Roles.CreateRole;
public sealed class CreateRoleCommandValidator : AbstractValidator<CreateRoleCommand>
{
public CreateRoleCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(64);
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Roles.DeleteRole;
public sealed record DeleteRoleCommand(Guid Id) : ICommand<Result>;
@@ -0,0 +1,12 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Roles.DeleteRole;
public sealed class DeleteRoleCommandHandler(IRoleService roleService)
: ICommandHandler<DeleteRoleCommand, Result>
{
public Task<Result> Handle(DeleteRoleCommand command, CancellationToken cancellationToken) =>
roleService.DeleteRoleAsync(command.Id, cancellationToken);
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Application.Admin.Roles.ListRoles;
public sealed record ListRolesQuery : IQuery<IReadOnlyList<RoleDto>>;
@@ -0,0 +1,13 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Application.Admin.Roles.ListRoles;
public sealed class ListRolesQueryHandler(IRoleService roleService)
: IQueryHandler<ListRolesQuery, IReadOnlyList<RoleDto>>
{
public Task<IReadOnlyList<RoleDto>> Handle(
ListRolesQuery query,
CancellationToken cancellationToken
) => roleService.ListRolesAsync(cancellationToken);
}
@@ -0,0 +1,28 @@
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Roles;
public static class RoleErrors
{
public static readonly Error NotFound = Error.NotFound("Roles.NotFound", "Роль не найдена.");
public static readonly Error DuplicateName = Error.Conflict(
"Roles.DuplicateName",
"Роль с таким именем уже существует."
);
public static readonly Error CannotModifySystemRole = Error.Forbidden(
"Roles.CannotModifySystemRole",
"Системную роль нельзя переименовать или удалить."
);
public static readonly Error RoleInUse = Error.Conflict(
"Roles.RoleInUse",
"Роль назначена пользователям — сначала смените им роль."
);
public static readonly Error CannotRemoveLastAdmin = Error.Conflict(
"Roles.CannotRemoveLastAdmin",
"Нельзя снять роль admin с последнего администратора."
);
}
@@ -0,0 +1,7 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Roles.UpdateRole;
public sealed record UpdateRoleCommand(Guid Id, string Name) : ICommand<Result<RoleDto>>;
@@ -0,0 +1,14 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Roles.UpdateRole;
public sealed class UpdateRoleCommandHandler(IRoleService roleService)
: ICommandHandler<UpdateRoleCommand, Result<RoleDto>>
{
public Task<Result<RoleDto>> Handle(
UpdateRoleCommand command,
CancellationToken cancellationToken
) => roleService.UpdateRoleAsync(command.Id, command.Name, cancellationToken);
}
@@ -0,0 +1,11 @@
using FluentValidation;
namespace TeleWave.Application.Admin.Roles.UpdateRole;
public sealed class UpdateRoleCommandValidator : AbstractValidator<UpdateRoleCommand>
{
public UpdateRoleCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(64);
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.BlockUser;
public sealed record BlockUserCommand(Guid UserId) : ICommand<Result>;
@@ -0,0 +1,19 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.BlockUser;
public sealed class BlockUserCommandHandler(
IIdentityService identityService,
ICurrentUser currentUser
) : ICommandHandler<BlockUserCommand, Result>
{
public Task<Result> Handle(BlockUserCommand command, CancellationToken cancellationToken)
{
if (currentUser.UserId == command.UserId)
return Task.FromResult(Result.Failure(UserErrors.CannotBlockSelf));
return identityService.BlockUserAsync(command.UserId, cancellationToken);
}
}
@@ -0,0 +1,8 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.CreateUser;
/// <summary>Создание пользователя администратором (в обход открытой регистрации), с выбором роли.</summary>
public sealed record CreateUserCommand(string UserName, string Password, Guid RoleId)
: ICommand<Result<Guid>>;
@@ -0,0 +1,44 @@
using LiteCqrs;
using TeleWave.Application.Admin.Roles;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.CreateUser;
public sealed class CreateUserCommandHandler(
IIdentityService identityService,
IRoleService roleService
) : ICommandHandler<CreateUserCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
CreateUserCommand command,
CancellationToken cancellationToken
)
{
// Роль проверяем до создания, чтобы не оставить пользователя с ролью по умолчанию при опечатке.
var roles = await roleService.ListRolesAsync(cancellationToken);
if (roles.All(r => r.Id != command.RoleId))
return Result.Failure<Guid>(RoleErrors.NotFound);
var createResult = await identityService.CreateUserAsync(
command.UserName,
command.Password,
cancellationToken
);
if (!createResult.IsSuccess)
return Result.Failure<Guid>(createResult.Error);
var userId = createResult.Value;
// CreateUserAsync назначает роль по умолчанию — выставляем выбранную админом.
var roleResult = await roleService.ChangeUserRoleAsync(
userId,
command.RoleId,
cancellationToken
);
if (!roleResult.IsSuccess)
return Result.Failure<Guid>(roleResult.Error);
return Result.Success(userId);
}
}
@@ -0,0 +1,13 @@
using FluentValidation;
namespace TeleWave.Application.Admin.Users.CreateUser;
public sealed class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
{
public CreateUserCommandValidator()
{
RuleFor(x => x.UserName).NotEmpty().MinimumLength(3).MaximumLength(64);
RuleFor(x => x.Password).NotEmpty().MinimumLength(8);
RuleFor(x => x.RoleId).NotEmpty();
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.DeleteUser;
public sealed record DeleteUserCommand(Guid UserId) : ICommand<Result>;
@@ -0,0 +1,19 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.DeleteUser;
public sealed class DeleteUserCommandHandler(
IIdentityService identityService,
ICurrentUser currentUser
) : ICommandHandler<DeleteUserCommand, Result>
{
public Task<Result> Handle(DeleteUserCommand command, CancellationToken cancellationToken)
{
if (currentUser.UserId == command.UserId)
return Task.FromResult(Result.Failure(UserErrors.CannotDeleteSelf));
return identityService.DeleteUserAsync(command.UserId, cancellationToken);
}
}
@@ -0,0 +1,7 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.GetUser;
public sealed record GetUserQuery(Guid Id) : IQuery<Result<UserSummaryDto>>;
@@ -0,0 +1,20 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.GetUser;
public sealed class GetUserQueryHandler(IIdentityService identityService)
: IQueryHandler<GetUserQuery, Result<UserSummaryDto>>
{
public async Task<Result<UserSummaryDto>> Handle(
GetUserQuery query,
CancellationToken cancellationToken
)
{
var user = await identityService.GetUserAsync(query.Id, cancellationToken);
return user is null
? Result.Failure<UserSummaryDto>(UserErrors.NotFound)
: Result.Success(user);
}
}
@@ -0,0 +1,13 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.ListUsers;
public sealed record ListUsersQuery(
int Page,
int PageSize,
string? Search,
Guid? RoleId,
bool? IsBlocked
) : IQuery<PagedList<UserSummaryDto>>;
@@ -0,0 +1,22 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.ListUsers;
public sealed class ListUsersQueryHandler(IIdentityService identityService)
: IQueryHandler<ListUsersQuery, PagedList<UserSummaryDto>>
{
public Task<PagedList<UserSummaryDto>> Handle(
ListUsersQuery query,
CancellationToken cancellationToken
) =>
identityService.ListUsersAsync(
query.Page,
query.PageSize,
query.Search,
query.RoleId,
query.IsBlocked,
cancellationToken
);
}
@@ -0,0 +1,7 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.ResetPassword;
/// <summary>Сброс пароля пользователя администратором (без текущего пароля).</summary>
public sealed record ResetUserPasswordCommand(Guid UserId, string NewPassword) : ICommand<Result>;
@@ -0,0 +1,14 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.ResetPassword;
public sealed class ResetUserPasswordCommandHandler(IIdentityService identityService)
: ICommandHandler<ResetUserPasswordCommand, Result>
{
public Task<Result> Handle(
ResetUserPasswordCommand command,
CancellationToken cancellationToken
) => identityService.ResetPasswordAsync(command.UserId, command.NewPassword, cancellationToken);
}
@@ -0,0 +1,13 @@
using FluentValidation;
namespace TeleWave.Application.Admin.Users.ResetPassword;
public sealed class ResetUserPasswordCommandValidator : AbstractValidator<ResetUserPasswordCommand>
{
public ResetUserPasswordCommandValidator()
{
// Длина — здесь; сложность (цифра/заглавная) проверяют валидаторы ASP.NET Identity при сбросе.
RuleFor(x => x.UserId).NotEmpty();
RuleFor(x => x.NewPassword).NotEmpty().MinimumLength(8);
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.UnblockUser;
public sealed record UnblockUserCommand(Guid UserId) : ICommand<Result>;
@@ -0,0 +1,12 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.UnblockUser;
public sealed class UnblockUserCommandHandler(IIdentityService identityService)
: ICommandHandler<UnblockUserCommand, Result>
{
public Task<Result> Handle(UnblockUserCommand command, CancellationToken cancellationToken) =>
identityService.UnblockUserAsync(command.UserId, cancellationToken);
}
@@ -0,0 +1,21 @@
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users;
public static class UserErrors
{
public static readonly Error NotFound = Error.NotFound(
"Users.NotFound",
"Пользователь не найден."
);
public static readonly Error CannotDeleteSelf = Error.Forbidden(
"Users.CannotDeleteSelf",
"Нельзя удалить собственный аккаунт через админку."
);
public static readonly Error CannotBlockSelf = Error.Forbidden(
"Users.CannotBlockSelf",
"Нельзя заблокировать собственный аккаунт."
);
}
@@ -0,0 +1,36 @@
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth;
public static class AuthErrors
{
public static readonly Error InvalidCredentials = Error.Unauthorized(
"Auth.InvalidCredentials",
"Неверное имя пользователя или пароль."
);
public static readonly Error Unauthorized = Error.Unauthorized(
"Auth.Unauthorized",
"Требуется вход в систему."
);
public static readonly Error InvalidRefreshToken = Error.Unauthorized(
"Auth.InvalidRefreshToken",
"Сессия истекла, войдите заново."
);
public static readonly Error Blocked = Error.Forbidden(
"Auth.Blocked",
"Аккаунт заблокирован администратором."
);
public static readonly Error UserNameTaken = Error.Conflict(
"Auth.UserNameTaken",
"Это имя пользователя уже занято."
);
public static readonly Error RegistrationDisabled = Error.Forbidden(
"Auth.RegistrationDisabled",
"Регистрация на сайте отключена администратором."
);
}
@@ -0,0 +1,11 @@
namespace TeleWave.Application.Auth;
public sealed record CurrentUserDto(Guid Id, string UserName, string Role);
public sealed record AuthResult(
string AccessToken,
DateTimeOffset AccessTokenExpiresAt,
string RefreshToken,
DateTimeOffset RefreshTokenExpiresAt,
CurrentUserDto User
);
@@ -0,0 +1,7 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.ChangePassword;
public sealed record ChangePasswordCommand(string CurrentPassword, string NewPassword)
: ICommand<Result>;
@@ -0,0 +1,27 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.ChangePassword;
public sealed class ChangePasswordCommandHandler(
IIdentityService identityService,
ICurrentUser currentUser
) : ICommandHandler<ChangePasswordCommand, Result>
{
public async Task<Result> Handle(
ChangePasswordCommand command,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } userId)
return Result.Failure(AuthErrors.Unauthorized);
return await identityService.ChangePasswordAsync(
userId,
command.CurrentPassword,
command.NewPassword,
cancellationToken
);
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace TeleWave.Application.Auth.ChangePassword;
public sealed class ChangePasswordCommandValidator : AbstractValidator<ChangePasswordCommand>
{
public ChangePasswordCommandValidator()
{
RuleFor(x => x.CurrentPassword).NotEmpty();
RuleFor(x => x.NewPassword).NotEmpty().MinimumLength(8);
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.ChangeUserName;
public sealed record ChangeUserNameCommand(string NewUserName) : ICommand<Result>;
@@ -0,0 +1,26 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.ChangeUserName;
public sealed class ChangeUserNameCommandHandler(
IIdentityService identityService,
ICurrentUser currentUser
) : ICommandHandler<ChangeUserNameCommand, Result>
{
public async Task<Result> Handle(
ChangeUserNameCommand command,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } userId)
return Result.Failure(AuthErrors.Unauthorized);
return await identityService.ChangeUserNameAsync(
userId,
command.NewUserName,
cancellationToken
);
}
}
@@ -0,0 +1,11 @@
using FluentValidation;
namespace TeleWave.Application.Auth.ChangeUserName;
public sealed class ChangeUserNameCommandValidator : AbstractValidator<ChangeUserNameCommand>
{
public ChangeUserNameCommandValidator()
{
RuleFor(x => x.NewUserName).NotEmpty().MinimumLength(3).MaximumLength(64);
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.DeleteMyAccount;
public sealed record DeleteMyAccountCommand : ICommand<Result>;
@@ -0,0 +1,22 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.DeleteMyAccount;
public sealed class DeleteMyAccountCommandHandler(
IIdentityService identityService,
ICurrentUser currentUser
) : ICommandHandler<DeleteMyAccountCommand, Result>
{
public async Task<Result> Handle(
DeleteMyAccountCommand command,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } userId)
return Result.Failure(AuthErrors.Unauthorized);
return await identityService.DeleteUserAsync(userId, cancellationToken);
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.Login;
public sealed record LoginCommand(string UserName, string Password) : ICommand<Result<AuthResult>>;
@@ -0,0 +1,50 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.Login;
public sealed class LoginCommandHandler(
IIdentityService identityService,
IJwtTokenService jwtTokenService,
IRefreshTokenService refreshTokenService
) : ICommandHandler<LoginCommand, Result<AuthResult>>
{
public async Task<Result<AuthResult>> Handle(
LoginCommand command,
CancellationToken cancellationToken
)
{
var credentialsResult = await identityService.ValidateCredentialsAsync(
command.UserName,
command.Password,
cancellationToken
);
if (!credentialsResult.IsSuccess)
return Result.Failure<AuthResult>(credentialsResult.Error);
var user = credentialsResult.Value;
var profile = await identityService.GetProfileAsync(user.Id, cancellationToken);
if (profile is null)
return Result.Failure<AuthResult>(AuthErrors.InvalidCredentials);
if (profile.IsBlocked)
return Result.Failure<AuthResult>(AuthErrors.Blocked);
var (accessToken, accessExpiresAt) = jwtTokenService.GenerateAccessToken(
new AuthenticatedUser(profile.Id, profile.UserName, profile.Role)
);
var refreshToken = await refreshTokenService.IssueAsync(profile.Id, cancellationToken);
var dto = new CurrentUserDto(profile.Id, profile.UserName, profile.Role);
return Result.Success(
new AuthResult(
accessToken,
accessExpiresAt,
refreshToken.RawToken,
refreshToken.ExpiresAt,
dto
)
);
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace TeleWave.Application.Auth.Login;
public sealed class LoginCommandValidator : AbstractValidator<LoginCommand>
{
public LoginCommandValidator()
{
RuleFor(x => x.UserName).NotEmpty();
RuleFor(x => x.Password).NotEmpty();
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.Logout;
public sealed record LogoutCommand(string RawToken) : ICommand<Result>;
@@ -0,0 +1,15 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.Logout;
public sealed class LogoutCommandHandler(IRefreshTokenService refreshTokenService)
: ICommandHandler<LogoutCommand, Result>
{
public async Task<Result> Handle(LogoutCommand command, CancellationToken cancellationToken)
{
await refreshTokenService.RevokeAsync(command.RawToken, cancellationToken);
return Result.Success();
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.Me;
public sealed record GetCurrentUserQuery : IQuery<Result<CurrentUserDto>>;
@@ -0,0 +1,26 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.Me;
public sealed class GetCurrentUserQueryHandler(
IIdentityService identityService,
ICurrentUser currentUser
) : IQueryHandler<GetCurrentUserQuery, Result<CurrentUserDto>>
{
public async Task<Result<CurrentUserDto>> Handle(
GetCurrentUserQuery query,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } userId)
return Result.Failure<CurrentUserDto>(AuthErrors.Unauthorized);
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
if (profile is null)
return Result.Failure<CurrentUserDto>(AuthErrors.Unauthorized);
return Result.Success(new CurrentUserDto(profile.Id, profile.UserName, profile.Role));
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.Refresh;
public sealed record RefreshCommand(string RawToken) : ICommand<Result<AuthResult>>;
@@ -0,0 +1,44 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.Refresh;
public sealed class RefreshCommandHandler(
IRefreshTokenService refreshTokenService,
IIdentityService identityService,
IJwtTokenService jwtTokenService
) : ICommandHandler<RefreshCommand, Result<AuthResult>>
{
public async Task<Result<AuthResult>> Handle(
RefreshCommand command,
CancellationToken cancellationToken
)
{
var rotated = await refreshTokenService.RotateAsync(command.RawToken, cancellationToken);
if (!rotated.IsSuccess)
return Result.Failure<AuthResult>(rotated.Error);
var profile = await identityService.GetProfileAsync(
rotated.Value.UserId,
cancellationToken
);
if (profile is null || profile.IsBlocked)
return Result.Failure<AuthResult>(AuthErrors.Unauthorized);
var (accessToken, accessExpiresAt) = jwtTokenService.GenerateAccessToken(
new AuthenticatedUser(profile.Id, profile.UserName, profile.Role)
);
var dto = new CurrentUserDto(profile.Id, profile.UserName, profile.Role);
return Result.Success(
new AuthResult(
accessToken,
accessExpiresAt,
rotated.Value.RawToken,
rotated.Value.ExpiresAt,
dto
)
);
}
}
@@ -0,0 +1,7 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.Register;
public sealed record RegisterCommand(string UserName, string Password)
: ICommand<Result<AuthResult>>;
@@ -0,0 +1,51 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.Register;
public sealed class RegisterCommandHandler(
IIdentityService identityService,
IJwtTokenService jwtTokenService,
IRefreshTokenService refreshTokenService,
ISiteSettings siteSettings
) : ICommandHandler<RegisterCommand, Result<AuthResult>>
{
public async Task<Result<AuthResult>> Handle(
RegisterCommand command,
CancellationToken cancellationToken
)
{
// Открытая регистрация должна быть явно включена админом; иначе учётки заводит только он.
if (!await siteSettings.IsRegistrationEnabledAsync(cancellationToken))
return Result.Failure<AuthResult>(AuthErrors.RegistrationDisabled);
var createResult = await identityService.CreateUserAsync(
command.UserName,
command.Password,
cancellationToken
);
if (!createResult.IsSuccess)
return Result.Failure<AuthResult>(createResult.Error);
var profile = await identityService.GetProfileAsync(createResult.Value, cancellationToken);
if (profile is null)
return Result.Failure<AuthResult>(AuthErrors.Unauthorized);
var (accessToken, accessExpiresAt) = jwtTokenService.GenerateAccessToken(
new AuthenticatedUser(profile.Id, profile.UserName, profile.Role)
);
var refreshToken = await refreshTokenService.IssueAsync(profile.Id, cancellationToken);
var dto = new CurrentUserDto(profile.Id, profile.UserName, profile.Role);
return Result.Success(
new AuthResult(
accessToken,
accessExpiresAt,
refreshToken.RawToken,
refreshToken.ExpiresAt,
dto
)
);
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace TeleWave.Application.Auth.Register;
public sealed class RegisterCommandValidator : AbstractValidator<RegisterCommand>
{
public RegisterCommandValidator()
{
RuleFor(x => x.UserName).NotEmpty().MinimumLength(3).MaximumLength(64);
RuleFor(x => x.Password).NotEmpty().MinimumLength(8);
}
}
@@ -0,0 +1,7 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.AddChannelAd;
public sealed record AddChannelAdCommand(Guid ChannelId, Guid MediaAssetId)
: ICommand<Result<Guid>>;
@@ -0,0 +1,35 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.AddChannelAd;
public sealed class AddChannelAdCommandHandler(IAppDbContext dbContext)
: ICommandHandler<AddChannelAdCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
AddChannelAdCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.Include(c => c.Ads)
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure<Guid>(ChannelErrors.NotFound);
var assetExists = await dbContext.MediaAssets.AnyAsync(
a => a.Id == command.MediaAssetId,
cancellationToken
);
if (!assetExists)
return Result.Failure<Guid>(ChannelErrors.AssetNotFound);
if (channel.HasAd(command.MediaAssetId))
return Result.Failure<Guid>(ChannelErrors.AdAlreadyAdded);
var ad = channel.AddAd(command.MediaAssetId);
return Result.Success(ad.Id);
}
}
@@ -0,0 +1,13 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.AddChannelShow;
public sealed record AddChannelShowCommand(
Guid ChannelId,
Guid ShowId,
int Weight,
BlockMode BlockMode,
int BlockValue
) : ICommand<Result<Guid>>;
@@ -0,0 +1,40 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.AddChannelShow;
public sealed class AddChannelShowCommandHandler(IAppDbContext dbContext)
: ICommandHandler<AddChannelShowCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
AddChannelShowCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.Include(c => c.Shows)
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure<Guid>(ChannelErrors.NotFound);
var showExists = await dbContext.Shows.AnyAsync(
s => s.Id == command.ShowId,
cancellationToken
);
if (!showExists)
return Result.Failure<Guid>(ChannelErrors.ShowNotFound);
if (channel.HasShow(command.ShowId))
return Result.Failure<Guid>(ChannelErrors.ShowAlreadyAdded);
var channelShow = channel.AddShow(
command.ShowId,
command.Weight,
command.BlockMode,
command.BlockValue
);
return Result.Success(channelShow.Id);
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace TeleWave.Application.Broadcast.AddChannelShow;
public sealed class AddChannelShowCommandValidator : AbstractValidator<AddChannelShowCommand>
{
public AddChannelShowCommandValidator()
{
RuleFor(x => x.Weight).InclusiveBetween(1, 1000);
RuleFor(x => x.BlockValue).InclusiveBetween(1, 10000);
}
}
@@ -0,0 +1,7 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>Добавить новый блок заставки на канал (звук/фон загружаются отдельно).</summary>
public sealed record AddBumperTemplateCommand(Guid ChannelId, string Name) : ICommand<Result<Guid>>;
@@ -0,0 +1,28 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
public sealed class AddBumperTemplateCommandHandler(IAppDbContext dbContext)
: ICommandHandler<AddBumperTemplateCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
AddBumperTemplateCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.Include(c => c.BumperTemplates)
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure<Guid>(ChannelErrors.NotFound);
var name = string.IsNullOrWhiteSpace(command.Name)
? $"Заставка {channel.BumperTemplates.Count + 1}"
: command.Name.Trim();
var template = channel.AddBumperTemplate(name);
return Result.Success(template.Id);
}
}
@@ -0,0 +1,8 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>Добавить подблок (текст-вариант) в блок заставки.</summary>
public sealed record AddBumperTextVariantCommand(Guid ChannelId, Guid TemplateId, string Name)
: ICommand<Result<Guid>>;
@@ -0,0 +1,33 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
public sealed class AddBumperTextVariantCommandHandler(IAppDbContext dbContext)
: ICommandHandler<AddBumperTextVariantCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
AddBumperTextVariantCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.Include(c => c.BumperTemplates)
.ThenInclude(t => t.Variants)
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure<Guid>(ChannelErrors.NotFound);
var template = channel.FindBumperTemplate(command.TemplateId);
if (template is null)
return Result.Failure<Guid>(ChannelErrors.BumperTemplateNotFound);
var name = string.IsNullOrWhiteSpace(command.Name)
? $"Текст {template.Variants.Count + 1}"
: command.Name.Trim();
var variant = template.AddVariant(name);
return Result.Success(variant.Id);
}
}
@@ -0,0 +1,24 @@
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>
/// Инфраструктурные настройки рендера ТВ-заставок (общие для всех каналов): разрешение, пути к
/// шрифтам, версия шаблона. Оформление и правила (цвета, подписи, длительность, интервал) задаются
/// на каждом канале — см. <c>Channel.UpdateBumperSettings</c>.
/// </summary>
public sealed class BumperOptions
{
public const string SectionName = "Bumpers";
public int Width { get; init; } = 1280;
public int Height { get; init; } = 720;
/// <summary>Пути к TTF-шрифтам с кириллицей внутри контейнера (см. Dockerfile, fonts-dejavu-core).</summary>
public string FontFileSans { get; init; } =
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf";
public string FontFileSerif { get; init; } =
"/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf";
/// <summary>Версия шаблона рендера — входит в кэш-ключ заставки; меняй при правке ЛОГИКИ рендера
/// (не оформления канала), чтобы пересобрать уже отрендеренные заставки.</summary>
public int TemplateVersion { get; init; } = 1;
}
@@ -0,0 +1,12 @@
using System.Security.Cryptography;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>
/// Детерминированный id ассета-превью для блока заставки: один и тот же на каждый повторный рендер,
/// поэтому предпросмотр перезаписывает единственный каталог assets/{id}, а не плодит новые.
/// </summary>
public static class BumperPreview
{
public static Guid AssetId(Guid templateId) => new(MD5.HashData(templateId.ToByteArray()));
}
@@ -0,0 +1,8 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>Удалить загруженный звук блока (вернуться к синтезированному джинглу).</summary>
public sealed record ClearBumperTemplateAudioCommand(Guid ChannelId, Guid TemplateId)
: ICommand<Result>;
@@ -0,0 +1,33 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
public sealed class ClearBumperTemplateAudioCommandHandler(
IAppDbContext dbContext,
IBumperTemplateStorage storage
) : ICommandHandler<ClearBumperTemplateAudioCommand, Result>
{
public async Task<Result> Handle(
ClearBumperTemplateAudioCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.Include(c => c.BumperTemplates)
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure(ChannelErrors.NotFound);
var template = channel.FindBumperTemplate(command.TemplateId);
if (template is null)
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
template.ClearAudio();
await dbContext.SaveChangesAsync(cancellationToken);
await storage.DeleteAudioAsync(command.TemplateId, cancellationToken);
return Result.Success();
}
}

Some files were not shown because too many files have changed in this diff Show More