Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f609db4e9 | ||
|
|
7a164dbd90 | ||
|
|
bed6d4fcfc | ||
|
|
88cfe3716b | ||
|
|
89b68a26e7 | ||
|
|
cbeee92d26 | ||
|
|
3dbe0b7698 | ||
|
|
4a2e6ac7f1 | ||
|
|
08f56e14c2 | ||
|
|
d6bc2b15ac | ||
|
|
8a439f1351 | ||
|
|
99821d3c76 | ||
|
|
54cc4862b2 | ||
|
|
ec957fe55c | ||
|
|
2b03e43a83 | ||
|
|
bd3ced3637 | ||
|
|
375e0a810b | ||
|
|
7163a0b937 | ||
|
|
789357481d | ||
|
|
9cd364b174 | ||
|
|
01ba48e163 | ||
|
|
bee655ec1c | ||
|
|
b9d96c915c | ||
|
|
e983374dec | ||
|
|
de48317f7d | ||
|
|
926a20020f | ||
|
|
398b60facc | ||
|
|
5032614773 | ||
|
|
3fecd81333 | ||
|
|
1758a93011 | ||
|
|
10e05b8689 | ||
|
|
bd89abf24b | ||
|
|
8119305c36 | ||
|
|
6be373476c | ||
|
|
35cb4b09dc | ||
|
|
6f74e66f0c | ||
|
|
281d081e6b | ||
|
|
c4a94443f7 | ||
|
|
775f287c29 | ||
|
|
5952ba4536 | ||
|
|
fbfc6e677e | ||
|
|
c67d1f7752 | ||
|
|
46c0169dea | ||
|
|
3601404383 | ||
|
|
ac41111fa0 | ||
|
|
6c85db2d60 | ||
|
|
3908b6e722 | ||
|
|
f924891f3b | ||
|
|
f895e1ddbc | ||
|
|
30a38d163c | ||
|
|
4a500583e7 | ||
|
|
9db54a4625 | ||
|
|
2e76fa1452 | ||
|
|
6ca629c13a | ||
|
|
287ed5aa12 | ||
|
|
ef5c56f04b | ||
|
|
05e9919a7b | ||
|
|
f15b2fefcd | ||
|
|
d0bc0465e7 | ||
|
|
d859a9894c | ||
|
|
e48838b69d | ||
|
|
0b9ea9f0f4 | ||
|
|
fe11034b16 | ||
|
|
f7e7b5f7c3 | ||
|
|
7bee84c548 | ||
|
|
e0b85807df | ||
|
|
9f86016a2c | ||
|
|
16b3c485d7 | ||
|
|
1afe01aed8 | ||
|
|
3d2ae20368 | ||
|
|
8ae8eebc12 | ||
|
|
779f322f62 | ||
|
|
ba3721eb92 | ||
|
|
ee0b4d2d01 | ||
|
|
e55161f8f9 | ||
|
|
986cf02b0d | ||
|
|
ef3328ec6f | ||
|
|
a284066da5 | ||
|
|
259f4041c3 | ||
|
|
36c70a61df | ||
|
|
860cde19a3 | ||
|
|
61f934d19f | ||
|
|
96ff2cc7ab | ||
|
|
062f4aa200 | ||
|
|
b8d6b6aad2 | ||
|
|
f22aee4685 | ||
|
|
0fd4e762f4 | ||
|
|
a302750de6 | ||
|
|
6306919ecd | ||
|
|
bef029b858 | ||
|
|
6fe6404e61 | ||
|
|
212899bc2b | ||
|
|
f8923b5275 | ||
|
|
97fa77287f | ||
|
|
5ad28b2966 | ||
|
|
1a7f73a5bd | ||
|
|
91af589547 | ||
|
|
24fdbdf680 | ||
|
|
4774083dc9 | ||
|
|
67e8b941bb | ||
|
|
8841059070 | ||
|
|
be6947af06 | ||
|
|
790d01b587 | ||
|
|
464355434b | ||
|
|
a8b5a8f693 | ||
|
|
986f7ff52d | ||
|
|
07c43a6875 | ||
|
|
2045b1a3f2 | ||
|
|
b80163b6b4 | ||
|
|
088af2318e | ||
|
|
0606ea3e6e | ||
|
|
0442056367 | ||
|
|
9d1c6d2fc3 | ||
|
|
5449c05b5b | ||
|
|
ecb5417170 | ||
|
|
19fa23b619 | ||
|
|
419aff54fa | ||
|
|
f4926848d7 | ||
|
|
38b6160bd2 | ||
|
|
8699d8ee00 | ||
|
|
940b622c03 | ||
|
|
59cc453026 | ||
|
|
69d1319564 | ||
|
|
0f0657e523 | ||
|
|
b0740503bb | ||
|
|
fdb0f321e4 | ||
|
|
c4c93cff02 | ||
|
|
b52080e026 | ||
|
|
e79769be81 | ||
|
|
34701bd6b1 | ||
|
|
205672b77d | ||
|
|
f36dbfa9cb | ||
|
|
9c865e1faa | ||
|
|
69c236d8cc | ||
|
|
2387223f0b | ||
|
|
8fa4ea02fe | ||
|
|
b5a47c6d8a | ||
|
|
6bca42754c | ||
|
|
7af6242003 | ||
|
|
a95c0540d9 | ||
|
|
74bca20a3c | ||
|
|
b602d099ca | ||
|
|
2445ba56b5 | ||
|
|
f699576582 | ||
|
|
8494eb5e6a | ||
|
|
7b12a06d1b | ||
|
|
66040a8841 | ||
|
|
c4ef954dea | ||
|
|
a0533a8ed8 | ||
|
|
44e954920c | ||
|
|
8962840654 | ||
|
|
525fd2ee01 | ||
|
|
64a2dade74 | ||
|
|
d6ad8e11a4 | ||
|
|
6c18a9da79 | ||
|
|
a261e261f0 | ||
|
|
058cbc6994 | ||
|
|
53e0eeb776 | ||
|
|
5bc6e144d2 | ||
|
|
8484587313 | ||
|
|
7694ff3388 | ||
|
|
2165bfc459 | ||
|
|
0fd9ab9bc2 | ||
|
|
4611f90d11 | ||
|
|
ed240ef73c | ||
|
|
f57b7503ed | ||
|
|
1f6fa6f1ae | ||
|
|
d44aa44d95 | ||
|
|
7c1b807e5e | ||
|
|
da0ac79079 | ||
|
|
9cefc6a198 | ||
|
|
8718e8b6bf | ||
|
|
358adbff21 | ||
|
|
18325fdfcc | ||
|
|
a65bcf4258 | ||
|
|
f640af1fc4 | ||
|
|
a34d929330 | ||
|
|
671bbb78bd | ||
|
|
afe6cc24aa | ||
|
|
7efd616c29 | ||
|
|
a970eae7d2 | ||
|
|
149cd153b9 | ||
|
|
72451f89a8 | ||
|
|
84c2867062 | ||
|
|
27571a4ab6 | ||
|
|
9a17715d0f | ||
|
|
8ca585a5be | ||
|
|
7fb46b5e0d | ||
|
|
0d2dee815e | ||
|
|
ba023bc416 | ||
|
|
de7e27c80f | ||
|
|
2dd8bf9724 | ||
|
|
4bebe64ff0 | ||
|
|
ca907762aa | ||
|
|
c53848477f | ||
|
|
1bdc3323ab | ||
|
|
4eee98467d | ||
|
|
19013a61dc | ||
|
|
0ac2973f46 | ||
|
|
041df061f9 | ||
|
|
5ad2746ddb | ||
|
|
17fdf32a3c | ||
|
|
0cfc72166a | ||
|
|
622bf1e440 | ||
|
|
0891b4e6a8 | ||
|
|
65f2a1d4ec | ||
|
|
8f8ce5122a | ||
|
|
2523808e3b | ||
|
|
517f11c897 | ||
|
|
1b078cdfd3 | ||
|
|
7b5c601821 | ||
|
|
4202c51a5b | ||
|
|
6e7db4a6a9 | ||
|
|
8281832e1d | ||
|
|
b7cdc4ad96 | ||
|
|
7309a25764 | ||
|
|
44d10f667c | ||
|
|
e7840ba919 | ||
|
|
5853009d71 | ||
|
|
a2685fb602 | ||
|
|
1bbfd15907 | ||
|
|
4fa9dae37f | ||
|
|
e15ecbdb29 | ||
|
|
1dd6991174 | ||
|
|
b67cc960a0 | ||
|
|
8a3eebc48f |
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"csharpier": {
|
||||
"version": "1.3.0",
|
||||
"commands": [
|
||||
"csharpier"
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
# Базовые правила для редакторов — чтобы IDE не ставила свои отступы и концы строк до того, как
|
||||
# файл дойдёт до форматтера. Итоговый стиль всё равно задают csharpier (.config/dotnet-tools.json)
|
||||
# и Prettier (frontend/.prettierrc.json); значения здесь совпадают с их настройками намеренно —
|
||||
# оба читают .editorconfig, и расхождение развернуло бы форматирование в другую сторону.
|
||||
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
max_line_length = 100
|
||||
|
||||
[*.cs]
|
||||
indent_size = 4
|
||||
|
||||
[*.md]
|
||||
# В markdown два пробела в конце строки — это перенос, обрезать их нельзя.
|
||||
trim_trailing_whitespace = false
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
# 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
|
||||
# Через сколько часов простоя убирать брошенное в uploads/ (след оборванной заливки или рестарта
|
||||
# посреди неё). Идущую загрузку не задевает — отсчёт от времени последней записи. 0 — не убирать.
|
||||
# Storage__StaleUploadHours=24
|
||||
# TTL stream-токена (cookie tw_stream), минуты. Короткий срок ограничивает окно доступа после
|
||||
# блокировки/логаута; фронт перевыпускает cookie, пока идёт просмотр. По умолчанию 30.
|
||||
# Storage__StreamTokenMinutes=30
|
||||
|
||||
# ── Планировщик эфира ──────────────────────────────────────────────────────
|
||||
# HorizonDays — на сколько суток вперёд держать материализованное расписание. Неделя — часть замысла:
|
||||
# «знать, что мультики будут в субботу в 9:30» работает, только если программа известна заранее.
|
||||
# RetentionDays — сколько суток прошедшей ленты хранить. Окно обязано покрывать самое долгое остывание
|
||||
# и самый глубокий повтор среди правил канала: история показов берётся из самой ленты, отдельного
|
||||
# журнала нет — укоротишь окно, и остывание с потолком повторов начнут «забывать» показы.
|
||||
# TickMinutes — период фонового тика, достраивающего горизонт.
|
||||
Scheduler__HorizonDays=7
|
||||
Scheduler__RetentionDays=90
|
||||
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
|
||||
# Заводить фильм по файлу из inbox/, когда имя уверенно опознано в источнике метаданных.
|
||||
# Неуверенное совпадение остаётся человеку: файл станет ассетом без шоу и попадёт в разбор.
|
||||
Media__InboxAutoMovies=true
|
||||
# Нормализация громкости при обработке (EBU R128 loudnorm) — все ролики одинаковой громкости.
|
||||
Media__NormalizeLoudness=true
|
||||
# Целевая громкость, LUFS (−16 типично для стриминга; тише — уменьшить).
|
||||
Media__LoudnessTargetLufs=-16
|
||||
# Таймауты вызовов ffprobe/ffmpeg, секунды (0 — без таймаута): не дают зависшему процессу вечно
|
||||
# держать слот параллелизма/тик планировщика. Поднять TranscodeTimeoutSeconds для очень длинных файлов.
|
||||
# Media__ProbeTimeoutSeconds=120
|
||||
# Media__TranscodeTimeoutSeconds=1800
|
||||
# Предпочитаемые языки аудиодорожек ПО УМОЛЧАНИЮ (через запятую, в порядке приоритета) — если файл
|
||||
# многоозвучный и есть дорожка с таким языком, при обработке берётся она. Обычно задаётся в админке
|
||||
# («Настройки»); этот конфиг — дефолт, если в настройках пусто. Пусто — выбор ffmpeg по умолчанию.
|
||||
# Media__PreferredAudioLanguages=rus,eng
|
||||
|
||||
# ── ТВ-заставки «Сейчас/Далее» (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), OMDb — omdbapi.com.
|
||||
# Без ключа источник просто не показывается в админке (ручной режим работает всегда).
|
||||
# TMDb принимает оба вида учётных данных, различать их не нужно — подставляй что есть:
|
||||
# «API Key (v3 auth)» — 32 hex-символа, уходит параметром api_key;
|
||||
# «API Read Access Token (v4 auth)» — длинный JWT с точками, уходит заголовком Bearer.
|
||||
Metadata__Language=ru-RU
|
||||
Metadata__Tmdb__ApiKey=
|
||||
Metadata__Omdb__ApiKey=
|
||||
# Страны возрастной сертификации TMDb в порядке приоритета: берётся первая, у которой рейтинг есть.
|
||||
# US впереди намеренно — его шкала (G/PG/PG-13/R/NC-17) совпадает с нашей и переводится точно;
|
||||
# RU (0+…18+) приводится приблизительно и нужен ради покрытия там, где US-сертификации нет.
|
||||
# Metadata__Tmdb__CertificationCountries__0=US
|
||||
# Metadata__Tmdb__CertificationCountries__1=RU
|
||||
|
||||
# ── 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
|
||||
@@ -0,0 +1,15 @@
|
||||
# Коммиты, которые правили только оформление: перевод концов строк в LF и первые прогоны
|
||||
# csharpier/Prettier. Без этого списка `git blame` на половине файлов показывал бы их вместо
|
||||
# автора реальной правки.
|
||||
#
|
||||
# Включить локально (в .git/config, а не в репозитории — потому команду надо выполнить у себя):
|
||||
# git config blame.ignoreRevsFile .git-blame-ignore-revs
|
||||
#
|
||||
# Добавлять сюда только чисто механические коммиты, полным SHA, по одному на строку:
|
||||
# git rev-parse HEAD >> .git-blame-ignore-revs
|
||||
|
||||
# Перевод дерева в LF.
|
||||
0442056367cb6cd3b38964dacf75f44f123e11ac
|
||||
|
||||
# Первый прогон Prettier по фронтенду.
|
||||
0606ea3e6ee4a4fd7faf8d1b054a316633442e98
|
||||
@@ -0,0 +1,10 @@
|
||||
# Концы строк в репозитории — всегда LF. Бэкенд и так лежал в LF, фронтенд — в CRLF; Prettier
|
||||
# (endOfLine: lf по умолчанию) выровнял его, и этот файл не даёт разъехаться обратно: без него
|
||||
# при core.autocrlf=true у другого разработчика CRLF вернулись бы в коммит.
|
||||
* text=auto eol=lf
|
||||
|
||||
# Бинарники не трогаем.
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.ico binary
|
||||
*.woff2 binary
|
||||
@@ -0,0 +1,170 @@
|
||||
name: ci
|
||||
|
||||
# Один workflow вместо трёх: порядок build → tests → sonar задаётся через `needs`, а между
|
||||
# отдельными файлами workflow'ов в Gitea связать этапы нечем. Побочный эффект — этапы идут
|
||||
# последовательно, поэтому прогон дольше, зато тесты не стартуют на заведомо битой сборке,
|
||||
# а анализ не уходит в SonarCloud с красными тестами.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-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
|
||||
# Форматирование проверяем до сборки: падает за секунду и сразу показывает, что чинить.
|
||||
# Версия csharpier прибита в .config/dotnet-tools.json — глобальная копия разработчика на CI
|
||||
# не влияет, иначе её минорка переформатировала бы репозиторий в свой стиль.
|
||||
- name: Format check
|
||||
working-directory: backend
|
||||
run: |
|
||||
dotnet tool restore
|
||||
dotnet csharpier check .
|
||||
# Строгая сборка: TreatWarningsAsErrors=true из Directory.Build.props не отключаем.
|
||||
- name: Build (Release)
|
||||
working-directory: backend
|
||||
run: dotnet build TeleWave.slnx -c Release --no-restore
|
||||
|
||||
build-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: Format check
|
||||
working-directory: frontend
|
||||
run: pnpm format:check
|
||||
- name: Lint
|
||||
working-directory: frontend
|
||||
run: pnpm lint
|
||||
- name: Typecheck
|
||||
working-directory: frontend
|
||||
run: pnpm typecheck
|
||||
- name: Build
|
||||
working-directory: frontend
|
||||
run: pnpm build
|
||||
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-backend, build-frontend]
|
||||
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
|
||||
# Покрытие здесь не собираем: его считает джоба sonar своим прогоном (в формате opencover),
|
||||
# а бейдж покрытия в README берётся из SonarCloud.
|
||||
- name: Test
|
||||
working-directory: backend
|
||||
run: >
|
||||
dotnet test TeleWave.slnx
|
||||
-c Release
|
||||
--no-restore
|
||||
--logger "console;verbosity=normal"
|
||||
|
||||
# Анализ SonarCloud вместе с покрытием: один прогон собирает решение под сканером, гоняет тесты
|
||||
# и отправляет результат. Только push в main — декорация pull request'ов у SonarCloud завязана
|
||||
# на GitHub/GitLab, из Gitea она не работает, и анализ PR только засорял бы ветки в проекте.
|
||||
sonar:
|
||||
runs-on: ubuntu-latest
|
||||
needs: tests
|
||||
if: github.event_name != 'pull_request'
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
SONAR_HOST: https://sonarcloud.io
|
||||
SONAR_PROJECT_KEY: mrleo1nid_telewave
|
||||
SONAR_ORGANIZATION: mrleo1nid
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# Полная история — иначе Sonar не сопоставит изменения с авторами и «новым кодом».
|
||||
fetch-depth: 0
|
||||
|
||||
# Сканер — java-приложение, на runner-образе Java может не оказаться.
|
||||
- uses: actions/setup-java@v4
|
||||
if: env.SONAR_TOKEN != ''
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
|
||||
- uses: actions/setup-dotnet@v4
|
||||
if: env.SONAR_TOKEN != ''
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
|
||||
- name: Install scanner
|
||||
if: env.SONAR_TOKEN != ''
|
||||
run: dotnet tool install --global dotnet-sonarscanner
|
||||
|
||||
# Сканер запускается из корня репозитория, а не из backend/: так в анализ попадает и фронт.
|
||||
- name: Begin analysis
|
||||
if: env.SONAR_TOKEN != ''
|
||||
run: |
|
||||
export PATH="$PATH:$HOME/.dotnet/tools"
|
||||
dotnet sonarscanner begin \
|
||||
/k:"$SONAR_PROJECT_KEY" \
|
||||
/o:"$SONAR_ORGANIZATION" \
|
||||
/d:sonar.host.url="$SONAR_HOST" \
|
||||
/d:sonar.token="$SONAR_TOKEN" \
|
||||
/d:sonar.cs.opencover.reportsPaths="**/coverage/**/coverage.opencover.xml" \
|
||||
/d:sonar.exclusions="frontend/node_modules/**,frontend/dist/**,frontend/src/routeTree.gen.ts,backend/src/TeleWave.Infrastructure/Migrations/**" \
|
||||
/d:sonar.coverage.exclusions="backend/src/TeleWave.Infrastructure/Migrations/**,frontend/**" \
|
||||
/d:sonar.cpd.exclusions="frontend/src/shared/lib/locales/**"
|
||||
|
||||
# Локали исключены из поиска дублей намеренно: ru.ts и en.ts обязаны совпадать ключ в ключ —
|
||||
# это не копипаста, а единственный способ не потерять перевод, и «устранение» такого дубля
|
||||
# означало бы отказ от второго языка.
|
||||
|
||||
# Сканер подмешивает в сборку свои анализаторы, а в проекте TreatWarningsAsErrors=true —
|
||||
# любое замечание Sonar роняло бы сборку вместо того, чтобы приехать в отчёт. Строгая
|
||||
# сборка живёт в джобе build-backend, здесь она нужна только как носитель анализа.
|
||||
- name: Build
|
||||
if: env.SONAR_TOKEN != ''
|
||||
run: dotnet build backend/TeleWave.slnx -c Release /p:TreatWarningsAsErrors=false
|
||||
|
||||
# Формат opencover, а не cobertura по умолчанию: C#-анализатор Sonar читает именно его.
|
||||
# Интеграционные тесты без Docker пропускаются сами (см. PostgresFixture).
|
||||
#
|
||||
# Проекты гоняются по одному, а не одним прогоном по решению: при общем прогоне сборщик
|
||||
# покрытия теряет попадания — отчёт приезжает без модуля TeleWave.Application, и в Sonar
|
||||
# покрытые тестами файлы выглядят нулевыми. Отчёты складываются в один каталог, сканер
|
||||
# подбирает их маской.
|
||||
- name: Test + coverage
|
||||
if: env.SONAR_TOKEN != ''
|
||||
run: |
|
||||
for project in Domain Application Integration; do
|
||||
dotnet test "backend/tests/TeleWave.$project.Tests" \
|
||||
-c Release \
|
||||
--no-build \
|
||||
--collect:"XPlat Code Coverage;Format=opencover" \
|
||||
--results-directory backend/coverage \
|
||||
--logger "console;verbosity=normal"
|
||||
done
|
||||
|
||||
- name: End analysis
|
||||
if: env.SONAR_TOKEN != ''
|
||||
run: |
|
||||
export PATH="$PATH:$HOME/.dotnet/tools"
|
||||
dotnet sonarscanner end /d:sonar.token="$SONAR_TOKEN"
|
||||
|
||||
- name: Skipped
|
||||
if: env.SONAR_TOKEN == ''
|
||||
run: echo "SONAR_TOKEN не задан — анализ пропущен."
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
# ---> 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/
|
||||
|
||||
# Отчёты покрытия (артефакт dotnet test / CI)
|
||||
coverage/
|
||||
TestResults/
|
||||
|
||||
# ---> TeleWave
|
||||
# Отладочные дампы каналов: архивы кладёт сюда бэкенд (секция Debug:ExportDirectory).
|
||||
debug-exports/*
|
||||
!debug-exports/.gitkeep
|
||||
@@ -0,0 +1,128 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Инструкции для Claude Code при работе в этом репозитории.
|
||||
|
||||
## Что это
|
||||
|
||||
**TeleWave** — сервис онлайн-каналов: зритель смотрит сетку каналов, видео отдаётся из хранилища
|
||||
на сервере. Админ ведёт библиотеку, каналы и эфир.
|
||||
|
||||
Работает: аутентификация, роли, пользователи; библиотека шоу/серий, коллекции, жанры, метаданные
|
||||
(TMDb/OMDb), обработка медиа (ffmpeg → HLS), изображения; группы, шаблон сетки, стыки и заставки,
|
||||
автосборка по профилям, обмен конфигурацией файлом и запросом к ИИ; генерация ленты, live-раздача,
|
||||
публичный просмотр, статистика хранилища. Telegram-бот расписания (подписки на оповещения по каналам, прокси, опрос или вебхук). Конвенции — зеркало
|
||||
[`PnvPanel`](../PnvPanel) (тот же автор).
|
||||
|
||||
**Спеки в `docs/` — источник правды по домену**, читай до правок в подсистеме:
|
||||
[`tv-scheduler-architecture.md`](docs/tv-scheduler-architecture.md) (группы, сетка, слоты, стыки,
|
||||
заставки, планировщик, автосборка, обмен конфигурацией — главный документ),
|
||||
[`media-storage-and-streaming.md`](docs/media-storage-and-streaming.md) и
|
||||
[`server-storage-setup.md`](docs/server-storage-setup.md) (медиа и хранилище).
|
||||
|
||||
## Стек
|
||||
|
||||
- **Backend**: C# / .NET 10, ASP.NET Core Minimal API, Clean Architecture, CQRS через
|
||||
[`LiteCqrs.Net`](https://github.com/mrleo1nid/LiteCqrs.Net) (лёгкая альтернатива MediatR с явным
|
||||
разделением Command/Query — см. README). EF Core 10 + Npgsql, Identity + JWT, FluentValidation,
|
||||
Serilog, маппинг DTO вручную, OpenAPI + Scalar UI, тесты на xUnit + NSubstitute.
|
||||
- **Frontend**: React 19 + Vite + TS, TanStack Query/Router (файловые роуты, `routeTree.gen.ts`
|
||||
генерируется сборкой), shadcn поверх Radix + Tailwind v4, Zustand (только auth+тема),
|
||||
react-hook-form + zod, i18n (ru/en), pnpm + oxlint + prettier.
|
||||
|
||||
## Архитектура — жёсткие правила
|
||||
|
||||
Зависимости строго внутрь: **Api → Infrastructure → Application → Domain**.
|
||||
|
||||
- **Domain** — без внешних зависимостей: `Auth`, `Library` (шоу, серии, коллекции, жанры), `Media`,
|
||||
`Images`, `Programming` (группы, сетка, слоты, стыки + **чистый планировщик** `Planning`),
|
||||
`Broadcast` (каналы, заставки, лента, live), `Settings`. Модель rich: приватные сеттеры,
|
||||
фабрики `Create`, поведенческие методы.
|
||||
- **Application** — CQRS-хендлеры, DTO, валидаторы и **порты** (`Common/Interfaces/I*.cs`:
|
||||
`IAppDbContext`, `ICurrentUser`, `IMediaProcessor`, `IMediaStorage`, `IBumperRenderer`,
|
||||
`IMetadataProvider`, `IImageStore`, `IStorageInspector`, …). Зависит только от Domain: никаких
|
||||
`Npgsql`/`AspNetCore.Identity`, лишь их абстракции.
|
||||
- **Infrastructure** — реализации портов: EF Core (`AppDbContext : IdentityDbContext<AppUser,
|
||||
AppRole, Guid>`, конфигурации в `Persistence/Configurations`), ffmpeg, хранилище, метаданные,
|
||||
идемпотентный `DbInitializer` (роли + админ из env `AdminSeed__*`).
|
||||
- **Api** — Minimal API (`Endpoints/*Endpoints.cs`), middleware, DI composition root (`Program.cs`).
|
||||
|
||||
Обязательно:
|
||||
- Команды меняют состояние в транзакции (`UnitOfWorkBehavior`); запросы только читают. Диспетчер —
|
||||
`ISender`/`ICommandHandler`/`IQueryHandler`, регистрация в `Application/DependencyInjection.cs`.
|
||||
- Управляемые ошибки — через `Result<T>` (`Common/Models/Result.cs`), не исключениями. Валидация —
|
||||
FluentValidation через `ValidationBehavior`; хендлер не перепроверяет формат ввода.
|
||||
- Всё I/O асинхронно, `CancellationToken` пробрасывается до EF/HTTP; никаких `.Result`/`.Wait()`.
|
||||
Nullable включён, `TreatWarningsAsErrors=true` — предупреждения анализаторов не игнорировать.
|
||||
- Планировщик (`Domain/Programming/Planning`) — **чистая функция**: вход `PlanningInput`, никаких
|
||||
запросов и часов внутри. Данные под него собирает `Application/Programming/Planning`.
|
||||
|
||||
## Инварианты, которые легко нарушить
|
||||
|
||||
- **Роли** динамические, у пользователя ровно одна; системные `admin`/`user` (`IsSystem=true`) не
|
||||
переименовать и не удалить, последнего админа не разжаловать, себя не заблокировать и не удалить.
|
||||
- **Регистрация открытая** (создаётся `user`, токены сразу); понадобится модерация — заводи отдельный
|
||||
флаг, не переиспользуй `IsBlocked`. Вход по `UserName`, email и SMTP не используются; JWT access +
|
||||
refresh в httpOnly `tw_refresh_token` с обнаружением повторного использования отозванного.
|
||||
- **Правка сетки эфир не двигает**: команда поднимает ревизию шаблона, ленту пересобирает применение.
|
||||
- **Вещательные сутки** начинаются с `Channel.DayStartTime` (06:00), сетка задаётся во времени
|
||||
канала (`UtcOffsetMinutes`), в базе всё в UTC.
|
||||
|
||||
## Единый контейнер
|
||||
|
||||
- Один образ: Api раздаёт REST (`/api`) и статику SPA из `wwwroot` (fallback на `index.html`), один
|
||||
origin, база API — относительный `/api`. Multi-stage Dockerfile: node → dotnet sdk → aspnet.
|
||||
Отдельный nginx для статики не вводить — ломает требование единого контейнера.
|
||||
- Работает под непривилегированным `USER $APP_UID` (uid/gid 1654) **статически**, без
|
||||
gosu/entrypoint: права на bind-mount выставляет оператор (`docs/server-storage-setup.md` § 5).
|
||||
Data Protection не заводим — шифруемых секретов на диске нет. **TLS внешний**: `app` отдаёт HTTP
|
||||
и доверяет `X-Forwarded-*`.
|
||||
- docker-compose: только `app`. PostgreSQL внешний (`ConnectionStrings__Default` из `.env`), база
|
||||
и пользователь создаются заранее вручную; миграции применяются на старте (`ApplyMigrationsAsync`),
|
||||
прав `CREATEDB` не нужно.
|
||||
|
||||
## Соглашения по коду
|
||||
|
||||
- `<Verb><Noun>Command`/`<Get|List><Noun>Query` + `Handler`/`Validator` (где есть что проверить),
|
||||
папки по фичам (`Auth/Login/`, `Programming/Templates/Generate/`, …). Один публичный тип на файл =
|
||||
имя файла, кроме `Body` — тела Api-эндпоинтов, не совпадающие с командой 1:1 (`sealed record`
|
||||
внизу файла эндпоинта). DTO — суффикс `Dto`.
|
||||
- Ошибки — каталоги `XErrors` (`Error.Validation/NotFound/Conflict/…`), наружу единым
|
||||
`application/problem+json` (`Api/Common/ResultExtensions.cs`). Секреты не логировать.
|
||||
- **Изображения — только через реестр `Image`** (`Domain/Images`, галерея `features/admin/images`):
|
||||
картинка хранится как `ImageId` и выбирается контролом `ImageGallery`/`GalleryBrowser`, своих
|
||||
файловых полей не заводить. Файлы — `images/{id}{ext}`, отдаются по `/api/images/{id}`.
|
||||
- Фронт: фича = папка в `src/features/admin/<фича>`; тексты только через i18n, ключи в `ru.ts` и `en.ts`.
|
||||
- Комментарии объясняют **почему**, а не что; по-русски, как в существующем коде.
|
||||
|
||||
## Команды
|
||||
|
||||
Backend (из `backend/`):
|
||||
```bash
|
||||
dotnet build
|
||||
dotnet csharpier format . # только через dotnet (версия из .config/); в CI — csharpier check
|
||||
dotnet test tests/TeleWave.Domain.Tests # по одному проекту: MSBuild не берёт несколько
|
||||
dotnet test tests/TeleWave.Application.Tests
|
||||
dotnet test tests/TeleWave.Integration.Tests # Testcontainers; без Docker пропускаются
|
||||
dotnet run --project src/TeleWave.Api
|
||||
dotnet ef migrations add <Name> --project src/TeleWave.Infrastructure --startup-project src/TeleWave.Api
|
||||
```
|
||||
|
||||
Frontend (из `frontend/`):
|
||||
```bash
|
||||
pnpm install && pnpm dev
|
||||
pnpm lint && pnpm typecheck && pnpm build
|
||||
pnpm format && pnpm format:check # CI падает на неотформатированном — прогоняй после правок
|
||||
```
|
||||
|
||||
Инфраструктура: `docker compose up -d --build` (только api; postgres внешний). Окружение — Windows,
|
||||
оболочка **PowerShell**; для POSIX-скриптов есть Bash-инструмент.
|
||||
|
||||
## Рабочие принципы
|
||||
|
||||
- Базовый срез реализован — правь по месту. Новые крупные направления с непринятыми решениями
|
||||
(многоэкземплярность, транскод-профили, подсистемы) — после сверки; неоднозначность — вопрос.
|
||||
- Меняешь поведение подсистемы — обнови её спеку в `docs/` тем же заходом.
|
||||
- Соблюдай границы слоёв — главный инвариант проекта.
|
||||
- Правь файлы через Edit/Write: пользователь видит дифф, а harness не даст перезаписать файл вслепую.
|
||||
Скриптом на python — только при крайней необходимости (массовая механическая замена), и сказать явно.
|
||||
- Не коммить и не пуши без явной просьбы; отвечай пользователю на русском.
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ── Stage 1: сборка фронтенда (Vite → dist) ───────────────────────────────
|
||||
FROM node:22-alpine AS frontend
|
||||
WORKDIR /app/frontend
|
||||
RUN corepack enable
|
||||
# pnpm-workspace.yaml здесь не про воркспейсы: в pnpm 11 там живут overrides, и без него
|
||||
# --frozen-lockfile падает на несовпадении конфигурации с локом.
|
||||
COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./
|
||||
# --ignore-scripts: ни один пакет фронта не требует postinstall, поэтому lifecycle-скрипты зависимостей
|
||||
# в сборке не выполняются — компрометация любого пакета в дереве не даёт исполнения кода на этапе install.
|
||||
RUN corepack prepare pnpm@11.9.0 --activate && pnpm install --frozen-lockfile --ignore-scripts
|
||||
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 ./
|
||||
# Работаем под непривилегированным пользователем образа aspnet (app, uid/gid 1654 — $APP_UID задан
|
||||
# в самом образе). Порт 8080 непривилегированный, установка пакетов уже позади, приложению нужна
|
||||
# только запись в /media. ВАЖНО: bind-mount пробрасывает права хоста как есть — каталог хранилища
|
||||
# должен принадлежать uid 1654, иначе контейнер не сможет писать (см. docs/server-storage-setup.md § 5).
|
||||
USER $APP_UID
|
||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=5 \
|
||||
CMD curl -f http://localhost:8080/health || exit 1
|
||||
ENTRYPOINT ["dotnet", "TeleWave.Api.dll"]
|
||||
@@ -0,0 +1,86 @@
|
||||
# TeleWave
|
||||
|
||||
[](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions?workflow=ci.yml)
|
||||
[](https://sonarcloud.io/summary/new_code?id=mrleo1nid_telewave)
|
||||
[](https://sonarcloud.io/summary/new_code?id=mrleo1nid_telewave)
|
||||
[](https://sonarcloud.io/summary/new_code?id=mrleo1nid_telewave)
|
||||
[](https://sonarcloud.io/summary/new_code?id=mrleo1nid_telewave)
|
||||
[](https://sonarcloud.io/summary/new_code?id=mrleo1nid_telewave)
|
||||
[](https://sonarcloud.io/summary/new_code?id=mrleo1nid_telewave)
|
||||
|
||||
**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)
|
||||
```
|
||||
|
||||
**Права на медиахранилище.** Контейнер работает под непривилегированным пользователем (uid/gid
|
||||
`1654`), а bind-mount пробрасывает права хоста как есть — каталог хранилища должен принадлежать
|
||||
этому uid, иначе приложение стартует, но любая запись в `/media` упадёт с `Permission denied`.
|
||||
Полная процедура (включая группу, через которую вы сами кладёте файлы в `inbox/` и `manual/`) —
|
||||
[`docs/server-storage-setup.md`](docs/server-storage-setup.md), шаг 5:
|
||||
|
||||
```bash
|
||||
sudo groupadd -g 1654 telewave
|
||||
sudo usermod -aG telewave "$USER" # затем перелогиниться
|
||||
sudo chown -R 1654:1654 /srv/telewave/media
|
||||
sudo chmod -R 750 /srv/telewave/media
|
||||
sudo chmod 2775 /srv/telewave/media/inbox /srv/telewave/media/manual
|
||||
```
|
||||
|
||||
> Обновляетесь с версии, где контейнер работал под root? Это и есть вся миграция: остановите
|
||||
> контейнер (`docker compose down`), выполните команды выше, поднимайте новый образ.
|
||||
|
||||
## Локальная разработка
|
||||
|
||||
```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) (стек, архитектурные
|
||||
правила, доменные инварианты, соглашения по коду).
|
||||
|
||||
## Лицензия
|
||||
|
||||
Не определена.
|
||||
@@ -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
|
||||
@@ -0,0 +1,26 @@
|
||||
<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>
|
||||
<!--
|
||||
CA1859 (сменить интерфейс коллекции на конкретный тип ради девиртуализации) — осознанно
|
||||
выключено: помечаются им границы API (доменные модели планировщика, порты, хелперы тестов),
|
||||
и «ускорение» там означает изменяемую коллекцию, протекающую через границу. Пути не горячие
|
||||
(генерация расписания идёт раз в тик, а не в цикле по кадрам). Понадобится — включать точечно
|
||||
по месту, а не глобально.
|
||||
|
||||
ASP0018 (параметр маршрута не используется хендлером) — тоже осознанно: часть шаблонов
|
||||
объявляет сегменты ради формы URL, а не ради хендлера. Так, превью заставки адресуется
|
||||
подблоком, но канал и блок остаются в пути, потому что по ним строит ссылки плейлист;
|
||||
у /{slug}/watch токен подтверждает зрителя, а не подписку на канал.
|
||||
-->
|
||||
<NoWarn>$(NoWarn);CA1711;CA1716;CA1848;CA1873;CA1859;ASP0018</NoWarn>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,49 @@
|
||||
<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" />
|
||||
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.6.0" />
|
||||
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.23" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,13 @@
|
||||
<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" />
|
||||
<Project Path="tests/TeleWave.Integration.Tests/TeleWave.Integration.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,34 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using TeleWave.Infrastructure.Media;
|
||||
|
||||
namespace TeleWave.Api.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Общая проверка файлов нарезки для всех эндпоинтов, отдающих HLS: эфир, превью заставок.
|
||||
/// Имя сегмента сверяется с allowlist, а путь резолвится через <see cref="MediaPathResolver"/>,
|
||||
/// который бросает <see cref="UnauthorizedAccessException"/> на попытку выйти за пределы каталога —
|
||||
/// наружу это должно выглядеть как обычный 404, а не как ошибка сервера.
|
||||
/// </summary>
|
||||
internal static partial class SegmentFiles
|
||||
{
|
||||
[GeneratedRegex(@"^seg\d{1,6}\.ts$")]
|
||||
private static partial Regex SegmentName();
|
||||
|
||||
public static bool IsSegmentName(string file) => SegmentName().IsMatch(file);
|
||||
|
||||
/// <summary>Путь к существующему файлу нарезки, либо null — если имя опасно или файла нет.</summary>
|
||||
public static string? TryResolveExisting(MediaPathResolver paths, Guid assetId, string fileName)
|
||||
{
|
||||
string path;
|
||||
try
|
||||
{
|
||||
path = paths.SegmentPath(assetId, fileName);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return File.Exists(path) ? path : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using TeleWave.Infrastructure.Media;
|
||||
|
||||
namespace TeleWave.Api.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Лимиты приёма файла, собранные в одно место. Они живут в двух разных секциях конфигурации
|
||||
/// (<c>Media</c> и <c>Storage</c>), но проверяются всегда вместе и только на входе загрузки —
|
||||
/// хендлеру незачем знать про обе секции и тащить два <see cref="IOptions{T}"/> в сигнатуре.
|
||||
/// </summary>
|
||||
public sealed class UploadLimits(IOptions<MediaOptions> media, IOptions<StorageOptions> storage)
|
||||
{
|
||||
/// <summary>Потолок размера загружаемого файла.</summary>
|
||||
public long MaxUploadBytes { get; } = media.Value.MaxUploadBytes;
|
||||
|
||||
/// <summary>Сколько места должно остаться свободным после приёма файла.</summary>
|
||||
public long MinFreeSpaceBytes { get; } = storage.Value.MinFreeSpaceBytes;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
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.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.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(
|
||||
[AsParameters] ListUsersFilter filter,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new ListUsersQuery(
|
||||
filter.Page is > 0 ? filter.Page.Value : 1,
|
||||
filter.PageSize is > 0 ? filter.PageSize.Value : 20,
|
||||
filter.Search,
|
||||
filter.RoleId,
|
||||
filter.IsBlocked,
|
||||
filter.Sort,
|
||||
filter.Desc ?? false
|
||||
),
|
||||
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> 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Фильтр и страница списка пользователей. Все поля nullable намеренно: обязательный параметр в query
|
||||
/// превращает запрос без него в 400 ещё до хендлера — ошибку в таком виде отладить тяжело. Умолчания
|
||||
/// подставляет сам хендлер, а не объявление: при <c>AsParameters</c> обязательность определяется
|
||||
/// nullable-типом, а не наличием значения по умолчанию в конструкторе.
|
||||
/// </summary>
|
||||
public sealed record ListUsersFilter(
|
||||
int? Page,
|
||||
int? PageSize,
|
||||
string? Search,
|
||||
Guid? RoleId,
|
||||
bool? IsBlocked,
|
||||
string? Sort,
|
||||
bool? Desc
|
||||
);
|
||||
|
||||
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,333 @@
|
||||
using System.Text;
|
||||
using LiteCqrs;
|
||||
using TeleWave.Api.Common;
|
||||
using TeleWave.Application.Broadcast.Bumpers;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Infrastructure.Identity;
|
||||
using TeleWave.Infrastructure.Media;
|
||||
|
||||
namespace TeleWave.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Блоки ТВ-заставок: оформление, звук, подблоки с текстом и рендер превью. Блоки общие для всех
|
||||
/// каналов, поэтому и раздел свой, не канальный — канал только ссылается на них врезками стыков.
|
||||
/// </summary>
|
||||
public static class BumperEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapBumperEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var admin = app.MapGroup("/api/admin/bumpers")
|
||||
.WithTags("Admin.Bumpers")
|
||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||
|
||||
admin.MapGet("", List).Produces<IReadOnlyList<BumperTemplateDto>>();
|
||||
admin.MapPost("", Create).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||
admin.MapPut("/{templateId:guid}", Update).Produces(StatusCodes.Status204NoContent);
|
||||
admin.MapDelete("/{templateId:guid}", Delete).Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
admin
|
||||
.MapPut("/{templateId:guid}/audio", UploadAudio)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
admin
|
||||
.MapDelete("/{templateId:guid}/audio", ClearAudio)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
admin
|
||||
.MapPut("/{templateId:guid}/background", SetBackground)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
admin
|
||||
.MapDelete("/{templateId:guid}/background", ClearBackground)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
admin
|
||||
.MapPost("/{templateId:guid}/variants", AddVariant)
|
||||
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||
admin
|
||||
.MapPut("/{templateId:guid}/variants/{variantId:guid}", UpdateVariant)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
admin
|
||||
.MapDelete("/{templateId:guid}/variants/{variantId:guid}", RemoveVariant)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
admin
|
||||
.MapPost("/{templateId:guid}/preview", RenderPreview)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
admin.MapGet("/{templateId:guid}/preview/{variantId:guid}/index.m3u8", PreviewPlaylist);
|
||||
admin.MapGet("/{templateId:guid}/preview/{variantId:guid}/{file}", PreviewSegment);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> List(ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await sender.Send(new ListBumperTemplatesQuery(), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> Create(
|
||||
BumperNameBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new CreateBumperTemplateCommand(body.Name),
|
||||
cancellationToken
|
||||
);
|
||||
return result.IsSuccess
|
||||
? Results.Created(
|
||||
$"/api/admin/bumpers/{result.Value}",
|
||||
new CreatedIdResponse(result.Value)
|
||||
)
|
||||
: result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> Update(
|
||||
Guid templateId,
|
||||
UpdateBumperTemplateBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new UpdateBumperTemplateCommand(
|
||||
templateId,
|
||||
new BumperStyle(
|
||||
body.Name,
|
||||
body.Font,
|
||||
body.BackgroundColor,
|
||||
body.BackgroundColor2,
|
||||
body.AccentColor,
|
||||
body.TextColor
|
||||
)
|
||||
),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> Delete(
|
||||
Guid templateId,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new DeleteBumperTemplateCommand(templateId),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> UploadAudio(
|
||||
Guid templateId,
|
||||
string fileName,
|
||||
HttpRequest request,
|
||||
IBumperTemplateStorage storage,
|
||||
IAudioProbe probe,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (ResolveExtension(fileName, request) is not { } ext)
|
||||
return BumperErrors.InvalidFile.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(templateId, ext, duration?.TotalSeconds ?? 0),
|
||||
cancellationToken
|
||||
);
|
||||
if (!result.IsSuccess)
|
||||
await storage.DeleteAudioAsync(templateId, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ClearAudio(
|
||||
Guid templateId,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new ClearBumperTemplateAudioCommand(templateId),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> SetBackground(
|
||||
Guid templateId,
|
||||
SetBumperBackgroundBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new SetBumperTemplateBackgroundCommand(templateId, body.ImageId),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ClearBackground(
|
||||
Guid templateId,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new ClearBumperTemplateBackgroundCommand(templateId),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> AddVariant(
|
||||
Guid templateId,
|
||||
BumperNameBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new AddBumperVariantCommand(templateId, body.Name),
|
||||
cancellationToken
|
||||
);
|
||||
return result.IsSuccess
|
||||
? Results.Created(
|
||||
$"/api/admin/bumpers/{templateId}",
|
||||
new CreatedIdResponse(result.Value)
|
||||
)
|
||||
: result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateVariant(
|
||||
Guid templateId,
|
||||
Guid variantId,
|
||||
BumperVariantInput input,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new UpdateBumperVariantCommand(templateId, variantId, input),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> RemoveVariant(
|
||||
Guid templateId,
|
||||
Guid variantId,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new RemoveBumperVariantCommand(templateId, variantId),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
/// <summary>Синхронно рендерит пример заставки блока (несколько секунд ffmpeg).</summary>
|
||||
private static async Task<IResult> RenderPreview(
|
||||
Guid templateId,
|
||||
Guid? channelId,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new RenderBumperPreviewCommand(templateId, channelId),
|
||||
cancellationToken
|
||||
);
|
||||
return result.IsSuccess ? Results.NoContent() : result.ToHttpResult();
|
||||
}
|
||||
|
||||
/// <summary>Плейлист превью подблока: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
|
||||
private static IResult PreviewPlaylist(Guid templateId, Guid variantId, MediaPathResolver paths)
|
||||
{
|
||||
var previewId = BumperPreview.AssetId(variantId);
|
||||
if (SegmentFiles.TryResolveExisting(paths, previewId, "index.m3u8") is not { } indexPath)
|
||||
return Results.NotFound();
|
||||
|
||||
var baseUrl = $"/api/admin/bumpers/{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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Сегмент превью. Блок в маршруте есть, но хендлеру не нужен: каталог превью адресуется
|
||||
/// подблоком (см. <see cref="BumperPreview.AssetId"/>) — незаявленные параметры не связываются.
|
||||
/// </summary>
|
||||
private static IResult PreviewSegment(Guid variantId, string file, MediaPathResolver paths)
|
||||
{
|
||||
if (!SegmentFiles.IsSegmentName(file))
|
||||
return Results.NotFound();
|
||||
|
||||
var previewId = BumperPreview.AssetId(variantId);
|
||||
if (SegmentFiles.TryResolveExisting(paths, previewId, file) is not { } path)
|
||||
return Results.NotFound();
|
||||
|
||||
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
|
||||
}
|
||||
|
||||
/// <summary>Проверяет расширение файла (по allowlist) и размер (Content-Length). Возвращает
|
||||
/// нормализованное расширение (с точкой, нижний регистр) или null при отказе.</summary>
|
||||
private static string? ResolveExtension(string fileName, HttpRequest request)
|
||||
{
|
||||
if (request.ContentLength is > BumperFiles.MaxBytes or 0 or null)
|
||||
return null;
|
||||
var ext = Path.GetExtension(fileName).ToLowerInvariant();
|
||||
return BumperFiles.AudioExtensions.Contains(ext) ? ext : null;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record BumperNameBody(string Name);
|
||||
|
||||
public sealed record UpdateBumperTemplateBody(
|
||||
string Name,
|
||||
BumperFont Font,
|
||||
string BackgroundColor,
|
||||
string BackgroundColor2,
|
||||
string AccentColor,
|
||||
string TextColor
|
||||
);
|
||||
|
||||
public sealed record SetBumperBackgroundBody(Guid ImageId);
|
||||
|
||||
/// <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,210 @@
|
||||
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.UpdateChannelSettings;
|
||||
using TeleWave.Application.Broadcast.UpdateChannelTime;
|
||||
using TeleWave.Application.Broadcast.UpdateViewerSettings;
|
||||
using TeleWave.Application.Programming.Planning.DebugExport;
|
||||
using TeleWave.Application.Programming.Planning.Trace;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Infrastructure.Identity;
|
||||
|
||||
namespace TeleWave.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Админ-эндпоинты канала: создание, список, настройки, время и чтение расписания. Заставки
|
||||
/// и стыки общие для всех каналов и живут своими разделами (<c>BumperEndpoints</c>,
|
||||
/// <c>JunctionEndpoints</c>); что и когда идёт в эфире, задаёт шаблон сетки (<c>TemplateEndpoints</c>).
|
||||
/// </summary>
|
||||
public static 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.MapPut("/{id:guid}/time", UpdateTime).Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
admin
|
||||
.MapGet("/{id:guid}/schedule", GetSchedule)
|
||||
.Produces<IReadOnlyList<ScheduleEntryDto>>();
|
||||
admin
|
||||
.MapPut("/{id:guid}/viewer", UpdateViewerSettings)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
// «Почему это здесь»: цепочка происхождения записи, записанная в момент генерации.
|
||||
admin.MapGet("/entries/{entryId:guid}/trace", GetEntryTrace).Produces<EntryTraceDto>();
|
||||
|
||||
// Отладочный дамп: вход планировщика и результат одним архивом.
|
||||
admin.MapPost("/{id:guid}/debug-export", ExportDebug).Produces(StatusCodes.Status200OK);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Отдаёт архив со снимком канала. POST, а не GET: команда оставляет копию дампа на сервере,
|
||||
/// и кэшировать такой ответ никому не следует.
|
||||
/// </summary>
|
||||
private static async Task<IResult> ExportDebug(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new ExportChannelDebugCommand(id), cancellationToken);
|
||||
if (!result.IsSuccess)
|
||||
return result.ToHttpResult();
|
||||
|
||||
return Results.File(result.Value.Content, "application/zip", result.Value.FileName);
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateViewerSettings(
|
||||
Guid id,
|
||||
UpdateViewerSettingsBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new UpdateViewerSettingsCommand(
|
||||
id,
|
||||
body.LogoImageId,
|
||||
body.LogoCorner,
|
||||
body.LogoOpacity,
|
||||
body.ShowClock,
|
||||
body.AnalogFilterStrength
|
||||
),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetEntryTrace(
|
||||
Guid entryId,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new GetEntryTraceQuery(entryId), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
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> UpdateTime(
|
||||
Guid id,
|
||||
UpdateChannelTimeBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new UpdateChannelTimeCommand(id, body.Number, body.UtcOffsetMinutes, body.DayStartTime),
|
||||
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.FillerAssetId,
|
||||
body.IconImageId
|
||||
),
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Номер канала и его время: смещение от UTC и начало вещательных суток.</summary>
|
||||
public sealed record UpdateChannelTimeBody(
|
||||
int? Number,
|
||||
int UtcOffsetMinutes,
|
||||
TimeOnly DayStartTime
|
||||
);
|
||||
|
||||
public sealed record UpdateChannelSettingsBody(
|
||||
string Name,
|
||||
bool IsEnabled,
|
||||
Guid? FillerAssetId,
|
||||
Guid? IconImageId
|
||||
);
|
||||
|
||||
/// <summary>Оверлеи и аналоговый фильтр — как канал выглядит у зрителя (см. 6.8).</summary>
|
||||
public sealed record UpdateViewerSettingsBody(
|
||||
Guid? LogoImageId,
|
||||
LogoCorner LogoCorner,
|
||||
double LogoOpacity,
|
||||
bool ShowClock,
|
||||
double AnalogFilterStrength
|
||||
);
|
||||
@@ -0,0 +1,257 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Api.Common;
|
||||
using TeleWave.Application.Library.Collections;
|
||||
using TeleWave.Application.Library.Collections.AddCollectionShow;
|
||||
using TeleWave.Application.Library.Collections.AddShowsToCollection;
|
||||
using TeleWave.Application.Library.Collections.CreateCollection;
|
||||
using TeleWave.Application.Library.Collections.DeleteCollection;
|
||||
using TeleWave.Application.Library.Collections.FillCollectionPosters;
|
||||
using TeleWave.Application.Library.Collections.GetCollection;
|
||||
using TeleWave.Application.Library.Collections.ListCollections;
|
||||
using TeleWave.Application.Library.Collections.RemoveCollectionShow;
|
||||
using TeleWave.Application.Library.Collections.ReorderCollection;
|
||||
using TeleWave.Application.Library.Collections.SetCollectionPoster;
|
||||
using TeleWave.Application.Library.Collections.Suggest;
|
||||
using TeleWave.Application.Library.Collections.UpdateCollection;
|
||||
using TeleWave.Infrastructure.Identity;
|
||||
|
||||
namespace TeleWave.Api.Endpoints;
|
||||
|
||||
public static class CollectionEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapCollectionEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var admin = app.MapGroup("/api/admin/collections")
|
||||
.WithTags("Admin.Collections")
|
||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||
|
||||
admin.MapGet("", ListCollections).Produces<IReadOnlyList<CollectionSummaryDto>>();
|
||||
admin.MapGet("/{id:guid}", GetCollection).Produces<CollectionDto>();
|
||||
admin
|
||||
.MapPost("", CreateCollection)
|
||||
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||
admin.MapPut("/{id:guid}", UpdateCollection).Produces(StatusCodes.Status204NoContent);
|
||||
admin.MapDelete("/{id:guid}", DeleteCollection).Produces(StatusCodes.Status204NoContent);
|
||||
admin.MapPost("/{id:guid}/shows", AddShow).Produces(StatusCodes.Status204NoContent);
|
||||
admin
|
||||
.MapDelete("/{id:guid}/shows/{showId:guid}", RemoveShow)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
admin.MapPut("/{id:guid}/order", Reorder).Produces(StatusCodes.Status204NoContent);
|
||||
admin.MapPut("/{id:guid}/poster-image", SetPoster).Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
// Сборка франшиз: массовое добавление отмеченных шоу и предложения по библиотеке.
|
||||
admin.MapPost("/bulk/shows", AddShows).Produces<AddShowsToCollectionResultDto>();
|
||||
// Постеры по частям: без списка — всем коллекциям, у которых постера ещё нет.
|
||||
admin.MapPost("/bulk/posters", FillPosters).Produces<int>();
|
||||
admin
|
||||
.MapGet("/suggestions", Suggestions)
|
||||
.Produces<IReadOnlyList<CollectionSuggestionDto>>();
|
||||
admin
|
||||
.MapPost("/suggestions", CreateFromSuggestion)
|
||||
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||
// Дополнение уже собранной франшизы: коллекция определяется тем же ключом предложения.
|
||||
admin.MapPost("/suggestions/add", AddFromSuggestion).Produces<int>();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> FillPosters(
|
||||
FillCollectionPostersBody? body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new FillCollectionPostersCommand(body?.CollectionIds),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> AddShows(
|
||||
AddShowsToCollectionBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new AddShowsToCollectionCommand(
|
||||
body.ShowIds,
|
||||
body.CollectionId,
|
||||
body.NewCollectionName
|
||||
),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> Suggestions(
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new SuggestCollectionsQuery(), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateFromSuggestion(
|
||||
CreateCollectionFromSuggestionBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new CreateCollectionFromSuggestionCommand(body.Key),
|
||||
cancellationToken
|
||||
);
|
||||
return result.IsSuccess
|
||||
? Results.Created(
|
||||
$"/api/admin/collections/{result.Value}",
|
||||
new CreatedIdResponse(result.Value)
|
||||
)
|
||||
: result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> AddFromSuggestion(
|
||||
CreateCollectionFromSuggestionBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new AddSuggestedShowsCommand(body.Key), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListCollections(
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new ListCollectionsQuery(), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetCollection(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new GetCollectionQuery(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateCollection(
|
||||
CreateCollectionCommand command,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
return result.IsSuccess
|
||||
? Results.Created(
|
||||
$"/api/admin/collections/{result.Value}",
|
||||
new CreatedIdResponse(result.Value)
|
||||
)
|
||||
: result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateCollection(
|
||||
Guid id,
|
||||
UpdateCollectionBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new UpdateCollectionCommand(id, body.Name, body.Description),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteCollection(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new DeleteCollectionCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> AddShow(
|
||||
Guid id,
|
||||
CollectionShowBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new AddCollectionShowCommand(id, body.ShowId),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> RemoveShow(
|
||||
Guid id,
|
||||
Guid showId,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new RemoveCollectionShowCommand(id, showId),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> Reorder(
|
||||
Guid id,
|
||||
ReorderCollectionBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new ReorderCollectionCommand(id, body.ShowIdsInOrder),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> SetPoster(
|
||||
Guid id,
|
||||
CollectionPosterBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new SetCollectionPosterCommand(id, body.ImageId),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record UpdateCollectionBody(string Name, string? Description);
|
||||
|
||||
public sealed record CollectionShowBody(Guid ShowId);
|
||||
|
||||
public sealed record ReorderCollectionBody(IReadOnlyList<Guid> ShowIdsInOrder);
|
||||
|
||||
public sealed record CollectionPosterBody(Guid? ImageId);
|
||||
|
||||
/// <summary>Куда складывать: существующая коллекция либо новая по имени.</summary>
|
||||
public sealed record AddShowsToCollectionBody(
|
||||
IReadOnlyList<Guid> ShowIds,
|
||||
Guid? CollectionId = null,
|
||||
string? NewCollectionName = null
|
||||
);
|
||||
|
||||
public sealed record CreateCollectionFromSuggestionBody(string Key);
|
||||
|
||||
public sealed record FillCollectionPostersBody(IReadOnlyList<Guid>? CollectionIds);
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using LiteCqrs;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TeleWave.Application.Diagnostics;
|
||||
using TeleWave.Infrastructure.Identity;
|
||||
|
||||
namespace TeleWave.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Админская диагностика. Главное здесь — картина прокси-форвардинга: заголовки, которым доверяет
|
||||
/// приложение, и итоговая схема/хост, из которых строятся абсолютные ссылки (M3U/EPG). Именно тут
|
||||
/// видно, почему ссылки уходят по http вместо https: X-Forwarded-Proto пришёл, но сосед не доверен.
|
||||
/// Плюс живое состояние инфраструктуры (БД, том хранилища, ffmpeg) — через системный запрос.
|
||||
/// </summary>
|
||||
public static class DiagnosticsEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapDiagnosticsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/api/admin/diagnostics", Get)
|
||||
.WithTags("Admin.Diagnostics")
|
||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin))
|
||||
.Produces<DiagnosticsResponse>();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> Get(
|
||||
HttpContext http,
|
||||
IOptions<ForwardedHeadersOptions> forwardedOptions,
|
||||
IHostEnvironment env,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var system = await sender.Send(new GetSystemDiagnosticsQuery(), cancellationToken);
|
||||
var host = BuildHost(env);
|
||||
var forwarding = BuildForwarding(http, forwardedOptions.Value);
|
||||
|
||||
return Results.Ok(new DiagnosticsResponse(host, forwarding, system));
|
||||
}
|
||||
|
||||
private static HostDiagnosticsDto BuildHost(IHostEnvironment env)
|
||||
{
|
||||
var assembly = Assembly.GetEntryAssembly();
|
||||
var version =
|
||||
assembly
|
||||
?.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
|
||||
?.InformationalVersion
|
||||
?? assembly?.GetName().Version?.ToString();
|
||||
|
||||
return new HostDiagnosticsDto(
|
||||
version,
|
||||
env.EnvironmentName,
|
||||
RuntimeInformation.FrameworkDescription,
|
||||
RuntimeInformation.OSDescription,
|
||||
DateTimeOffset.UtcNow
|
||||
);
|
||||
}
|
||||
|
||||
private static ForwardingDiagnosticsDto BuildForwarding(
|
||||
HttpContext http,
|
||||
ForwardedHeadersOptions options
|
||||
)
|
||||
{
|
||||
var request = http.Request;
|
||||
var scheme = request.Scheme;
|
||||
var host = request.Host.Value ?? string.Empty;
|
||||
var isHttps = string.Equals(scheme, "https", StringComparison.OrdinalIgnoreCase);
|
||||
var remoteIp = http.Connection.RemoteIpAddress?.ToString();
|
||||
|
||||
var forwardedProto = Header(request, "X-Forwarded-Proto");
|
||||
// X-Original-* заголовки ставит сам ForwardedHeaders-middleware, когда доверяет соседу и
|
||||
// применяет форвардинг — по их наличию отличаем «применено» от «отброшено как недоверенное».
|
||||
var originalProto = Header(request, "X-Original-Proto");
|
||||
var originalRemoteIp = Header(request, "X-Original-For");
|
||||
var applied = originalProto is not null || originalRemoteIp is not null;
|
||||
|
||||
// Что прокси реально прислал (до возможного применения), и вывод с готовой подсказкой.
|
||||
var sentProto = originalProto ?? forwardedProto;
|
||||
string verdict;
|
||||
string? suggestedTrustedIp = null;
|
||||
if (isHttps)
|
||||
verdict = "ok";
|
||||
else if (string.Equals(sentProto, "https", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Заголовок пришёл, но схема осталась http → непосредственный сосед не в доверенных.
|
||||
verdict = "proxyNotTrusted";
|
||||
suggestedTrustedIp = remoteIp;
|
||||
}
|
||||
else
|
||||
verdict = "protoHeaderMissing";
|
||||
|
||||
return new ForwardingDiagnosticsDto(
|
||||
scheme,
|
||||
host,
|
||||
$"{scheme}://{host}",
|
||||
isHttps,
|
||||
remoteIp,
|
||||
forwardedProto,
|
||||
Header(request, "X-Forwarded-For"),
|
||||
Header(request, "X-Forwarded-Host"),
|
||||
Header(request, "X-Real-IP"),
|
||||
originalProto,
|
||||
originalRemoteIp,
|
||||
applied,
|
||||
options.ForwardLimit,
|
||||
options.ForwardedHeaders.ToString(),
|
||||
[.. options.KnownProxies.Select(ip => ip.ToString())],
|
||||
[.. options.KnownIPNetworks.Select(net => net.ToString())],
|
||||
verdict,
|
||||
suggestedTrustedIp
|
||||
);
|
||||
}
|
||||
|
||||
private static string? Header(HttpRequest request, string name) =>
|
||||
request.Headers.TryGetValue(name, out var value) && !string.IsNullOrEmpty(value)
|
||||
? value.ToString()
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>Ответ диагностики: процесс/рантайм, картина форвардинга и живое состояние инфраструктуры.</summary>
|
||||
public sealed record DiagnosticsResponse(
|
||||
HostDiagnosticsDto Host,
|
||||
ForwardingDiagnosticsDto Forwarding,
|
||||
SystemDiagnosticsDto System
|
||||
);
|
||||
|
||||
public sealed record HostDiagnosticsDto(
|
||||
string? AppVersion,
|
||||
string Environment,
|
||||
string Framework,
|
||||
string Os,
|
||||
DateTimeOffset ServerTimeUtc
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Картина X-Forwarded-*. <c>Verdict</c>: <c>ok</c> — схема https; <c>proxyNotTrusted</c> — прокси
|
||||
/// прислал https, но <c>Applied=false</c>, и <c>SuggestedTrustedIp</c> нужно внести в KnownProxies;
|
||||
/// <c>protoHeaderMissing</c> — прокси вообще не шлёт X-Forwarded-Proto.
|
||||
/// </summary>
|
||||
public sealed record ForwardingDiagnosticsDto(
|
||||
string Scheme,
|
||||
string Host,
|
||||
string Origin,
|
||||
bool IsHttps,
|
||||
string? RemoteIp,
|
||||
string? XForwardedProto,
|
||||
string? XForwardedFor,
|
||||
string? XForwardedHost,
|
||||
string? XRealIp,
|
||||
string? OriginalProto,
|
||||
string? OriginalRemoteIp,
|
||||
bool Applied,
|
||||
int? ForwardLimit,
|
||||
string ForwardedHeaders,
|
||||
IReadOnlyList<string> KnownProxies,
|
||||
IReadOnlyList<string> KnownNetworks,
|
||||
string Verdict,
|
||||
string? SuggestedTrustedIp
|
||||
);
|
||||
@@ -0,0 +1,77 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Api.Common;
|
||||
using TeleWave.Application.Library.Genres;
|
||||
using TeleWave.Application.Library.Genres.CreateGenre;
|
||||
using TeleWave.Application.Library.Genres.DeleteGenre;
|
||||
using TeleWave.Application.Library.Genres.ListGenres;
|
||||
using TeleWave.Application.Library.Genres.UpdateGenre;
|
||||
using TeleWave.Infrastructure.Identity;
|
||||
|
||||
namespace TeleWave.Api.Endpoints;
|
||||
|
||||
public static class GenreEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapGenreEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var admin = app.MapGroup("/api/admin/genres")
|
||||
.WithTags("Admin.Genres")
|
||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||
|
||||
admin.MapGet("", ListGenres).Produces<IReadOnlyList<GenreDto>>();
|
||||
admin.MapPost("", CreateGenre).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||
admin.MapPut("/{id:guid}", UpdateGenre).Produces(StatusCodes.Status204NoContent);
|
||||
admin.MapDelete("/{id:guid}", DeleteGenre).Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListGenres(
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new ListGenresQuery(), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateGenre(
|
||||
CreateGenreCommand command,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
return result.IsSuccess
|
||||
? Results.Created(
|
||||
$"/api/admin/genres/{result.Value}",
|
||||
new CreatedIdResponse(result.Value)
|
||||
)
|
||||
: result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateGenre(
|
||||
Guid id,
|
||||
UpdateGenreBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new UpdateGenreCommand(id, body.Name, body.SortOrder, body.Aliases),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteGenre(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new DeleteGenreCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record UpdateGenreBody(string Name, int SortOrder, IReadOnlyList<string>? Aliases);
|
||||
@@ -0,0 +1,256 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Api.Common;
|
||||
using TeleWave.Application.Programming.Groups;
|
||||
using TeleWave.Application.Programming.Groups.AddGroupElements;
|
||||
using TeleWave.Application.Programming.Groups.CreateGroup;
|
||||
using TeleWave.Application.Programming.Groups.DeleteGroup;
|
||||
using TeleWave.Application.Programming.Groups.ExcludeGroupElement;
|
||||
using TeleWave.Application.Programming.Groups.FindGroupCandidates;
|
||||
using TeleWave.Application.Programming.Groups.GetGroup;
|
||||
using TeleWave.Application.Programming.Groups.ListGroups;
|
||||
using TeleWave.Application.Programming.Groups.RemoveGroupItem;
|
||||
using TeleWave.Application.Programming.Groups.ReorderGroup;
|
||||
using TeleWave.Application.Programming.Groups.SetGroupItemWeight;
|
||||
using TeleWave.Application.Programming.Groups.Suggest;
|
||||
using TeleWave.Application.Programming.Groups.UpdateGroup;
|
||||
using TeleWave.Domain.Programming;
|
||||
using TeleWave.Infrastructure.Identity;
|
||||
|
||||
namespace TeleWave.Api.Endpoints;
|
||||
|
||||
public static class GroupEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapGroupEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var admin = app.MapGroup("/api/admin/groups")
|
||||
.WithTags("Admin.Groups")
|
||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||
|
||||
admin.MapGet("", ListGroups).Produces<IReadOnlyList<GroupSummaryDto>>();
|
||||
admin.MapGet("/{id:guid}", GetGroup).Produces<GroupDto>();
|
||||
admin.MapPost("", CreateGroup).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||
admin.MapPut("/{id:guid}", UpdateGroup).Produces(StatusCodes.Status204NoContent);
|
||||
admin.MapDelete("/{id:guid}", DeleteGroup).Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
// Подбор по правилу набора: правило можно передать в теле, чтобы крутить его до сохранения.
|
||||
admin
|
||||
.MapPost("/{id:guid}/candidates", FindCandidates)
|
||||
.Produces<IReadOnlyList<GroupCandidateDto>>();
|
||||
|
||||
// Предложения: что имеет смысл завести группой при нынешней библиотеке.
|
||||
admin.MapGet("/suggestions", Suggestions).Produces<IReadOnlyList<GroupSuggestionDto>>();
|
||||
admin
|
||||
.MapPost("/suggestions", CreateFromSuggestion)
|
||||
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||
|
||||
admin.MapPost("/{id:guid}/items", AddElements).Produces<AddedCountResponse>();
|
||||
admin
|
||||
.MapDelete("/{id:guid}/items/{itemId:guid}", RemoveItem)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
admin
|
||||
.MapPut("/{id:guid}/items/{itemId:guid}/weight", SetWeight)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
admin.MapPut("/{id:guid}/order", Reorder).Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
// Поправки к вычисленному составу динамической группы.
|
||||
admin.MapPost("/{id:guid}/exclusions", Exclude).Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListGroups(
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new ListGroupsQuery(), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetGroup(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new GetGroupQuery(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateGroup(
|
||||
CreateGroupCommand command,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
return result.IsSuccess
|
||||
? Results.Created(
|
||||
$"/api/admin/groups/{result.Value}",
|
||||
new CreatedIdResponse(result.Value)
|
||||
)
|
||||
: result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> Suggestions(
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new SuggestGroupsQuery(), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateFromSuggestion(
|
||||
CreateGroupFromSuggestionBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new CreateGroupFromSuggestionCommand(body.Key),
|
||||
cancellationToken
|
||||
);
|
||||
return result.IsSuccess
|
||||
? Results.Created(
|
||||
$"/api/admin/groups/{result.Value}",
|
||||
new CreatedIdResponse(result.Value)
|
||||
)
|
||||
: result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateGroup(
|
||||
Guid id,
|
||||
UpdateGroupBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new UpdateGroupCommand(id, body.Name, body.Description, body.Filter, body.Mode),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteGroup(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new DeleteGroupCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> FindCandidates(
|
||||
Guid id,
|
||||
FindCandidatesBody? body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new FindGroupCandidatesQuery(id, body?.Filter),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> AddElements(
|
||||
Guid id,
|
||||
AddGroupElementsBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new AddGroupElementsCommand(id, body.Elements),
|
||||
cancellationToken
|
||||
);
|
||||
return result.IsSuccess
|
||||
? Results.Ok(new AddedCountResponse(result.Value))
|
||||
: result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> RemoveItem(
|
||||
Guid id,
|
||||
Guid itemId,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new RemoveGroupItemCommand(id, itemId), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> SetWeight(
|
||||
Guid id,
|
||||
Guid itemId,
|
||||
GroupItemWeightBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new SetGroupItemWeightCommand(id, itemId, body.Weight),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> Exclude(
|
||||
Guid id,
|
||||
ExcludeElementBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new ExcludeGroupElementCommand(id, body.ElementKind, body.ElementId, body.Excluded),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> Reorder(
|
||||
Guid id,
|
||||
ReorderGroupBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new ReorderGroupCommand(id, body.ItemIdsInOrder),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record UpdateGroupBody(
|
||||
string Name,
|
||||
string? Description,
|
||||
GroupFilter? Filter,
|
||||
GroupMode? Mode = null
|
||||
);
|
||||
|
||||
/// <summary>Исключить элемент из динамической группы либо вернуть его правилу.</summary>
|
||||
public sealed record ExcludeElementBody(
|
||||
GroupElementKind ElementKind,
|
||||
Guid ElementId,
|
||||
bool Excluded
|
||||
);
|
||||
|
||||
public sealed record FindCandidatesBody(GroupFilter? Filter);
|
||||
|
||||
public sealed record CreateGroupFromSuggestionBody(string Key);
|
||||
|
||||
public sealed record AddGroupElementsBody(IReadOnlyList<GroupElementRef> Elements);
|
||||
|
||||
public sealed record GroupItemWeightBody(int Weight);
|
||||
|
||||
public sealed record ReorderGroupBody(IReadOnlyList<Guid> ItemIdsInOrder);
|
||||
|
||||
/// <summary>Сколько позиций реально добавлено (уже входящие в группу пропускаются).</summary>
|
||||
public sealed record AddedCountResponse(int Added);
|
||||
@@ -0,0 +1,131 @@
|
||||
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 МБ
|
||||
|
||||
// Тип поля — HashSet, а не IReadOnlySet: поле приватное, и через интерфейс Contains уходит
|
||||
// в виртуальный вызов вместо прямого.
|
||||
private static readonly HashSet<string> AllowedExtensions = new(
|
||||
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,64 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Api.Common;
|
||||
using TeleWave.Application.Library.Interstitials;
|
||||
using TeleWave.Application.Library.Interstitials.ImportInterstitials;
|
||||
using TeleWave.Application.Library.Interstitials.ListInterstitialBlocks;
|
||||
using TeleWave.Application.Library.Interstitials.ListInterstitials;
|
||||
using TeleWave.Infrastructure.Identity;
|
||||
|
||||
namespace TeleWave.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Ролики-врезки: тот же <c>Show(Kind = Interstitial)</c>, но со своим экраном (см. 6.7). Правка
|
||||
/// названия и удаление идут через обычные эндпоинты шоу — здесь только то, чего у библиотеки нет:
|
||||
/// список с длительностями, блоки и импорт загруженных файлов.
|
||||
/// </summary>
|
||||
public static class InterstitialEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapInterstitialEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var admin = app.MapGroup("/api/admin/interstitials")
|
||||
.WithTags("Admin.Interstitials")
|
||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||
|
||||
admin.MapGet("", List).Produces<IReadOnlyList<InterstitialDto>>();
|
||||
admin.MapGet("/blocks", ListBlocks).Produces<IReadOnlyList<InterstitialBlockDto>>();
|
||||
admin.MapPost("/import", Import).Produces<ImportInterstitialsResponse>();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> List(ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await sender.Send(new ListInterstitialsQuery(), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListBlocks(
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new ListInterstitialBlocksQuery(), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> Import(
|
||||
ImportInterstitialsBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new ImportInterstitialsCommand(body.MediaAssetIds),
|
||||
cancellationToken
|
||||
);
|
||||
return result.IsSuccess
|
||||
? Results.Ok(new ImportInterstitialsResponse(result.Value))
|
||||
: result.ToHttpResult();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ImportInterstitialsBody(IReadOnlyList<Guid> MediaAssetIds);
|
||||
|
||||
public sealed record ImportInterstitialsResponse(int Imported);
|
||||
@@ -0,0 +1,241 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Xml;
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Streaming.GetIptvGuide;
|
||||
using TeleWave.Infrastructure.Streaming;
|
||||
|
||||
namespace TeleWave.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Выгрузка для внешних IPTV-плееров: M3U со списком каналов и XMLTV с программой передач.
|
||||
///
|
||||
/// Плеер не умеет ни Bearer, ни httpOnly-cookie — он умеет только открыть URL. Поэтому пропуск
|
||||
/// зашит в сами ссылки файла долгим stream-токеном: **файл и есть доступ к эфиру**, и делиться им
|
||||
/// нельзя. Срок — <c>Storage:IptvTokenDays</c>; блокировка зрителя обрывает доступ на ближайшей
|
||||
/// перезагрузке плейлиста канала, а смена <c>Jwt:SigningKey</c> обнуляет все выданные разом.
|
||||
/// </summary>
|
||||
public static class IptvEndpoints
|
||||
{
|
||||
/// <summary>Сколько суток программы класть в XMLTV: горизонт ленты и так неделя.</summary>
|
||||
private static readonly TimeSpan GuideWindow = TimeSpan.FromDays(7);
|
||||
|
||||
/// <summary>Заглушка иконки канала, когда своя не задана: фавикон сайта из wwwroot.</summary>
|
||||
private const string FaviconPath = "/favicon.svg";
|
||||
|
||||
public static IEndpointRouteBuilder MapIptvEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var iptv = app.MapGroup("/api/iptv").WithTags("IPTV");
|
||||
|
||||
// Файл забирает браузер под обычной авторизацией — токен внутрь кладёт уже сервер.
|
||||
iptv.MapGet("/playlist.m3u", Playlist).RequireAuthorization();
|
||||
// XMLTV тянет сам плеер, у него из пропусков только токен из ссылки.
|
||||
iptv.MapGet("/epg.xml", Guide);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> Playlist(
|
||||
ICurrentUser currentUser,
|
||||
StreamTokenService tokens,
|
||||
HttpRequest request,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return Results.Unauthorized();
|
||||
|
||||
// Окно нулевое: плейлисту нужен только список каналов, программу плеер возьмёт из XMLTV.
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var channels = await sender.Send(new GetIptvGuideQuery(now, now), cancellationToken);
|
||||
|
||||
var (token, _) = tokens.IssueForIptv(userId);
|
||||
var origin = Origin(request);
|
||||
var body = RenderPlaylist(channels, origin, token);
|
||||
|
||||
return Results.File(
|
||||
Encoding.UTF8.GetBytes(body),
|
||||
"audio/x-mpegurl",
|
||||
fileDownloadName: "telewave.m3u"
|
||||
);
|
||||
}
|
||||
|
||||
private static async Task<IResult> Guide(
|
||||
HttpRequest request,
|
||||
StreamTokenService tokens,
|
||||
IIdentityService identity,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (tokens.Validate(request.Query["token"]) is not { } userId)
|
||||
return Results.Unauthorized();
|
||||
// Та же сверка, что и у плейлиста канала: заблокированный зритель не должен продолжать
|
||||
// тянуть гид по выданному раньше файлу.
|
||||
var profile = await identity.GetProfileAsync(userId, cancellationToken);
|
||||
if (profile is null || profile.IsBlocked)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var channels = await sender.Send(
|
||||
new GetIptvGuideQuery(now, now.Add(GuideWindow)),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return Results.File(
|
||||
RenderGuide(channels, Origin(request)),
|
||||
"application/xml; charset=utf-8",
|
||||
fileDownloadName: "telewave-epg.xml"
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Абсолютный адрес сервиса. Плееру нужны именно абсолютные ссылки — относительные ему не от
|
||||
/// чего считать. Схема и хост берутся из запроса: TLS внешний, и их подставляет
|
||||
/// <c>ForwardedHeaders</c>.
|
||||
/// </summary>
|
||||
private static string Origin(HttpRequest request) => $"{request.Scheme}://{request.Host}";
|
||||
|
||||
private static string RenderPlaylist(
|
||||
IReadOnlyList<IptvChannelDto> channels,
|
||||
string origin,
|
||||
string token
|
||||
)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"#EXTM3U x-tvg-url=\"{origin}/api/iptv/epg.xml?token={token}\"\n"
|
||||
);
|
||||
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
// tvg-logo ставим всегда: своя иконка либо фавикон-заглушка — иначе в плеере пустой квадрат.
|
||||
var logo = $" tvg-logo=\"{ChannelIcon(origin, channel.IconImageId)}\"";
|
||||
var number = channel.Number is { } value
|
||||
? $" tvg-chno=\"{value.ToString(CultureInfo.InvariantCulture)}\""
|
||||
: string.Empty;
|
||||
var name = Attribute(channel.Name);
|
||||
|
||||
sb.Append(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"#EXTINF:-1 tvg-id=\"{Attribute(channel.Slug)}\" tvg-name=\"{name}\"{number}{logo} group-title=\"TeleWave\",{name}\n"
|
||||
);
|
||||
sb.Append(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{origin}/api/channels/{channel.Slug}/live.m3u8?token={token}\n"
|
||||
);
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Значение атрибута M3U. Экранирования в формате нет вовсе, поэтому кавычки и переводы строк
|
||||
/// не заменяются, а вырезаются: иначе название канала рвало бы строку файла.
|
||||
/// </summary>
|
||||
private static string Attribute(string value) =>
|
||||
value.Replace("\"", string.Empty).ReplaceLineEndings(" ").Trim();
|
||||
|
||||
private static byte[] RenderGuide(IReadOnlyList<IptvChannelDto> channels, string origin)
|
||||
{
|
||||
using var output = new MemoryStream();
|
||||
// Пишем сразу в поток в UTF-8, а не в StringBuilder: XmlWriter поверх строки объявил бы
|
||||
// encoding="utf-16" (строки .NET — UTF-16), тогда как байты мы всё равно отдаём в UTF-8.
|
||||
// Часть плееров (Go encoding/xml) отвергает такое расхождение декларации и реальных байт.
|
||||
// BOM не пишем: XMLTV его не ждёт, и он мешает строгим парсерам.
|
||||
// Через XmlWriter, а не склейкой строк: в названия и описания приходит текст из библиотеки,
|
||||
// и экранировать его руками — верный способ однажды отдать битый XML.
|
||||
using (
|
||||
var writer = XmlWriter.Create(
|
||||
output,
|
||||
new XmlWriterSettings
|
||||
{
|
||||
Indent = true,
|
||||
Encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
|
||||
}
|
||||
)
|
||||
)
|
||||
{
|
||||
writer.WriteStartDocument();
|
||||
writer.WriteStartElement("tv");
|
||||
writer.WriteAttributeString("generator-info-name", "TeleWave");
|
||||
|
||||
// Сначала все каналы, потом все передачи: XMLTV требует именно такого порядка секций.
|
||||
foreach (var channel in channels)
|
||||
WriteChannel(writer, channel, origin);
|
||||
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
foreach (var programme in channel.Programmes)
|
||||
WriteProgramme(writer, channel.Slug, programme, origin);
|
||||
}
|
||||
|
||||
writer.WriteEndElement();
|
||||
writer.WriteEndDocument();
|
||||
}
|
||||
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
private static void WriteChannel(XmlWriter writer, IptvChannelDto channel, string origin)
|
||||
{
|
||||
writer.WriteStartElement("channel");
|
||||
writer.WriteAttributeString("id", channel.Slug);
|
||||
writer.WriteElementString("display-name", channel.Name);
|
||||
// Номер вторым display-name: так плееры с нумерацией подхватывают его без своих настроек.
|
||||
if (channel.Number is { } number)
|
||||
writer.WriteElementString(
|
||||
"display-name",
|
||||
number.ToString(CultureInfo.InvariantCulture)
|
||||
);
|
||||
// Иконку канала пишем всегда: своя либо фавикон-заглушка (в отличие от иконок передач ниже).
|
||||
writer.WriteStartElement("icon");
|
||||
writer.WriteAttributeString("src", ChannelIcon(origin, channel.IconImageId));
|
||||
writer.WriteEndElement();
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
|
||||
private static void WriteProgramme(
|
||||
XmlWriter writer,
|
||||
string slug,
|
||||
IptvProgrammeDto programme,
|
||||
string origin
|
||||
)
|
||||
{
|
||||
writer.WriteStartElement("programme");
|
||||
writer.WriteAttributeString("start", XmltvTime(programme.StartsAtUtc));
|
||||
writer.WriteAttributeString("stop", XmltvTime(programme.EndsAtUtc));
|
||||
writer.WriteAttributeString("channel", slug);
|
||||
|
||||
writer.WriteElementString("title", programme.Title);
|
||||
if (!string.IsNullOrWhiteSpace(programme.SubTitle))
|
||||
writer.WriteElementString("sub-title", programme.SubTitle);
|
||||
if (!string.IsNullOrWhiteSpace(programme.Description))
|
||||
writer.WriteElementString("desc", programme.Description);
|
||||
WriteIcon(writer, programme.ImageId, origin);
|
||||
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
|
||||
private static void WriteIcon(XmlWriter writer, Guid? imageId, string origin)
|
||||
{
|
||||
if (imageId is not { } id)
|
||||
return;
|
||||
|
||||
writer.WriteStartElement("icon");
|
||||
writer.WriteAttributeString("src", $"{origin}/api/images/{id}");
|
||||
writer.WriteEndElement();
|
||||
}
|
||||
|
||||
/// <summary>URL иконки канала: своя из реестра либо фавикон-заглушка сайта (абсолютный адрес).</summary>
|
||||
private static string ChannelIcon(string origin, Guid? iconImageId) =>
|
||||
iconImageId is { } id ? $"{origin}/api/images/{id}" : $"{origin}{FaviconPath}";
|
||||
|
||||
/// <summary>Время XMLTV: <c>YYYYMMDDHHMMSS +0000</c>. Отдаём в UTC — плеер сдвинет сам.</summary>
|
||||
private static string XmltvTime(DateTimeOffset moment) =>
|
||||
moment.ToUniversalTime().ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture)
|
||||
+ " +0000";
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Api.Common;
|
||||
using TeleWave.Application.Programming.Templates.Junctions;
|
||||
using TeleWave.Domain.Programming;
|
||||
using TeleWave.Infrastructure.Identity;
|
||||
|
||||
namespace TeleWave.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Шаблоны стыков: что играет между программами. Стыки общие для всех каналов, канал только
|
||||
/// ссылается на них слотами. Как и правка сетки, эфира не двигают — помечают шаблоны каналов,
|
||||
/// которые их используют, изменёнными, а хвост пересобирается применением.
|
||||
/// </summary>
|
||||
public static class JunctionEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapJunctionEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var admin = app.MapGroup("/api/admin/junctions")
|
||||
.WithTags("Admin.Junctions")
|
||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||
|
||||
admin.MapGet("", List).Produces<IReadOnlyList<JunctionTemplateDto>>();
|
||||
admin.MapPost("", Create).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||
admin.MapPut("/{junctionId:guid}", Update).Produces(StatusCodes.Status204NoContent);
|
||||
admin.MapDelete("/{junctionId:guid}", Delete).Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
admin
|
||||
.MapPost("/{junctionId:guid}/elements", AddElement)
|
||||
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||
admin
|
||||
.MapPut("/{junctionId:guid}/elements/{elementId:guid}", UpdateElement)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
admin
|
||||
.MapDelete("/{junctionId:guid}/elements/{elementId:guid}", RemoveElement)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
admin.MapPut("/{junctionId:guid}/order", Reorder).Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> List(ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await sender.Send(new ListJunctionsQuery(), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> Create(
|
||||
JunctionNameBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new CreateJunctionCommand(body.Name), cancellationToken);
|
||||
return result.IsSuccess
|
||||
? Results.Created(
|
||||
$"/api/admin/junctions/{result.Value}",
|
||||
new CreatedIdResponse(result.Value)
|
||||
)
|
||||
: result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> Update(
|
||||
Guid junctionId,
|
||||
UpdateJunctionBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new UpdateJunctionCommand(junctionId, body.Name, body.MaxTotalSeconds),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> Delete(
|
||||
Guid junctionId,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new DeleteJunctionCommand(junctionId), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> AddElement(
|
||||
Guid junctionId,
|
||||
JunctionElementKindBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new AddJunctionElementCommand(junctionId, body.Kind),
|
||||
cancellationToken
|
||||
);
|
||||
return result.IsSuccess
|
||||
? Results.Created(
|
||||
$"/api/admin/junctions/{junctionId}",
|
||||
new CreatedIdResponse(result.Value)
|
||||
)
|
||||
: result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateElement(
|
||||
Guid junctionId,
|
||||
Guid elementId,
|
||||
JunctionElementInput input,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new UpdateJunctionElementCommand(junctionId, elementId, input),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> RemoveElement(
|
||||
Guid junctionId,
|
||||
Guid elementId,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new RemoveJunctionElementCommand(junctionId, elementId),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> Reorder(
|
||||
Guid junctionId,
|
||||
ReorderJunctionBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new ReorderJunctionCommand(junctionId, body.Order),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record JunctionNameBody(string Name);
|
||||
|
||||
public sealed record UpdateJunctionBody(string Name, int? MaxTotalSeconds);
|
||||
|
||||
public sealed record JunctionElementKindBody(JunctionElementKind Kind);
|
||||
|
||||
/// <summary>Порядок врезок вместе с их развилками — перетаскивание меняет и то, и другое разом.</summary>
|
||||
public sealed record ReorderJunctionBody(IReadOnlyList<JunctionElementOrder> Order);
|
||||
@@ -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,306 @@
|
||||
using System.Text;
|
||||
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.ListMedia;
|
||||
using TeleWave.Application.Media.ManualInbox;
|
||||
using TeleWave.Application.Media.MovieImport;
|
||||
using TeleWave.Application.Media.Register;
|
||||
using TeleWave.Application.Media.Retry;
|
||||
using TeleWave.Application.Media.Stats;
|
||||
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("/stats", Stats).Produces<MediaStatsDto>();
|
||||
admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
// Перезапуск упавшей обработки: файл на месте, повторить нарезку — обычное дело.
|
||||
admin.MapPost("/{id:guid}/retry", Retry).Produces<RetryMediaResultDto>();
|
||||
admin.MapPost("/retry-failed", RetryFailed).Produces<RetryMediaResultDto>();
|
||||
|
||||
// Ручной inbox: сканером не разбирается — файлы выбирает админ и сразу указывает шоу.
|
||||
admin.MapGet("/manual", ListManual).Produces<ManualInboxListDto>();
|
||||
admin.MapPost("/manual/import", ImportManual).Produces<ImportManualInboxResultDto>();
|
||||
|
||||
// Разбор фильмов: имена приходят с клиента (каталог manual/ либо выбранные в браузере
|
||||
// файлы), сервер разбирает их и ищет в источнике. Импорт заводит шоу на файл.
|
||||
admin.MapPost("/movies/match", MatchMovies).Produces<IReadOnlyList<MovieMatchDto>>();
|
||||
admin.MapPost("/movies/match-one", MatchMovie).Produces<MovieMatchDto>();
|
||||
admin.MapPost("/movies/import", ImportMovies).Produces<ImportMoviesResultDto>();
|
||||
|
||||
// Просмотр обработанного ассета в админке (ролики, проверка серии). Публичная раздача идёт
|
||||
// по stream-куке, здесь роут под JWT — плейлист и сегменты грузит hls.js с Bearer.
|
||||
admin.MapGet("/{id:guid}/preview/index.m3u8", PreviewPlaylist);
|
||||
admin.MapGet("/{id:guid}/preview/{file}", PreviewSegment);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> Retry(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new RetryMediaCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> RetryFailed(
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new RetryMediaCommand(), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Потоковая загрузка: тело запроса — сырые байты файла, имя передаётся в query «fileName».
|
||||
/// Файл стримится на диск без буферизации в память, затем регистрируется и уходит в обработку.
|
||||
/// </summary>
|
||||
private static async Task<IResult> Upload(
|
||||
string fileName,
|
||||
HttpRequest request,
|
||||
IMediaStorage storage,
|
||||
IMediaProcessingQueue queue,
|
||||
ISender sender,
|
||||
UploadLimits limits,
|
||||
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 > limits.MaxUploadBytes)
|
||||
return Results.Problem(
|
||||
title: MediaErrors.FileTooLarge.Code,
|
||||
detail: MediaErrors.FileTooLarge.Message,
|
||||
statusCode: StatusCodes.Status400BadRequest
|
||||
);
|
||||
|
||||
var free = storage.GetAvailableFreeSpaceBytes();
|
||||
if (free - contentLength < limits.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);
|
||||
|
||||
// Файл уже на диске в uploads/, но в БД его ещё нет и ссылок на токен нигде не остаётся:
|
||||
// любой исход, кроме успешной регистрации, обязан за собой убрать. Удаляем и при исключении
|
||||
// (оборванный запрос, сбой БД) — иначе гигабайты остаются в uploads/ до уборщика.
|
||||
// Токен отмены для уборки не пробрасываем: при обрыве запроса он уже сработал.
|
||||
Result<Guid> result;
|
||||
try
|
||||
{
|
||||
result = await sender.Send(
|
||||
new RegisterMediaAssetCommand(token, MediaSource.Upload, fileName),
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await storage.DeleteUploadAsync(token, CancellationToken.None);
|
||||
throw;
|
||||
}
|
||||
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
await storage.DeleteUploadAsync(token, CancellationToken.None);
|
||||
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(
|
||||
[AsParameters] ListMediaFilter filter,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new ListMediaAssetsQuery(
|
||||
filter.Page is > 0 ? filter.Page.Value : 1,
|
||||
filter.PageSize is > 0 ? filter.PageSize.Value : 20,
|
||||
filter.Status ?? [],
|
||||
filter.Search,
|
||||
filter.Sort,
|
||||
filter.Desc ?? false
|
||||
),
|
||||
cancellationToken
|
||||
);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> Stats(ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await sender.Send(new GetMediaStatsQuery(), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> Delete(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new DeleteMediaAssetCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListManual(
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new ListManualInboxQuery(), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> MatchMovies(
|
||||
MatchMoviesBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new MatchMoviesQuery(body.Names, body.Provider),
|
||||
cancellationToken
|
||||
);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> MatchMovie(
|
||||
MatchMovieBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new MatchMovieQuery(body.Name, body.Title, body.Year, body.Provider),
|
||||
cancellationToken
|
||||
);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ImportMovies(
|
||||
ImportMoviesBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new ImportMoviesCommand(body.Items, body.Provider),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ImportManual(
|
||||
ImportManualInboxBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new ImportManualInboxCommand(body.Items, body.ShowId),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
/// <summary>Плейлист ассета: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
|
||||
private static IResult PreviewPlaylist(Guid id, MediaPathResolver paths)
|
||||
{
|
||||
if (SegmentFiles.TryResolveExisting(paths, id, "index.m3u8") is not { } indexPath)
|
||||
return Results.NotFound();
|
||||
|
||||
var baseUrl = $"/api/admin/media/{id}/preview/";
|
||||
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, string file, MediaPathResolver paths)
|
||||
{
|
||||
if (!SegmentFiles.IsSegmentName(file))
|
||||
return Results.NotFound();
|
||||
|
||||
if (SegmentFiles.TryResolveExisting(paths, id, file) is not { } path)
|
||||
return Results.NotFound();
|
||||
|
||||
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Фильтр и страница списка медиа. Все поля nullable намеренно: обязательный параметр в query
|
||||
/// заставлял минимальный API отвечать 400 на запросы без него — например, из выборок «все готовые
|
||||
/// ассеты», которым сортировка не нужна. Умолчания подставляет хендлер, а не объявление: при
|
||||
/// <c>AsParameters</c> обязательность определяется nullable-типом, а не значением по умолчанию.
|
||||
/// </summary>
|
||||
public sealed record ListMediaFilter(
|
||||
int? Page,
|
||||
int? PageSize,
|
||||
MediaAssetStatus[]? Status,
|
||||
string? Search,
|
||||
string? Sort,
|
||||
bool? Desc
|
||||
);
|
||||
|
||||
public sealed record UploadMediaResponse(Guid Id);
|
||||
|
||||
public sealed record ImportManualInboxBody(IReadOnlyList<ManualImportItem> Items, Guid ShowId);
|
||||
|
||||
public sealed record MatchMoviesBody(IReadOnlyList<string> Names, string Provider);
|
||||
|
||||
public sealed record MatchMovieBody(string Name, string Title, int? Year, string Provider);
|
||||
|
||||
public sealed record ImportMoviesBody(IReadOnlyList<MovieImportItem> Items, string Provider);
|
||||
@@ -0,0 +1,160 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Api.Common;
|
||||
using TeleWave.Application.Metadata;
|
||||
using TeleWave.Application.Metadata.ApplyShowMetadata;
|
||||
using TeleWave.Application.Metadata.ClearShowMetadata;
|
||||
using TeleWave.Application.Metadata.FindMissingEpisodes;
|
||||
using TeleWave.Application.Metadata.FindMissingSeasons;
|
||||
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.Library;
|
||||
using TeleWave.Infrastructure.Identity;
|
||||
|
||||
namespace TeleWave.Api.Endpoints;
|
||||
|
||||
public static class MetadataEndpoints
|
||||
{
|
||||
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-image", SetPosterImage)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
admin.MapPost("/shows/{showId:guid}/refresh-episodes", RefreshEpisodes).Produces<int>();
|
||||
admin
|
||||
.MapGet("/shows/{showId:guid}/missing-episodes", FindMissing)
|
||||
.Produces<MissingEpisodesReport>();
|
||||
admin
|
||||
.MapGet("/shows/{showId:guid}/missing-seasons", FindMissingSeasons)
|
||||
.Produces<MissingSeasonsReport>();
|
||||
|
||||
// Постеры шоу и кадры серий теперь в общем реестре и отдаются по /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,
|
||||
ShowKind kind,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new SearchShowMetadataQuery(provider, query, kind),
|
||||
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> 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();
|
||||
}
|
||||
|
||||
private static async Task<IResult> FindMissing(
|
||||
Guid showId,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new FindMissingEpisodesQuery(showId), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> FindMissingSeasons(
|
||||
Guid showId,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new FindMissingSeasonsQuery(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,55 @@
|
||||
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,
|
||||
body.PreferredAudioLanguages ?? "",
|
||||
body.ChannelNumbersEnabled
|
||||
),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record UpdateSiteSettingsBody(
|
||||
bool RegistrationEnabled,
|
||||
string? PreferredAudioLanguages,
|
||||
bool ChannelNumbersEnabled
|
||||
);
|
||||
@@ -0,0 +1,259 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Api.Common;
|
||||
using TeleWave.Application.Library;
|
||||
using TeleWave.Application.Library.AddEpisode;
|
||||
using TeleWave.Application.Library.BulkTagShows;
|
||||
using TeleWave.Application.Library.CreateShow;
|
||||
using TeleWave.Application.Library.DeleteShow;
|
||||
using TeleWave.Application.Library.GetShow;
|
||||
using TeleWave.Application.Library.GetShowUsage;
|
||||
using TeleWave.Application.Library.ListShows;
|
||||
using TeleWave.Application.Library.RemoveEpisode;
|
||||
using TeleWave.Application.Library.RenameShow;
|
||||
using TeleWave.Application.Library.SetShowAudience;
|
||||
using TeleWave.Application.Library.SetShowGenres;
|
||||
using TeleWave.Application.Library.SetShowOriginalName;
|
||||
using TeleWave.Application.Metadata.EnrichShows;
|
||||
using TeleWave.Domain.Library;
|
||||
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.MapPut("/{id:guid}/audience", SetAudience).Produces(StatusCodes.Status204NoContent);
|
||||
admin.MapPut("/{id:guid}/genres", SetGenres).Produces(StatusCodes.Status204NoContent);
|
||||
admin.MapGet("/{id:guid}/usage", GetUsage).Produces<ShowUsageDto>();
|
||||
admin.MapDelete("/{id:guid}", DeleteShow).Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
// Массовые операции над отмеченными шоу: разметка руками и обогащение из источника.
|
||||
admin.MapPost("/bulk/tag", BulkTag).Produces<BulkTaggedResponse>();
|
||||
admin.MapPost("/bulk/enrich", BulkEnrich).Produces<EnrichShowsResultDto>();
|
||||
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,
|
||||
Guid? genreId = null,
|
||||
bool interstitials = false
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new ListShowsQuery(genreId, interstitials),
|
||||
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> SetAudience(
|
||||
Guid id,
|
||||
SetShowAudienceBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new SetShowAudienceCommand(id, body.Audience),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> SetGenres(
|
||||
Guid id,
|
||||
SetShowGenresBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new SetShowGenresCommand(id, body.GenreIds, body.PrimaryGenreId),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
/// <param name="withMedia">Снести заодно файлы шоу — иначе они останутся осиротевшими.</param>
|
||||
/// <param name="withSchedule">Вырезать шоу из будущего эфира вместо отказа.</param>
|
||||
private static async Task<IResult> DeleteShow(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken,
|
||||
bool withMedia = false,
|
||||
bool withSchedule = false
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new DeleteShowCommand(id, withMedia, withSchedule),
|
||||
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();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetUsage(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new GetShowUsageQuery(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> BulkTag(
|
||||
BulkTagShowsBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new BulkTagShowsCommand(
|
||||
body.ShowIds,
|
||||
body.GenreIds,
|
||||
body.ReplaceGenres,
|
||||
body.Audience,
|
||||
body.SetAudience
|
||||
),
|
||||
cancellationToken
|
||||
);
|
||||
return result.IsSuccess
|
||||
? Results.Ok(new BulkTaggedResponse(result.Value))
|
||||
: result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> BulkEnrich(
|
||||
BulkEnrichBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new EnrichShowsMetadataCommand(body.ShowIds, body.Provider),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AddEpisodeBody(Guid MediaAssetId);
|
||||
|
||||
/// <summary>Массовая разметка: жанры и рейтинг применяются независимо друг от друга.</summary>
|
||||
public sealed record BulkTagShowsBody(
|
||||
IReadOnlyList<Guid> ShowIds,
|
||||
IReadOnlyList<Guid>? GenreIds = null,
|
||||
bool ReplaceGenres = false,
|
||||
ShowAudience? Audience = null,
|
||||
bool SetAudience = false
|
||||
);
|
||||
|
||||
public sealed record BulkEnrichBody(IReadOnlyList<Guid> ShowIds, string Provider);
|
||||
|
||||
/// <summary>Скольких шоу коснулась массовая правка.</summary>
|
||||
public sealed record BulkTaggedResponse(int Updated);
|
||||
|
||||
public sealed record RenameShowBody(string Name);
|
||||
|
||||
public sealed record SetShowOriginalNameBody(string? OriginalName);
|
||||
|
||||
/// <summary>Рейтинг шоу; null — снять проставленный.</summary>
|
||||
public sealed record SetShowAudienceBody(ShowAudience? Audience);
|
||||
|
||||
public sealed record SetShowGenresBody(IReadOnlyList<Guid> GenreIds, Guid? PrimaryGenreId);
|
||||
@@ -0,0 +1,33 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Storage;
|
||||
using TeleWave.Infrastructure.Identity;
|
||||
|
||||
namespace TeleWave.Api.Endpoints;
|
||||
|
||||
/// <summary>Чем занят диск: объём по областям хранилища и заполненность тома.</summary>
|
||||
public static class StorageEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapStorageEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/api/admin/storage", Stats)
|
||||
.WithTags("Admin.Storage")
|
||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin))
|
||||
.Produces<StorageStatsDto>();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <param name="refresh">Пересчитать, не дожидаясь истечения кэша: обход дерева долгий.</param>
|
||||
private static async Task<IResult> Stats(
|
||||
bool? refresh,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new GetStorageStatsQuery(refresh ?? false),
|
||||
cancellationToken
|
||||
);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
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";
|
||||
|
||||
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>>();
|
||||
// Что включено на стороне зрителя: сейчас только переключение по номерам (см. 6.8).
|
||||
channels.MapGet("/features", ViewerFeatures).Produces<ViewerFeaturesDto>();
|
||||
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 async Task<IResult> ViewerFeatures(
|
||||
ISiteSettings siteSettings,
|
||||
CancellationToken cancellationToken
|
||||
) =>
|
||||
Results.Ok(
|
||||
new ViewerFeaturesDto(
|
||||
await siteSettings.AreChannelNumbersEnabledAsync(cancellationToken)
|
||||
)
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Выдаёт cookie доступа к эфиру. Канал в маршруте есть для симметрии с остальными
|
||||
/// эндпоинтами, но токен не привязан к каналу — он подтверждает зрителя, а не подписку на
|
||||
/// конкретную ленту, поэтому в сигнатуре slug не нужен.
|
||||
/// </summary>
|
||||
private static IResult Watch(
|
||||
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; сегменты этой проверки не делают (слишком часто).
|
||||
// Для IPTV это единственная точка, где блокировка вообще может сработать: у внешнего плеера
|
||||
// пропуск долгий и перевыпуску не подлежит.
|
||||
if (Viewer(request, tokens) 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);
|
||||
|
||||
// Токен из ссылки тянется дальше в адреса сегментов: плеер разрешает их относительно
|
||||
// плейлиста и query при этом теряет, а cookie у него нет — без этого сегменты дадут 401.
|
||||
var token = request.Query["token"].ToString();
|
||||
var suffix = string.IsNullOrEmpty(token)
|
||||
? string.Empty
|
||||
: $"?token={Uri.EscapeDataString(token)}";
|
||||
|
||||
response.Headers.CacheControl = "no-cache";
|
||||
return Results.Text(Render(result.Value, suffix), "application/vnd.apple.mpegurl");
|
||||
}
|
||||
|
||||
private static IResult Segment(
|
||||
Guid assetId,
|
||||
string file,
|
||||
HttpRequest request,
|
||||
HttpResponse response,
|
||||
StreamTokenService tokens,
|
||||
MediaPathResolver paths
|
||||
)
|
||||
{
|
||||
if (Viewer(request, tokens) is null)
|
||||
return Results.Unauthorized();
|
||||
if (!SegmentFiles.IsSegmentName(file))
|
||||
return Results.NotFound();
|
||||
|
||||
if (SegmentFiles.TryResolveExisting(paths, assetId, file) is not { } path)
|
||||
return Results.NotFound();
|
||||
|
||||
response.Headers.CacheControl = "public, max-age=31536000, immutable";
|
||||
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Кто смотрит: cookie — для браузера, токен в ссылке — для внешнего IPTV-плеера, который
|
||||
/// cookie не носит. Токен один и тот же по формату, различается только сроком выдачи.
|
||||
/// </summary>
|
||||
private static Guid? Viewer(HttpRequest request, StreamTokenService tokens) =>
|
||||
tokens.Validate(request.Cookies[StreamCookieName])
|
||||
?? tokens.Validate(request.Query["token"]);
|
||||
|
||||
private static string Render(LivePlaylistDto playlist, string segmentSuffix)
|
||||
{
|
||||
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"
|
||||
);
|
||||
// Без DISCONTINUITY-SEQUENCE ffmpeg-плееры (Jellyfin) теряют счёт склеек, когда тег
|
||||
// EXT-X-DISCONTINUITY уезжает за верхний край окна, и встают на стыке серий.
|
||||
sb.Append(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"#EXT-X-DISCONTINUITY-SEQUENCE:{playlist.DiscontinuitySequence}\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{segmentSuffix}\n"
|
||||
);
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Опции зрительской части, включённые глобально.</summary>
|
||||
public sealed record ViewerFeaturesDto(bool ChannelNumbersEnabled);
|
||||
@@ -0,0 +1,175 @@
|
||||
using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Api.Common;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Notifications;
|
||||
using TeleWave.Application.Notifications.Link;
|
||||
using TeleWave.Application.Notifications.Settings;
|
||||
using TeleWave.Application.Notifications.Subscribers;
|
||||
using TeleWave.Domain.Notifications;
|
||||
using TeleWave.Infrastructure.Identity;
|
||||
|
||||
namespace TeleWave.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Бот расписания: настройка в админке, привязка чата зрителем и приём обновлений вебхуком.
|
||||
/// </summary>
|
||||
public static class TelegramEndpoints
|
||||
{
|
||||
/// <summary>Заголовок, которым Telegram подтверждает, что вызов вебхука — его.</summary>
|
||||
private const string SecretHeader = "X-Telegram-Bot-Api-Secret-Token";
|
||||
|
||||
public static IEndpointRouteBuilder MapTelegramEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var admin = app.MapGroup("/api/admin/telegram")
|
||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||
|
||||
admin.MapGet("/settings", GetSettings).Produces<TelegramSettingsDto>();
|
||||
admin.MapPut("/settings", UpdateSettings).Produces<TelegramSettingsDto>();
|
||||
admin
|
||||
.MapGet("/subscribers", ListSubscribers)
|
||||
.Produces<IReadOnlyList<TelegramSubscriberDto>>();
|
||||
|
||||
// Настроен ли бот — публично: по этому флагу зрительская страница решает, показывать ли
|
||||
// кнопку. Ничего, кроме флага и имени бота, здесь не отдаётся.
|
||||
app.MapGet("/api/telegram/status", Status).AllowAnonymous().Produces<TelegramStatusDto>();
|
||||
|
||||
// Ссылка на чат — зрителю, а не админу: код привязывает его собственную учётку.
|
||||
app.MapPost("/api/telegram/link", IssueLink)
|
||||
.RequireAuthorization()
|
||||
.Produces<TelegramLinkDto>();
|
||||
|
||||
// Вебхук открыт наружу по построению: его вызывает Telegram, а не наш фронт. Пускаем
|
||||
// только по секрету из настроек — адрес рано или поздно окажется в чужих логах.
|
||||
app.MapPost("/api/telegram/webhook", Webhook).AllowAnonymous();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> Status(
|
||||
IAppDbContext dbContext,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var settings = await dbContext
|
||||
.TelegramSettings.AsNoTracking()
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
var link = settings is null ? null : TelegramSettingsService.BotLink(settings);
|
||||
return Results.Ok(new TelegramStatusDto(link is not null, settings?.BotUsername));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetSettings(
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new GetTelegramSettingsQuery(), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateSettings(
|
||||
TelegramSettingsBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new UpdateTelegramSettingsCommand(
|
||||
body.IsEnabled,
|
||||
body.Transport,
|
||||
body.BotToken,
|
||||
body.WebhookUrl,
|
||||
body.ProxyKind,
|
||||
body.ProxyHost,
|
||||
body.ProxyPort,
|
||||
body.ProxyUsername,
|
||||
body.ProxyPassword
|
||||
),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListSubscribers(
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new ListTelegramSubscribersQuery(), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> IssueLink(
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new IssueTelegramLinkCommand(), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Приём обновления от Telegram. Разбираем тем же сервисом, что и опрос: транспорт разный,
|
||||
/// разговор один.
|
||||
///
|
||||
/// Отвечаем 200 всегда, когда секрет сошёлся: на ошибку Telegram повторяет доставку, и одно
|
||||
/// кривое обновление зациклило бы вебхук.
|
||||
/// </summary>
|
||||
private static async Task<IResult> Webhook(
|
||||
HttpRequest request,
|
||||
IAppDbContext dbContext,
|
||||
TelegramBotService bot,
|
||||
ITelegramApi api,
|
||||
ILoggerFactory loggers,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var settings = await dbContext.TelegramSettings.FirstOrDefaultAsync(cancellationToken);
|
||||
if (
|
||||
settings is null
|
||||
|| !settings.IsOperational
|
||||
|| settings.Transport != TelegramTransport.Webhook
|
||||
)
|
||||
return Results.NotFound();
|
||||
|
||||
var secret = request.Headers[SecretHeader].ToString();
|
||||
if (string.IsNullOrWhiteSpace(settings.WebhookSecret) || secret != settings.WebhookSecret)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var update = await api.ReadUpdateAsync(request.Body, cancellationToken);
|
||||
if (update is null)
|
||||
return Results.Ok();
|
||||
|
||||
var logger = loggers.CreateLogger("Telegram.Webhook");
|
||||
try
|
||||
{
|
||||
await bot.HandleAsync(settings, update, DateTimeOffset.UtcNow, cancellationToken);
|
||||
settings.MarkContact(DateTimeOffset.UtcNow);
|
||||
settings.AdvanceUpdateId(update.UpdateId);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
logger.LogError(exception, "Не разобрано обновление {UpdateId}", update.UpdateId);
|
||||
}
|
||||
|
||||
return Results.Ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Что зрительской части нужно знать о боте: настроен ли и как он называется.</summary>
|
||||
public sealed record TelegramStatusDto(bool IsConfigured, string? BotUsername);
|
||||
|
||||
/// <summary>Тело правки настроек: секреты необязательны — пусто значит «не менять».</summary>
|
||||
public sealed record TelegramSettingsBody(
|
||||
bool IsEnabled,
|
||||
TelegramTransport Transport,
|
||||
string? BotToken,
|
||||
string? WebhookUrl,
|
||||
TelegramProxyKind ProxyKind,
|
||||
string? ProxyHost,
|
||||
int? ProxyPort,
|
||||
string? ProxyUsername,
|
||||
string? ProxyPassword
|
||||
);
|
||||
@@ -0,0 +1,434 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Api.Common;
|
||||
using TeleWave.Application.Programming.Planning.ApplyTemplate;
|
||||
using TeleWave.Application.Programming.Planning.Diff;
|
||||
using TeleWave.Application.Programming.Planning.Preview;
|
||||
using TeleWave.Application.Programming.Templates;
|
||||
using TeleWave.Application.Programming.Templates.CopyTemplate;
|
||||
using TeleWave.Application.Programming.Templates.CreateSlot;
|
||||
using TeleWave.Application.Programming.Templates.CreateTemplate;
|
||||
using TeleWave.Application.Programming.Templates.DeleteSlot;
|
||||
using TeleWave.Application.Programming.Templates.Generate;
|
||||
using TeleWave.Application.Programming.Templates.GetTemplate;
|
||||
using TeleWave.Application.Programming.Templates.Layers;
|
||||
using TeleWave.Application.Programming.Templates.Reset;
|
||||
using TeleWave.Application.Programming.Templates.Restore;
|
||||
using TeleWave.Application.Programming.Templates.Transfer;
|
||||
using TeleWave.Application.Programming.Templates.UpdateSlot;
|
||||
using TeleWave.Application.Programming.Templates.Validate;
|
||||
using TeleWave.Infrastructure.Identity;
|
||||
|
||||
namespace TeleWave.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Сетка канала: шаблон, слои, слоты. Правка ничего не двигает в эфире — она помечает шаблон
|
||||
/// изменённым, а хвост пересобирается отдельной командой применения.
|
||||
/// </summary>
|
||||
public static class TemplateEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapTemplateEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var admin = app.MapGroup("/api/admin")
|
||||
.WithTags("Admin.Templates")
|
||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||
|
||||
admin
|
||||
.MapGet("/channels/{channelId:guid}/template", GetTemplate)
|
||||
.Produces<ScheduleTemplateDto>();
|
||||
// Завести сетку каналу, у которого её нет (напр. пережившему снос старой ротации).
|
||||
admin
|
||||
.MapPost("/channels/{channelId:guid}/template", CreateTemplate)
|
||||
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||
admin
|
||||
.MapPut("/templates/{templateId:guid}", UpdateTemplate)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
// Применение правил к эфиру — отдельным действием: правка слотов эфир не двигает.
|
||||
admin
|
||||
.MapPost("/channels/{channelId:guid}/template/apply", ApplyTemplate)
|
||||
.Produces<ApplyResultDto>();
|
||||
// Откат правок к состоянию, по которому идёт эфир. Ленту не трогает — она и так по нему.
|
||||
admin
|
||||
.MapPost("/channels/{channelId:guid}/template/restore", RestoreTemplate)
|
||||
.Produces<RestoreTemplateResultDto>();
|
||||
// Сброс сетки вместе с собранным по ней будущим эфиром. Прошлое и идущая запись остаются.
|
||||
admin
|
||||
.MapPost("/channels/{channelId:guid}/template/reset", ResetTemplate)
|
||||
.Produces<ResetTemplateResultDto>();
|
||||
// Предпросмотр — тот же генератор, но без записи и без продвижения курсоров.
|
||||
admin
|
||||
.MapGet("/channels/{channelId:guid}/template/preview", PreviewTemplate)
|
||||
.Produces<SchedulePreviewDto>();
|
||||
// Проверки по правилам — только по шаблону, без прогона генератора.
|
||||
admin
|
||||
.MapGet("/channels/{channelId:guid}/template/issues", ValidateTemplate)
|
||||
.Produces<IReadOnlyList<TemplateIssueDto>>();
|
||||
// Что изменится в эфире, если применить прямо сейчас.
|
||||
admin
|
||||
.MapGet("/channels/{channelId:guid}/template/diff", DiffTemplate)
|
||||
.Produces<ScheduleDiffDto>();
|
||||
admin
|
||||
.MapPost(
|
||||
"/channels/{channelId:guid}/template/copy-to/{targetChannelId:guid}",
|
||||
CopyTemplate
|
||||
)
|
||||
.Produces<CopyTemplateResultDto>();
|
||||
|
||||
// Автосборка сетки: каталог профилей, план по выбранному и его создание. План считается
|
||||
// одним и тем же кодом, поэтому предпросмотр показывает ровно то, что создаст кнопка.
|
||||
admin
|
||||
.MapGet("/grid-profiles", ListGridProfiles)
|
||||
.Produces<IReadOnlyList<GridProfileDto>>();
|
||||
admin
|
||||
.MapGet("/channels/{channelId:guid}/template/grid-plan", PreviewGrid)
|
||||
.Produces<GridPlanDto>();
|
||||
admin
|
||||
.MapPost("/channels/{channelId:guid}/template/generate", GenerateGrid)
|
||||
.Produces<GenerateGridResultDto>();
|
||||
|
||||
// Обмен конфигурацией: выгрузка файлом, загрузка чужого файла и сборка запроса к ИИ.
|
||||
// Запрос никуда не отправляется — админ копирует его в свою модель и приносит ответ назад.
|
||||
admin
|
||||
.MapGet("/channels/{channelId:guid}/template/export", ExportGrid)
|
||||
.Produces<GridConfig>();
|
||||
admin
|
||||
.MapPost("/channels/{channelId:guid}/template/import", ImportGrid)
|
||||
.Produces<GridImportResultDto>();
|
||||
admin
|
||||
.MapPost("/channels/{channelId:guid}/template/ai-prompt", BuildPrompt)
|
||||
.Produces<GridPromptDto>();
|
||||
|
||||
admin
|
||||
.MapPost("/templates/{templateId:guid}/layers", CreateLayer)
|
||||
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||
admin
|
||||
.MapPut("/layers/{layerId:guid}", UpdateLayer)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
admin
|
||||
.MapDelete("/layers/{layerId:guid}", DeleteLayer)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
admin
|
||||
.MapPost("/layers/{layerId:guid}/slots", CreateSlot)
|
||||
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||
admin.MapPut("/slots/{slotId:guid}", UpdateSlot).Produces(StatusCodes.Status204NoContent);
|
||||
admin
|
||||
.MapDelete("/slots/{slotId:guid}", DeleteSlot)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetTemplate(
|
||||
Guid channelId,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new GetChannelTemplateQuery(channelId), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateTemplate(
|
||||
Guid channelId,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new CreateChannelTemplateCommand(channelId),
|
||||
cancellationToken
|
||||
);
|
||||
return result.IsSuccess
|
||||
? Results.Created(
|
||||
$"/api/admin/channels/{channelId}/template",
|
||||
new CreatedIdResponse(result.Value)
|
||||
)
|
||||
: result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ApplyTemplate(
|
||||
Guid channelId,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new ApplyChannelTemplateCommand(channelId),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> RestoreTemplate(
|
||||
Guid channelId,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new RestoreTemplateCommand(channelId), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ResetTemplate(
|
||||
Guid channelId,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new ResetTemplateCommand(channelId), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> PreviewTemplate(
|
||||
Guid channelId,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken,
|
||||
int days = 1
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new PreviewScheduleQuery(channelId, days),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ValidateTemplate(
|
||||
Guid channelId,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new ValidateTemplateQuery(channelId), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> DiffTemplate(
|
||||
Guid channelId,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new PreviewApplyDiffQuery(channelId), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListGridProfiles(
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new ListGridProfilesQuery(), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> PreviewGrid(
|
||||
Guid channelId,
|
||||
GridProfileKind profile,
|
||||
GridGenerationMode mode,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new PreviewGeneratedGridQuery(channelId, profile, mode),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GenerateGrid(
|
||||
Guid channelId,
|
||||
GenerateGridBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new GenerateGridCommand(channelId, body.Profile, body.Mode),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ExportGrid(
|
||||
Guid channelId,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new ExportGridQuery(channelId), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ImportGrid(
|
||||
Guid channelId,
|
||||
ImportGridBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new ImportGridCommand(channelId, body.Config, body.Replace),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> BuildPrompt(
|
||||
Guid channelId,
|
||||
GridPromptBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new BuildGridPromptQuery(channelId, body.References ?? [], body.Notes),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> CopyTemplate(
|
||||
Guid channelId,
|
||||
Guid targetChannelId,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new CopyTemplateCommand(channelId, targetChannelId),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateTemplate(
|
||||
Guid templateId,
|
||||
UpdateTemplateBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new UpdateTemplateCommand(
|
||||
templateId,
|
||||
body.Name,
|
||||
body.FallbackGroupId,
|
||||
body.DefaultJunctionId,
|
||||
body.Rules
|
||||
),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateLayer(
|
||||
Guid templateId,
|
||||
CreateLayerBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new CreateLayerCommand(templateId, body.Name, body.Priority),
|
||||
cancellationToken
|
||||
);
|
||||
return result.IsSuccess
|
||||
? Results.Created(
|
||||
$"/api/admin/layers/{result.Value}",
|
||||
new CreatedIdResponse(result.Value)
|
||||
)
|
||||
: result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateLayer(
|
||||
Guid layerId,
|
||||
UpdateLayerBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new UpdateLayerCommand(
|
||||
layerId,
|
||||
body.Name,
|
||||
body.Priority,
|
||||
body.Applicability,
|
||||
body.IsEnabled
|
||||
),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteLayer(
|
||||
Guid layerId,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new DeleteLayerCommand(layerId), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateSlot(
|
||||
Guid layerId,
|
||||
SlotInput input,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new CreateSlotCommand(layerId, input), cancellationToken);
|
||||
return result.IsSuccess
|
||||
? Results.Created(
|
||||
$"/api/admin/slots/{result.Value}",
|
||||
new CreatedIdResponse(result.Value)
|
||||
)
|
||||
: result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateSlot(
|
||||
Guid slotId,
|
||||
SlotInput input,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new UpdateSlotCommand(slotId, input), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteSlot(
|
||||
Guid slotId,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new DeleteSlotCommand(slotId), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record UpdateTemplateBody(
|
||||
string Name,
|
||||
Guid? FallbackGroupId,
|
||||
Guid? DefaultJunctionId,
|
||||
PlanningRules? Rules
|
||||
);
|
||||
|
||||
public sealed record GenerateGridBody(GridProfileKind Profile, GridGenerationMode Mode);
|
||||
|
||||
public sealed record ImportGridBody(GridConfig Config, bool Replace);
|
||||
|
||||
public sealed record GridPromptBody(IReadOnlyList<string>? References, string? Notes);
|
||||
|
||||
public sealed record CreateLayerBody(string Name, int Priority);
|
||||
|
||||
public sealed record UpdateLayerBody(
|
||||
string Name,
|
||||
int Priority,
|
||||
LayerApplicability? Applicability,
|
||||
bool IsEnabled
|
||||
);
|
||||
@@ -0,0 +1,162 @@
|
||||
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 ReadList(builder.Configuration, "ForwardedHeaders:KnownProxies"))
|
||||
options.KnownProxies.Add(IPAddress.Parse(proxy));
|
||||
|
||||
foreach (var network in ReadList(builder.Configuration, "ForwardedHeaders:KnownNetworks"))
|
||||
{
|
||||
var parts = network.Split('/');
|
||||
options.KnownIPNetworks.Add(
|
||||
new System.Net.IPNetwork(IPAddress.Parse(parts[0]), int.Parse(parts[1]))
|
||||
);
|
||||
}
|
||||
|
||||
// Список доверенных прокси/сетей приходит из .env одной строкой (env-переменная по одному ключу
|
||||
// в string[] не биндится) — принимаем и запятые, и массив appsettings/индексы __0. Иначе
|
||||
// задокументированная в .env.example запись через запятую молча игнорировалась бы, оставляя
|
||||
// доверие только loopback и обрывая подхват X-Forwarded-Proto.
|
||||
static string[] ReadList(IConfiguration config, string key)
|
||||
{
|
||||
var array = config.GetSection(key).Get<string[]>();
|
||||
if (array is { Length: > 0 })
|
||||
return array;
|
||||
|
||||
var scalar = config[key];
|
||||
return string.IsNullOrWhiteSpace(scalar)
|
||||
? []
|
||||
: scalar.Split(
|
||||
',',
|
||||
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddSingleton<UploadLimits>();
|
||||
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.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.MapGenreEndpoints();
|
||||
app.MapInterstitialEndpoints();
|
||||
app.MapCollectionEndpoints();
|
||||
app.MapGroupEndpoints();
|
||||
app.MapTemplateEndpoints();
|
||||
app.MapBumperEndpoints();
|
||||
app.MapJunctionEndpoints();
|
||||
app.MapChannelEndpoints();
|
||||
app.MapTelegramEndpoints();
|
||||
app.MapStreamingEndpoints();
|
||||
app.MapIptvEndpoints();
|
||||
app.MapMaintenanceEndpoints();
|
||||
app.MapStorageEndpoints();
|
||||
app.MapSettingsEndpoints();
|
||||
app.MapMetadataEndpoints();
|
||||
app.MapImageEndpoints();
|
||||
app.MapDiagnosticsEndpoints();
|
||||
|
||||
// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов.
|
||||
app.UseDefaultFiles();
|
||||
app.UseStaticFiles();
|
||||
app.MapFallbackToFile("index.html");
|
||||
|
||||
// Раньше здесь объявлялся публичный partial-класс Program, чтобы WebApplicationTestFactory видела
|
||||
// сгенерированный класс. В ASP.NET Core 10 он и так публичный (ASP0027), объявление стало лишним.
|
||||
await app.RunAsync();
|
||||
@@ -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,29 @@
|
||||
{
|
||||
// Локальная БД разработчика. В appsettings.json строки подключения нет намеренно: этот файл
|
||||
// едет в образ, и креды в нём (даже заведомо игрушечные) — это и находка сканера, и приглашение
|
||||
// однажды поправить их «на месте» вместо ConnectionStrings__Default из окружения.
|
||||
"ConnectionStrings": {
|
||||
"Default": "Host=localhost;Port=5432;Database=telewave;Username=telewave;Password=telewave"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
// Отладочные дампы каналов ложатся в папку репозитория: разбирать «почему сетка построилась
|
||||
// так» приходится рядом с кодом. Путь относительный — от рабочего каталога Api (backend/src/TeleWave.Api).
|
||||
"Debug": {
|
||||
"ExportDirectory": "../../../debug-exports"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"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": 7,
|
||||
"RetentionDays": 90,
|
||||
"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 lang="en">
|
||||
<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>;
|
||||
+14
@@ -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,15 @@
|
||||
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,
|
||||
string? Sort = null,
|
||||
bool Desc = false
|
||||
) : IQuery<PagedList<UserSummaryDto>>;
|
||||
@@ -0,0 +1,26 @@
|
||||
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(
|
||||
new UserListFilter(
|
||||
query.Page,
|
||||
query.PageSize,
|
||||
query.Search,
|
||||
query.RoleId,
|
||||
query.IsBlocked,
|
||||
query.Sort,
|
||||
query.Desc
|
||||
),
|
||||
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>;
|
||||
+14
@@ -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);
|
||||
}
|
||||
+13
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
+12
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
+11
@@ -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>;
|
||||
+22
@@ -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,27 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
public sealed class AddBumperVariantCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<AddBumperVariantCommand, Result<Guid>>
|
||||
{
|
||||
public async Task<Result<Guid>> Handle(
|
||||
AddBumperVariantCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var template = await BumperTemplateLoader.LoadAsync(
|
||||
dbContext,
|
||||
command.TemplateId,
|
||||
cancellationToken
|
||||
);
|
||||
if (template is null)
|
||||
return Result.Failure<Guid>(BumperErrors.TemplateNotFound);
|
||||
|
||||
var variant = template.AddVariant(command.Name);
|
||||
variant.SetLines(BumperTemplateLoader.DefaultLines());
|
||||
return Result.Success(variant.Id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using FluentValidation;
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
public sealed record ListBumperTemplatesQuery : IQuery<IReadOnlyList<BumperTemplateDto>>;
|
||||
|
||||
public sealed record CreateBumperTemplateCommand(string Name) : ICommand<Result<Guid>>;
|
||||
|
||||
/// <summary>Оформление блока: имя, шрифт и палитра. Меняет ревизию — заставки пересобираются.</summary>
|
||||
public sealed record UpdateBumperTemplateCommand(Guid TemplateId, BumperStyle Style)
|
||||
: ICommand<Result>;
|
||||
|
||||
public sealed record DeleteBumperTemplateCommand(Guid TemplateId) : ICommand<Result>;
|
||||
|
||||
public sealed record SetBumperTemplateAudioCommand(
|
||||
Guid TemplateId,
|
||||
string Extension,
|
||||
double DurationSeconds
|
||||
) : ICommand<Result>;
|
||||
|
||||
public sealed record ClearBumperTemplateAudioCommand(Guid TemplateId) : ICommand<Result>;
|
||||
|
||||
public sealed record SetBumperTemplateBackgroundCommand(Guid TemplateId, Guid ImageId)
|
||||
: ICommand<Result>;
|
||||
|
||||
public sealed record ClearBumperTemplateBackgroundCommand(Guid TemplateId) : ICommand<Result>;
|
||||
|
||||
public sealed record AddBumperVariantCommand(Guid TemplateId, string Name) : ICommand<Result<Guid>>;
|
||||
|
||||
/// <summary>Полное содержимое подблока — строки редактор всегда присылает списком целиком.</summary>
|
||||
public sealed record BumperVariantInput(
|
||||
string Name,
|
||||
BumperTrigger Trigger,
|
||||
BumperBackground Background,
|
||||
int Weight,
|
||||
IReadOnlyList<BumperLineDto> Lines,
|
||||
/// <summary>Ролик-подложка для фона <see cref="BumperBackground.Clip"/>.</summary>
|
||||
Guid? BackgroundClipShowId = null
|
||||
);
|
||||
|
||||
public sealed record UpdateBumperVariantCommand(
|
||||
Guid TemplateId,
|
||||
Guid VariantId,
|
||||
BumperVariantInput Input
|
||||
) : ICommand<Result>;
|
||||
|
||||
public sealed record RemoveBumperVariantCommand(Guid TemplateId, Guid VariantId) : ICommand<Result>;
|
||||
|
||||
/// <summary>
|
||||
/// Рендер примера заставки. Канал нужен только для образцов подстановки: блок общий, но посмотреть
|
||||
/// его надо глазами конкретного канала — иначе <c>{channel}</c> не на что заменить.
|
||||
/// </summary>
|
||||
public sealed record RenderBumperPreviewCommand(Guid TemplateId, Guid? ChannelId)
|
||||
: ICommand<Result>;
|
||||
|
||||
public sealed class CreateBumperTemplateCommandValidator
|
||||
: AbstractValidator<CreateBumperTemplateCommand>
|
||||
{
|
||||
public CreateBumperTemplateCommandValidator() =>
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(64);
|
||||
}
|
||||
|
||||
public sealed class UpdateBumperTemplateCommandValidator
|
||||
: AbstractValidator<UpdateBumperTemplateCommand>
|
||||
{
|
||||
public UpdateBumperTemplateCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Style.Name).NotEmpty().MaximumLength(64);
|
||||
RuleFor(x => x.Style.BackgroundColor).NotEmpty().MaximumLength(32);
|
||||
RuleFor(x => x.Style.BackgroundColor2).NotEmpty().MaximumLength(32);
|
||||
RuleFor(x => x.Style.AccentColor).NotEmpty().MaximumLength(32);
|
||||
RuleFor(x => x.Style.TextColor).NotEmpty().MaximumLength(32);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AddBumperVariantCommandValidator : AbstractValidator<AddBumperVariantCommand>
|
||||
{
|
||||
public AddBumperVariantCommandValidator() => RuleFor(x => x.Name).NotEmpty().MaximumLength(64);
|
||||
}
|
||||
|
||||
public sealed class UpdateBumperVariantCommandValidator
|
||||
: AbstractValidator<UpdateBumperVariantCommand>
|
||||
{
|
||||
/// <summary>Больше шести строк в кадр не помещается ни при каком размере шрифта.</summary>
|
||||
private const int MaxLines = 6;
|
||||
|
||||
public UpdateBumperVariantCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Input.Name).NotEmpty().MaximumLength(64);
|
||||
RuleFor(x => x.Input.Weight).InclusiveBetween(0, 1000);
|
||||
RuleFor(x => x.Input.Lines).NotNull().Must(l => l.Count <= MaxLines);
|
||||
RuleForEach(x => x.Input.Lines)
|
||||
.ChildRules(line => line.RuleFor(l => l.Text).NotEmpty().MaximumLength(120));
|
||||
|
||||
// Фон из ролика без самого ролика — это не «пока не выбрал», а заставка, которая молча
|
||||
// отрендерится градиентом. Пусть выбор будет обязательным сразу.
|
||||
RuleFor(x => x.Input.BackgroundClipShowId)
|
||||
.NotNull()
|
||||
.When(x => x.Input.Background == BumperBackground.Clip);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using TeleWave.Domain.Broadcast;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
/// <summary>Строка заставки: роль, цвет из палитры блока и текст с плейсхолдерами.</summary>
|
||||
public sealed record BumperLineDto(BumperLineStyle Style, BumperLineColor Color, string Text);
|
||||
|
||||
/// <summary>Подблок (текст-вариант): свои строки, фон и правило показа поверх оформления блока.</summary>
|
||||
public sealed record BumperTextVariantDto(
|
||||
Guid Id,
|
||||
int Position,
|
||||
string Name,
|
||||
BumperTrigger Trigger,
|
||||
BumperBackground Background,
|
||||
int Weight,
|
||||
IReadOnlyList<BumperLineDto> Lines,
|
||||
/// <summary>Ролик-подложка для фона <see cref="BumperBackground.Clip"/>.</summary>
|
||||
Guid? BackgroundClipShowId
|
||||
);
|
||||
|
||||
/// <summary>Блок заставки: оформление + звук + подблоки. <see cref="AudioDurationSeconds"/> — длина звука (сек).</summary>
|
||||
public sealed record BumperTemplateDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
BumperFont Font,
|
||||
string BackgroundColor,
|
||||
string BackgroundColor2,
|
||||
string AccentColor,
|
||||
string TextColor,
|
||||
Guid? BackgroundImageId,
|
||||
bool HasAudio,
|
||||
double? AudioDurationSeconds,
|
||||
/// <summary>Во скольких врезках стыков используется блок — блоки общие, и это надо видеть до правки.</summary>
|
||||
int UsageCount,
|
||||
IReadOnlyList<BumperTextVariantDto> Variants
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user