Compare commits
1
Commits
058cbc6994
..
badges
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19e7a97f42 |
@@ -1,88 +0,0 @@
|
|||||||
# 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
|
|
||||||
|
|
||||||
# ── Планировщик эфира ──────────────────────────────────────────────────────
|
|
||||||
# На сколько дней вперёд держать расписание; сколько часов прошлого хранить; период тика.
|
|
||||||
Scheduler__HorizonDays=3
|
|
||||||
Scheduler__RetentionHours=24
|
|
||||||
Scheduler__TickMinutes=30
|
|
||||||
|
|
||||||
# ── Обработка медиа (ffmpeg) ───────────────────────────────────────────────
|
|
||||||
Media__FfmpegPath=/usr/bin/ffmpeg
|
|
||||||
Media__FfprobePath=/usr/bin/ffprobe
|
|
||||||
# Максимальный размер загружаемого файла (20 ГБ).
|
|
||||||
Media__MaxUploadBytes=21474836480
|
|
||||||
# Потоки одного ffmpeg (0 — авто/все ядра).
|
|
||||||
Media__TranscodeThreads=3
|
|
||||||
# Сколько файлов транскодировать одновременно (1 — по умолчанию, как раньше).
|
|
||||||
# Тюнинг под ядра: обычно TranscodeThreads × MaxParallelTranscodes ≈ число ядер.
|
|
||||||
# 4 vCPU: Threads=3, Parallel=1 (или Threads=2, Parallel=2)
|
|
||||||
# 8 vCPU: Threads=4, Parallel=2 ← лучший throughput на пачках серий
|
|
||||||
# (или Threads=6, Parallel=1 — если важнее скорость одного файла)
|
|
||||||
# Память: один 1080p-транскод ~0.5 ГБ; Parallel=2 требует ~1 ГБ + база API.
|
|
||||||
Media__MaxParallelTranscodes=1
|
|
||||||
# Период опроса каталога inbox/ сканером, секунды.
|
|
||||||
Media__InboxScanSeconds=15
|
|
||||||
# Нормализация громкости при обработке (EBU R128 loudnorm) — все ролики одинаковой громкости.
|
|
||||||
Media__NormalizeLoudness=true
|
|
||||||
# Целевая громкость, LUFS (−16 типично для стриминга; тише — уменьшить).
|
|
||||||
Media__LoudnessTargetLufs=-16
|
|
||||||
|
|
||||||
# ── ТВ-заставки «Сейчас/Далее» (bumpers) ───────────────────────────────────
|
|
||||||
# Общие для всех каналов параметры рендера. Оформление и правила (цвета, подписи, длительность,
|
|
||||||
# интервал, только-между-разными) настраиваются на каждом канале в админке.
|
|
||||||
# Bumpers__Width=1280
|
|
||||||
# Bumpers__Height=720
|
|
||||||
# Bumpers__FontFileSans=/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf
|
|
||||||
# Bumpers__FontFileSerif=/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf
|
|
||||||
# Меняй при правке ЛОГИКИ рендера, чтобы пересобрать уже отрендеренные заставки.
|
|
||||||
# Bumpers__TemplateVersion=1
|
|
||||||
|
|
||||||
# ── Метаданные шоу/серий (TMDb / OMDb) ─────────────────────────────────────
|
|
||||||
# Ключи бесплатные: TMDb — themoviedb.org (Settings → API, v3 key), OMDb — omdbapi.com.
|
|
||||||
# Без ключа источник просто не показывается в админке (ручной режим работает всегда).
|
|
||||||
Metadata__Language=ru-RU
|
|
||||||
Metadata__Tmdb__ApiKey=
|
|
||||||
Metadata__Omdb__ApiKey=
|
|
||||||
|
|
||||||
# ── ASP.NET Core ──────────────────────────────────────────────────────────
|
|
||||||
ASPNETCORE_ENVIRONMENT=Production
|
|
||||||
ASPNETCORE_HTTP_PORTS=8080
|
|
||||||
|
|
||||||
# ── Доверенные прокси (X-Forwarded-For/Proto) ──────────────────────────────
|
|
||||||
# TLS терминируется вне compose внешним прокси/шлюзом. По умолчанию доверяется только loopback.
|
|
||||||
# Если прокси стоит не на loopback, перечисли его через запятую.
|
|
||||||
# ForwardedHeaders__KnownProxies=203.0.113.10
|
|
||||||
# ForwardedHeaders__KnownNetworks=172.18.0.0/16
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
name: build
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
backend:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-dotnet@v4
|
|
||||||
with:
|
|
||||||
dotnet-version: 10.0.x
|
|
||||||
- name: Restore
|
|
||||||
working-directory: backend
|
|
||||||
run: dotnet restore TeleWave.slnx
|
|
||||||
- name: Build (Release)
|
|
||||||
working-directory: backend
|
|
||||||
run: dotnet build TeleWave.slnx -c Release --no-restore
|
|
||||||
|
|
||||||
frontend:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
- name: Enable pnpm
|
|
||||||
run: corepack enable
|
|
||||||
- name: Install
|
|
||||||
working-directory: frontend
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
- name: Lint
|
|
||||||
working-directory: frontend
|
|
||||||
run: pnpm lint
|
|
||||||
- name: Typecheck
|
|
||||||
working-directory: frontend
|
|
||||||
run: pnpm typecheck
|
|
||||||
- name: Build
|
|
||||||
working-directory: frontend
|
|
||||||
run: pnpm build
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
name: tests
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
backend-tests:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-dotnet@v4
|
|
||||||
with:
|
|
||||||
dotnet-version: 10.0.x
|
|
||||||
- name: Restore
|
|
||||||
working-directory: backend
|
|
||||||
run: dotnet restore TeleWave.slnx
|
|
||||||
- name: Test + coverage
|
|
||||||
working-directory: backend
|
|
||||||
run: >
|
|
||||||
dotnet test TeleWave.slnx
|
|
||||||
-c Release
|
|
||||||
--no-restore
|
|
||||||
--collect:"XPlat Code Coverage"
|
|
||||||
--results-directory ./coverage
|
|
||||||
--logger "console;verbosity=normal"
|
|
||||||
|
|
||||||
# Бейдж покрытия обновляем только для main (не для PR/форков): генерируем SVG и
|
|
||||||
# force-пушим в отдельную orphan-ветку badges — README ссылается на её raw-URL.
|
|
||||||
- name: Coverage badge
|
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
||||||
working-directory: backend
|
|
||||||
env:
|
|
||||||
TOKEN: ${{ secrets.COVERAGE_TOKEN || secrets.GITEA_TOKEN }}
|
|
||||||
run: |
|
|
||||||
dotnet tool install --global dotnet-reportgenerator-globaltool
|
|
||||||
export PATH="$PATH:$HOME/.dotnet/tools"
|
|
||||||
reportgenerator \
|
|
||||||
"-reports:coverage/**/coverage.cobertura.xml" \
|
|
||||||
"-targetdir:coverage/badges" \
|
|
||||||
"-reporttypes:Badges" \
|
|
||||||
"-assemblyfilters:+TeleWave.Domain;+TeleWave.Application" \
|
|
||||||
"-classfilters:-System.*"
|
|
||||||
BADGE=coverage/badges/badge_linecoverage.svg
|
|
||||||
test -f "$BADGE" || { echo "badge not generated"; exit 1; }
|
|
||||||
WORK=$(mktemp -d)
|
|
||||||
cp "$BADGE" "$WORK/coverage.svg"
|
|
||||||
cd "$WORK"
|
|
||||||
git init -q -b badges
|
|
||||||
git add coverage.svg
|
|
||||||
git -c user.name=gitea-actions -c user.email=actions@local \
|
|
||||||
commit -q -m "coverage badge [skip ci]"
|
|
||||||
git push -f \
|
|
||||||
"https://gitea-actions:${TOKEN}@gitea.hsrv.site/mrleo1nid/TeleWave.git" badges
|
|
||||||
-82
@@ -1,82 +0,0 @@
|
|||||||
# ---> 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/
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
# CLAUDE.md
|
|
||||||
|
|
||||||
Инструкции для Claude Code при работе в этом репозитории.
|
|
||||||
|
|
||||||
## Что это
|
|
||||||
|
|
||||||
**TeleWave** — сервис онлайн-каналов: пользователи смотрят сетку каналов, видео отдаётся из
|
|
||||||
хранилища на сервере. Админ управляет каналами и пользователями.
|
|
||||||
|
|
||||||
> Текущее состояние — **база**: вход/регистрация, роли, пользователи, минимальная админка,
|
|
||||||
> приветственная главная страница. Каталог каналов и сама раздача видео **не реализованы** —
|
|
||||||
> это следующий шаг. Telegram-бот сознательно не делаем.
|
|
||||||
|
|
||||||
Архитектура и код-конвенции — прямое зеркало [`D:\Github\PnvPanel`](../PnvPanel) (тот же автор,
|
|
||||||
тот же стек), но домен урезан под текущий объём фичи.
|
|
||||||
|
|
||||||
## Стек
|
|
||||||
|
|
||||||
- **Backend**: C# / .NET 10, ASP.NET Core Web API (Minimal API), Clean Architecture, CQRS через
|
|
||||||
[`LiteCqrs.Net`](https://github.com/mrleo1nid/LiteCqrs.Net) (NuGet-пакет, лёгкая
|
|
||||||
CQRS-библиотека, альтернатива MediatR с явным разделением Command/Query — см. её README).
|
|
||||||
EF Core 10 + Npgsql, ASP.NET Core Identity + JWT (access + refresh), FluentValidation, Serilog.
|
|
||||||
Маппинг DTO вручную. OpenAPI — нативный `Microsoft.AspNetCore.OpenApi` + Scalar UI.
|
|
||||||
- **Frontend**: React 19 + Vite + TS, TanStack Query/Router, shadcn-стиль поверх Radix + Tailwind
|
|
||||||
v4, Zustand (только auth+тема), react-hook-form + zod. pnpm, oxlint.
|
|
||||||
- **Инфра**: единый Docker-образ (API + статика SPA); PostgreSQL — **внешний сервер**, не в
|
|
||||||
compose (в отличие от PnvPanel).
|
|
||||||
|
|
||||||
## Архитектура — жёсткие правила
|
|
||||||
|
|
||||||
Слои и направление зависимостей: **Api → Infrastructure → Application → Domain** (внутрь).
|
|
||||||
|
|
||||||
- **Domain** — без внешних зависимостей. Сейчас единственная сущность — `Auth/RefreshToken`
|
|
||||||
(rich model: приватные сеттеры, фабричный `Issue(...)`, поведенческий `Revoke(...)`).
|
|
||||||
- **Application** — CQRS-хендлеры, DTO, валидаторы, **порты** (интерфейсы:
|
|
||||||
`IAppDbContext`, `ICurrentUser`, `IIdentityService`, `IJwtTokenService`, `IRefreshTokenService`,
|
|
||||||
`IRoleService`). Зависит только от Domain — никаких `Npgsql`/`AspNetCore.Identity`, только их
|
|
||||||
абстракции.
|
|
||||||
- **Infrastructure** — реализации портов: EF Core (`AppDbContext : IdentityDbContext<AppUser,
|
|
||||||
AppRole, Guid>`), `IdentityService`/`RoleService`/`JwtTokenService`/`RefreshTokenService`,
|
|
||||||
идемпотентный `DbInitializer` (сидинг ролей + админа).
|
|
||||||
- **Api** — Minimal API эндпоинты (`Endpoints/*Endpoints.cs`), middleware, DI composition root
|
|
||||||
(`Program.cs`).
|
|
||||||
|
|
||||||
Обязательно:
|
|
||||||
- Команды меняют состояние в транзакции (`UnitOfWorkBehavior`); запросы только читают. Диспетчер —
|
|
||||||
из `LiteCqrs.Net` (`ISender`/`ICommandHandler`/`IQueryHandler`), регистрация через
|
|
||||||
`AddLiteCqrs(...)` в `TeleWave.Application/DependencyInjection.cs`.
|
|
||||||
- Управляемые ошибки — через `Result<T>` (`Common/Models/Result.cs`), не исключениями.
|
|
||||||
- Валидация — FluentValidation через `ValidationBehavior`; хендлер не перепроверяет формат ввода.
|
|
||||||
- Всё I/O асинхронно, `CancellationToken` пробрасывается до EF/HTTP. Никаких `.Result`/`.Wait()`.
|
|
||||||
- Nullable reference types включены; `Directory.Build.props` включает
|
|
||||||
`TreatWarningsAsErrors=true` — предупреждения анализаторов не игнорировать.
|
|
||||||
|
|
||||||
## Домен: роли, пользователи, аутентификация
|
|
||||||
|
|
||||||
Полноценной доменной модели (каналы/видео) пока нет — см. заметку в начале файла. Текущие
|
|
||||||
инварианты:
|
|
||||||
|
|
||||||
- **Роли** (`AppRole : IdentityRole<Guid>`, `Infrastructure/Identity/`) — динамические,
|
|
||||||
без квот (в отличие от PnvPanel: тут нет `MaxIpLimit`/`BillingEnabled` — при появлении
|
|
||||||
доменных фич квоты/лимиты добавляются на `AppRole`/`AppUser` по мере необходимости, не заранее).
|
|
||||||
У пользователя ровно одна роль. Системные роли `admin`/`user` (`IsSystem=true`) нельзя
|
|
||||||
переименовать/удалить — проверяется в `RoleService`. Смену роли (`ChangeUserRoleCommand`)
|
|
||||||
запрещено делать так, чтобы не осталось ни одного `admin` (`RoleErrors.CannotRemoveLastAdmin`).
|
|
||||||
- **Регистрация** — открытая, без гейта активации (в отличие от PnvPanel): `RegisterCommandHandler`
|
|
||||||
создаёт пользователя с ролью `user` и сразу выдаёт токены (auto-login). Если в будущем
|
|
||||||
понадобится модерация новых пользователей — добавлять отдельным полем/флагом по аналогии с
|
|
||||||
`IsActivated` в PnvPanel, не переиспользовать `IsBlocked`.
|
|
||||||
- **Блокировка** (`AppUser.IsBlocked`) — админ блокирует/разблокирует пользователя; вход
|
|
||||||
запрещён (`LoginCommandHandler`/`RefreshCommandHandler` проверяют флаг). Админ не может
|
|
||||||
заблокировать/удалить самого себя (`UserErrors.CannotBlockSelf`/`CannotDeleteSelf`).
|
|
||||||
- **Вход по `UserName`** (email не используется, SMTP не нужен). JWT — access (короткий TTL) +
|
|
||||||
refresh (httpOnly cookie `tw_refresh_token`, `SameSite=Strict`, ротация с обнаружением повторного
|
|
||||||
использования отозванного токена — см. `RefreshTokenService.RotateAsync`).
|
|
||||||
- **Сидинг**: идемпотентный `DbInitializer` создаёт системные роли + админа из env
|
|
||||||
(`AdminSeed__Username`/`Password`) при старте. Источник примера env — `.env.example`.
|
|
||||||
|
|
||||||
## Единый контейнер
|
|
||||||
|
|
||||||
- Один образ: Api раздаёт REST (`/api`) и статику SPA из `wwwroot` (fallback на `index.html`).
|
|
||||||
Один origin, база API — относительный `/api`.
|
|
||||||
- Multi-stage Dockerfile: node (фронт) → dotnet sdk (publish + копирование в `wwwroot`) → aspnet
|
|
||||||
runtime. Никакого gosu/root-drop и Data Protection key-ring — секретов на диске пока нет
|
|
||||||
(JWT-ключ — обычная конфигурация, не шифруемый секрет at-rest); если появятся зашифрованные
|
|
||||||
данные (например API-ключи внешних сервисов) — добавлять Data Protection по образцу PnvPanel.
|
|
||||||
- docker-compose: только `app` — PostgreSQL живёт вне compose (внешний сервер/хост, адрес и
|
|
||||||
креды — в `ConnectionStrings__Default` из `.env`; база и пользователь на нём создаются
|
|
||||||
заранее вручную, приложение их не сидит). Миграции применяются авто на старте
|
|
||||||
(`ApplyMigrationsAsync` в `Program.cs`) — прав `CREATEDB` не требуется, только DDL/DML в
|
|
||||||
уже существующей базе.
|
|
||||||
- Не вводи отдельный nginx-контейнер для статики — ломает требование единого контейнера.
|
|
||||||
- **TLS — внешний**; `app` отдаёт HTTP + доверяет `X-Forwarded-*` (`ForwardedHeaders`).
|
|
||||||
|
|
||||||
## Соглашения по коду
|
|
||||||
|
|
||||||
- `<Verb><Noun>Command`/`<Get|List><Noun>Query` + `Handler`/`Validator` (валидатор — где есть что
|
|
||||||
проверить). Папки — по фичам (`Auth/Login/`, `Admin/Roles/CreateRole/`, ...).
|
|
||||||
- DTO — суффикс `Dto`; тела запросов Api-эндпоинтов, не совпадающие с командой 1:1, — `Body`
|
|
||||||
(`sealed record` внизу файла эндпоинта).
|
|
||||||
- Один публичный тип на файл = имя файла (кроме `Body` в файле эндпоинтов).
|
|
||||||
- Ошибки — статические каталоги `XErrors` (`AuthErrors`, `RoleErrors`, `UserErrors`) с
|
|
||||||
`Error.Validation/NotFound/Conflict/Unauthorized/Forbidden(...)`.
|
|
||||||
- Секреты не логировать; логи — Serilog.
|
|
||||||
- Ошибки API — единый `application/problem+json` (см. `Api/Common/ResultExtensions.cs`).
|
|
||||||
- **Изображения — через общий реестр `Image`** (домен `Domain/Images`, галерея `features/admin/images`).
|
|
||||||
Любой новый функционал, где загружается или выбирается картинка, должен использовать контрол
|
|
||||||
`ImageGallery`/`GalleryBrowser` (пикер по категориям `ImageCategory`) и хранить ссылку на `ImageId`,
|
|
||||||
а не заводить своё файловое поле. Автоматически полученные картинки (например постер из метадаты)
|
|
||||||
тоже регистрируются как `Image`. Файлы лежат под `images/{id}{ext}`, отдаются по `/api/images/{id}`.
|
|
||||||
|
|
||||||
## Команды
|
|
||||||
|
|
||||||
Backend (из `backend/`):
|
|
||||||
```bash
|
|
||||||
dotnet build
|
|
||||||
dotnet test tests/TeleWave.Domain.Tests tests/TeleWave.Application.Tests
|
|
||||||
dotnet run --project src/TeleWave.Api
|
|
||||||
dotnet ef migrations add <Name> --project src/TeleWave.Infrastructure --startup-project src/TeleWave.Api
|
|
||||||
dotnet ef database update --project src/TeleWave.Infrastructure --startup-project src/TeleWave.Api
|
|
||||||
```
|
|
||||||
|
|
||||||
Frontend (из `frontend/`):
|
|
||||||
```bash
|
|
||||||
pnpm install
|
|
||||||
pnpm dev
|
|
||||||
pnpm build
|
|
||||||
pnpm lint && pnpm typecheck
|
|
||||||
```
|
|
||||||
|
|
||||||
Инфраструктура:
|
|
||||||
```bash
|
|
||||||
docker compose up -d --build # только api; postgres — внешний, см. ConnectionStrings__Default
|
|
||||||
```
|
|
||||||
|
|
||||||
> Окружение: Windows, основная оболочка — **PowerShell**. Для POSIX-скриптов есть Bash-инструмент.
|
|
||||||
|
|
||||||
## Рабочие принципы
|
|
||||||
|
|
||||||
- Не начинай крупную реализацию (каталог каналов, раздача видео, транскодинг и т.п.) без сверки
|
|
||||||
с пользователем — это следующий большой этап после базы, архитектурные решения там ещё не приняты.
|
|
||||||
При неоднозначности — вопрос пользователю, не предположение.
|
|
||||||
- Соблюдай границы слоёв — главный инвариант проекта, как и в PnvPanel.
|
|
||||||
- Не коммить и не пуши без явной просьбы.
|
|
||||||
- Отвечай пользователю на русском.
|
|
||||||
-39
@@ -1,39 +0,0 @@
|
|||||||
# syntax=docker/dockerfile:1
|
|
||||||
|
|
||||||
# ── Stage 1: сборка фронтенда (Vite → dist) ───────────────────────────────
|
|
||||||
FROM node:22-alpine AS frontend
|
|
||||||
WORKDIR /app/frontend
|
|
||||||
RUN corepack enable
|
|
||||||
COPY frontend/package.json frontend/pnpm-lock.yaml ./
|
|
||||||
RUN corepack prepare pnpm@11.9.0 --activate && pnpm install --frozen-lockfile
|
|
||||||
COPY frontend/ ./
|
|
||||||
RUN pnpm build
|
|
||||||
|
|
||||||
# ── Stage 2: publish бэкенда, статика фронта в wwwroot ────────────────────
|
|
||||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS backend
|
|
||||||
WORKDIR /src
|
|
||||||
COPY backend/Directory.Build.props backend/Directory.Packages.props ./backend/
|
|
||||||
COPY backend/src/TeleWave.Domain/TeleWave.Domain.csproj ./backend/src/TeleWave.Domain/
|
|
||||||
COPY backend/src/TeleWave.Application/TeleWave.Application.csproj ./backend/src/TeleWave.Application/
|
|
||||||
COPY backend/src/TeleWave.Infrastructure/TeleWave.Infrastructure.csproj ./backend/src/TeleWave.Infrastructure/
|
|
||||||
COPY backend/src/TeleWave.Api/TeleWave.Api.csproj ./backend/src/TeleWave.Api/
|
|
||||||
RUN dotnet restore backend/src/TeleWave.Api/TeleWave.Api.csproj
|
|
||||||
COPY backend/ ./backend/
|
|
||||||
# Статика собранного фронта → wwwroot (перекрывает заглушку)
|
|
||||||
COPY --from=frontend /app/frontend/dist/ ./backend/src/TeleWave.Api/wwwroot/
|
|
||||||
RUN dotnet publish backend/src/TeleWave.Api/TeleWave.Api.csproj -c Release -o /app/publish --no-restore
|
|
||||||
|
|
||||||
# ── Stage 3: runtime ──────────────────────────────────────────────────────
|
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
|
||||||
WORKDIR /app
|
|
||||||
# curl — для HEALTHCHECK; ffmpeg — нормализация и нарезка медиа при загрузке (см. Media__FfmpegPath);
|
|
||||||
# fonts-dejavu-core — кириллический TTF для текста на ТВ-заставках (drawtext, см. Bumpers__FontFile).
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends curl ffmpeg fonts-dejavu-core \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
ENV ASPNETCORE_ENVIRONMENT=Production \
|
|
||||||
ASPNETCORE_HTTP_PORTS=8080
|
|
||||||
EXPOSE 8080
|
|
||||||
COPY --from=backend /app/publish ./
|
|
||||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=5 \
|
|
||||||
CMD curl -f http://localhost:8080/health || exit 1
|
|
||||||
ENTRYPOINT ["dotnet", "TeleWave.Api.dll"]
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
# TeleWave
|
|
||||||
|
|
||||||
[](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions?workflow=build.yml)
|
|
||||||
[](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions?workflow=test.yml)
|
|
||||||
[](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions?workflow=test.yml)
|
|
||||||
|
|
||||||
**TeleWave** — сервис онлайн-каналов: пользователи смотрят сетку каналов, видео отдаётся из
|
|
||||||
хранилища на сервере, админ управляет каналами и пользователями.
|
|
||||||
|
|
||||||
> Текущее состояние — **база**: вход/регистрация, роли, пользователи, минимальная админка,
|
|
||||||
> приветственная главная страница. Каталог каналов и раздача видео — следующий этап, пока не
|
|
||||||
> реализованы. Приложение (фронт + бек) поставляется **единым Docker-образом**; PostgreSQL —
|
|
||||||
> **внешний**, не поднимается через compose.
|
|
||||||
|
|
||||||
## Стек
|
|
||||||
|
|
||||||
| Слой | Технологии |
|
|
||||||
| -------- | ---------------------------------------------------------------------------------------- |
|
|
||||||
| Backend | C# / .NET 10, ASP.NET Core Web API, Clean Architecture, CQRS ([LiteCqrs.Net](https://github.com/mrleo1nid/LiteCqrs.Net)), EF Core |
|
|
||||||
| БД | PostgreSQL (Npgsql) |
|
|
||||||
| Auth | ASP.NET Core Identity + JWT (access + refresh, ротация) |
|
|
||||||
| Frontend | React 19 + Vite + TypeScript, TanStack Query/Router, shadcn/ui + Tailwind v4 (ретро-CRT тема) |
|
|
||||||
| Упаковка | Единый Docker-образ (API + статика SPA); PostgreSQL — внешний сервер |
|
|
||||||
|
|
||||||
## Развёртывание
|
|
||||||
|
|
||||||
Postgres не входит в compose — база и пользователь создаются заранее на внешнем сервере БД:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- на сервере БД (psql -h <хост БД> -U postgres)
|
|
||||||
CREATE USER telewave WITH PASSWORD 'change-me-strong-db-password';
|
|
||||||
CREATE DATABASE telewave OWNER telewave;
|
|
||||||
```
|
|
||||||
|
|
||||||
Затем на хосте с приложением:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
# заполнить ConnectionStrings__Default (хост/база/юзер/пароль БД), Jwt__SigningKey, AdminSeed__*
|
|
||||||
docker compose up -d --build
|
|
||||||
# → http://localhost:8085 (админ — логин/пароль из .env, AdminSeed__Username/Password)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Локальная разработка
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# backend, из backend/
|
|
||||||
dotnet build && dotnet test tests/TeleWave.Domain.Tests tests/TeleWave.Application.Tests
|
|
||||||
dotnet run --project src/TeleWave.Api
|
|
||||||
|
|
||||||
# frontend, из frontend/
|
|
||||||
pnpm install
|
|
||||||
pnpm dev # проксирует /api на localhost:8080
|
|
||||||
```
|
|
||||||
|
|
||||||
## Документация
|
|
||||||
|
|
||||||
Пример переменных окружения (сид админа, БД, JWT) — [`.env.example`](.env.example).
|
|
||||||
Инструкции для AI-ассистента (Claude Code) — в [`CLAUDE.md`](CLAUDE.md) (стек, архитектурные
|
|
||||||
правила, доменные инварианты, соглашения по коду).
|
|
||||||
|
|
||||||
## Лицензия
|
|
||||||
|
|
||||||
Не определена.
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
# Ревью TeleWave — план исправлений
|
|
||||||
|
|
||||||
Статус чекбоксов: `[ ]` не сделано · `[x]` исправлено · `[~]` в работе.
|
|
||||||
|
|
||||||
Дата ревью: 2026-07-25. Область: backend (C#/.NET 10), frontend (React 19), инфра.
|
|
||||||
Сборка чистая, 144 теста зелёные, typecheck чистый.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔴 Критичное
|
|
||||||
|
|
||||||
- [x] **C1. Утечка слота семафора → медиа-обработка навсегда встаёт**
|
|
||||||
`MediaProcessingBackgroundService.cs:52` — слот берётся до `ClaimNextAsync`; бросок из claim
|
|
||||||
уводит во внешний `catch` без `Release()`. После N ошибок диспетчер зависает навсегда.
|
|
||||||
_Fix: try/finally вокруг claim либо Release() в ветке ошибки._
|
|
||||||
|
|
||||||
- [x] **C2. Слабый JWT-ключ по умолчанию без fail-fast**
|
|
||||||
`appsettings.json:8` + `DependencyInjection.cs:65` — placeholder-ключ, нет проверки на
|
|
||||||
переопределение и длину; тот же ключ у stream-токенов. Забыли → обход авторизации.
|
|
||||||
_Fix: на старте кидать, если ключ = placeholder или < 32 байт._
|
|
||||||
|
|
||||||
- [x] **C3. Инъекция/поломка ffmpeg-фильтра из пользовательских подписей**
|
|
||||||
`FfmpegBumperRenderer.cs:306` — `EscapeText` не экранирует `,` `;` `[` `]` `%` перевод строки.
|
|
||||||
Подпись с запятой рвёт filtergraph; `[`/`]` — инъекция звеньев. Валидатор проверяет только длину.
|
|
||||||
_Fix: экранировать полный набор метасимволов filtergraph._
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🟠 Среднее
|
|
||||||
|
|
||||||
### Безопасность
|
|
||||||
- [x] **M1. Cookie без `Secure` за TLS-прокси** — `AuthEndpoints.cs:207`, `StreamingEndpoints.cs:63`.
|
|
||||||
- [x] **M2. Ключи TMDb/OMDb в логах** — `TmdbMetadataProvider.cs:27`, `OmdbMetadataProvider.cs:25` (`System.Net.Http` не приглушён в Serilog).
|
|
||||||
- [ ] **M3. Stream-токен неотзывной, TTL 6ч, игнорирует userId/блокировку** — `StreamTokenService.cs:15`, `StreamingEndpoints.cs:98,123`. _(дизайн-решение — отложено)_
|
|
||||||
- [x] **M4. Rate-limiter глобальный (не партиционирован), только на `/api/auth`** — `Program.cs:66`.
|
|
||||||
|
|
||||||
### Архитектура / транзакции
|
|
||||||
- [ ] **M5. `ExecuteDeleteAsync` ломает границу UnitOfWork + файлы удаляются до коммита** — `DeleteShowMediaCommandHandler.cs:36`, `ClearAllMediaCommandHandler.cs`, `DeleteAllShowsCommandHandler.cs`. _(дизайн-решение — отложено)_
|
|
||||||
- [ ] **M6. Query с побочным эффектом на ФС** — `RenderBumperPreviewQueryHandler.cs:18` (рендер файлов под видом запроса). _(дизайн-решение — отложено)_
|
|
||||||
- [ ] **M7. Файловый/HTTP I/O до коммита** — Delete/Register/RefreshEpisodes хендлеры (orphan-файлы при откате). _(дизайн-решение — отложено)_
|
|
||||||
|
|
||||||
### Планировщик / медиа
|
|
||||||
- [ ] **M8. Нет таймаута на ffmpeg/ffprobe** — `ProcessRunner.cs:63` (зависший процесс держит слот). _(нужен разумный таймаут — отложено на согласование)_
|
|
||||||
- [ ] **M9. Гонка при конкурентной генерации расписания канала** — `ScheduleGenerator.cs:62` (тик + regenerate → дубли записей). _(дизайн-решение — отложено)_
|
|
||||||
- [ ] **M10. Рендер заставок синхронно внутри тика планировщика** — `ScheduleBumperResolver.cs:174`. _(дизайн-решение — отложено)_
|
|
||||||
- [x] **M11. Override через полночь не работает** — `SchedulePlannerModels.cs:40` (`EndMinute <= StartMinute` → пустое окно).
|
|
||||||
|
|
||||||
### Фронтенд
|
|
||||||
- [x] **M12. Молчаливое проглатывание ошибки → вечный скелетон** — `AirPage.tsx:43`.
|
|
||||||
- [x] **M13. Повторный 401 после refresh не разлогинивает** — `client.ts:80`.
|
|
||||||
- [ ] **M14. Клиентская пагинация поверх усечённого ответа** — `ShowDetail.tsx:42` (`pageSize:500`), `ChannelDetail.tsx:32` (`pageSize:100`). _(нужна серверная пагинация — отложено)_
|
|
||||||
- [ ] **M15. Дедуп загрузок только по имени файла** — `upload-store.ts:143`. _(зависит от контракта серверной уникальности — отложено)_
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🟡 Низкое / прочее
|
|
||||||
|
|
||||||
- [ ] **L1. Синхронный I/O без CancellationToken в портах удаления** (`IMediaStorage`/`IImageStore`/`IBumperTemplateStorage`). _(меняет интерфейсы портов — отложено)_
|
|
||||||
- [x] **L2. `ValidationBehavior` вызывает `Validate` вместо `ValidateAsync`** — `ValidationBehavior.cs:24`.
|
|
||||||
- [ ] **L3. Инвариант «Single = 1 серия» проверяется в хендлере, а не в агрегате** — `Show.AddEpisode`. _(рефактор агрегата — отложено)_
|
|
||||||
- [ ] **L4. Двойной `SaveChanges` в командном пути ScheduleGenerator** — `ScheduleGenerator.cs:79,132`. _(связано с M9 — отложено)_
|
|
||||||
- [x] **L5. HLS-плеер не восстанавливается после fatal network/media error** — `ChannelPlayer.tsx:106` (+ симметричная очистка слушателей нативного HLS).
|
|
||||||
- [x] **L6. Бесконечная повторная регистрация «падающего» файла из inbox** — `InboxScannerBackgroundService.cs:92`.
|
|
||||||
- [x] **L7. Возможное переполнение int в весах шоу** — `SchedulePlanner.cs:256,265`.
|
|
||||||
- [ ] **L8. OpenAPI/Scalar мапятся всегда, без гейта по окружению** — `Program.cs:108`. _(возможно намеренно для внутреннего инструмента — решение за владельцем)_
|
|
||||||
- [ ] **L9. `AllowedHosts: "*"` и dev-креды БД в appsettings.json**. _(dev-дефолты; решение за владельцем)_
|
|
||||||
- [x] **L10. SSRF-поверхность в ImageDownloader** (без allowlist схемы/хоста) — `ImageDownloader.cs:16` (allowlist http/https).
|
|
||||||
- [ ] **L11. Документация (CLAUDE.md/README) отстала**: заявлено «каталог каналов и раздача видео не реализованы», хотя реализованы. _(нужна сверка с владельцем по объёму)_
|
|
||||||
- [ ] **L12. Пробелы в тестах**: фоновые сервисы, ScheduleGenerator-оркестрация, media-конвейер, эндпоинты без тестов. _(частично: добавлены тесты на M11)_
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Журнал исправлений
|
|
||||||
|
|
||||||
### 2026-07-25 — критические (C1–C3)
|
|
||||||
|
|
||||||
- **C1** — `MediaProcessingBackgroundService.cs`: `ClaimNextAsync` обёрнут в `try/catch`, слот
|
|
||||||
освобождается (`slots.Release()`) при любом сбое захвата перед `throw`. Теперь транзиентная
|
|
||||||
ошибка БД не «съедает» слот семафора — диспетчер не зависает.
|
|
||||||
- **C2** — `Infrastructure/DependencyInjection.cs`: после загрузки `JwtOptions` добавлен fail-fast:
|
|
||||||
старт падает с понятным сообщением, если `Jwt:SigningKey` короче 32 байт или содержит `change-me`
|
|
||||||
(значение-заглушка). Тот же ключ подписывает stream-токены, поэтому это закрывает и их.
|
|
||||||
- **C3** — `FfmpegBumperRenderer.cs`: подписи «Сейчас/Далее» (`NowLabel`/`NextLabel`) переведены с
|
|
||||||
инлайнового `text=` на `textfile=` (`nowlabel.txt`/`nextlabel.txt`, `expansion=none`), как уже
|
|
||||||
сделано для названий шоу. Метасимволы filtergraph в подписи больше не ломают/не инъектируют
|
|
||||||
цепочку. Неиспользуемый `EscapeText` удалён. Файлы чистятся в `finally`.
|
|
||||||
|
|
||||||
Проверка: `dotnet build` — 0 warnings/0 errors (при `TreatWarningsAsErrors`); тесты 95 + 49 зелёные.
|
|
||||||
|
|
||||||
### 2026-07-25 — механические средние/низкие (M1, M2, M4, M11, M12, M13, L2, L5, L6, L7, L10)
|
|
||||||
|
|
||||||
Backend:
|
|
||||||
- **M1** — `AuthEndpoints.cs`/`StreamingEndpoints.cs`: cookie-флаг `Secure` вычисляется через
|
|
||||||
`UseSecureCookie` (`!env.IsDevelopment() || request.IsHttps`) вместо голого `request.IsHttps`.
|
|
||||||
Вне Development cookie всегда `Secure` — прод за внешним TLS-прокси больше не отдаёт refresh/stream
|
|
||||||
cookie в открытую при неполной настройке ForwardedHeaders. В Development HTTP-разработка сохранена.
|
|
||||||
- **M2** — `appsettings.json`: добавлен override `System.Net.Http.HttpClient: Warning` — URI запросов
|
|
||||||
к TMDb/OMDb (с `api_key` в query) больше не пишутся в лог на уровне Information.
|
|
||||||
- **M4** — `Program.cs`: rate-limiter `auth` переведён на партиционирование по IP клиента
|
|
||||||
(`RateLimitPartition.GetFixedWindowLimiter`) — один клиент больше не исчерпывает окно логина для всех.
|
|
||||||
- **M11** — `SchedulePlannerModels.cs` + `ProgrammingOverride.cs`: `Covers` для еженедельного override
|
|
||||||
поддерживает окно через полночь (`EndMinute <= StartMinute` → `[start,24:00)` в day + `[0,end)` на
|
|
||||||
следующий день); `end == start` — пустое окно; добавлены null-guards. Регресс-тесты в
|
|
||||||
`ProgrammingOverrideTests` (+2, всего 97 domain).
|
|
||||||
- **L2** — `ValidationBehavior.cs`: `ValidateAsync(context, cancellationToken)` вместо синхронного
|
|
||||||
`Validate` — async-правила FluentValidation и отмена работают.
|
|
||||||
- **L6** — `InboxScannerBackgroundService.cs`: файлы, стабильно отклоняемые командой регистрации,
|
|
||||||
помечаются (`_failed`) и не регистрируются повторно каждые два тика; метка снимается при исчезновении файла.
|
|
||||||
- **L7** — `SchedulePlanner.cs`: веса шоу/вариантов суммируются в `long` с clamp до `int.MaxValue` —
|
|
||||||
экстремальные вес/множитель не переполняют int (иначе взвешенный выбор молча вырождался в первого).
|
|
||||||
- **L10** — `ImageDownloader.cs`: allowlist схемы (только абсолютные http/https) — подменённый ответ
|
|
||||||
провайдера не заставит сервер дёрнуть `file://`/`ftp://` и т.п.
|
|
||||||
|
|
||||||
Frontend:
|
|
||||||
- **M12** — `AirPage.tsx`: провал `watchChannel` показывает offline-панель с кнопкой ретрая (а не
|
|
||||||
бесконечный скелетон); ретрай (`attempt`) заново дёргает `watchChannel`.
|
|
||||||
- **M13** — `client.ts`: повторный 401 уже после успешного refresh вызывает `onUnauthorized()` —
|
|
||||||
мёртвая сессия чистит авторизацию, а не остаётся «залогиненной». Плюс guard на тело refresh-ответа
|
|
||||||
(`accessToken` должен быть строкой).
|
|
||||||
- **L5** — `ChannelPlayer.tsx`: fatal network/media HLS-ошибка сперва пробует восстановиться
|
|
||||||
(`startLoad`/`recoverMediaError`, до 3 попыток) и лишь затем уходит в offline; слушатели нативной
|
|
||||||
HLS-ветки снимаются в cleanup.
|
|
||||||
|
|
||||||
Отложено (требует решения владельца/дизайна): M3, M5–M10, M14, M15, L1, L3, L4, L8, L9, L11, L12 —
|
|
||||||
причины помечены у пунктов.
|
|
||||||
|
|
||||||
Проверка: `dotnet build` 0/0; тесты 97 + 49 зелёные; frontend `tsc` чистый; `oxlint` без новых
|
|
||||||
предупреждений.
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<Project>
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<LangVersion>latest</LangVersion>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
|
||||||
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
|
||||||
<AnalysisLevel>latest</AnalysisLevel>
|
|
||||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
|
||||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
|
||||||
<NoWarn>$(NoWarn);CA1711;CA1716;CA1848;CA1873</NoWarn>
|
|
||||||
</PropertyGroup>
|
|
||||||
</Project>
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
<Project>
|
|
||||||
<PropertyGroup>
|
|
||||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
|
||||||
</PropertyGroup>
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageVersion Include="FluentValidation" Version="12.1.1" />
|
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
|
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.11" />
|
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.10" />
|
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
|
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.10" />
|
|
||||||
<PackageVersion
|
|
||||||
Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore"
|
|
||||||
Version="10.0.10"
|
|
||||||
/>
|
|
||||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.10" />
|
|
||||||
<!-- Держим 2.x: Microsoft.AspNetCore.OpenApi 10.0.10 сам зависит от Microsoft.OpenApi >=2.0.0
|
|
||||||
(nuspec) и его Roslyn source generator (XmlCommentGenerator) скомпилирован под 2.x API —
|
|
||||||
3.x меняет IOpenApiMediaType.Example на read-only и ломает генерацию (CS0200). -->
|
|
||||||
<PackageVersion Include="Microsoft.OpenApi" Version="2.11.0" />
|
|
||||||
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.16" />
|
|
||||||
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
|
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.10" />
|
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
|
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
|
||||||
<PrivateAssets>all</PrivateAssets>
|
|
||||||
</PackageVersion>
|
|
||||||
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.10" />
|
|
||||||
<PackageVersion
|
|
||||||
Include="Microsoft.Extensions.DependencyInjection.Abstractions"
|
|
||||||
Version="10.0.10"
|
|
||||||
/>
|
|
||||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.10" />
|
|
||||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" />
|
|
||||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.10" />
|
|
||||||
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.21.0" />
|
|
||||||
<PackageVersion Include="LiteCqrs.Net" Version="1.0.1" />
|
|
||||||
<!-- Тестирование -->
|
|
||||||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
|
||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
|
||||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
|
||||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
|
|
||||||
<PackageVersion Include="NSubstitute" Version="6.0.0" />
|
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.10" />
|
|
||||||
</ItemGroup>
|
|
||||||
</Project>
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<Solution>
|
|
||||||
<Folder Name="/src/">
|
|
||||||
<Project Path="src/TeleWave.Api/TeleWave.Api.csproj" />
|
|
||||||
<Project Path="src/TeleWave.Application/TeleWave.Application.csproj" />
|
|
||||||
<Project Path="src/TeleWave.Domain/TeleWave.Domain.csproj" />
|
|
||||||
<Project Path="src/TeleWave.Infrastructure/TeleWave.Infrastructure.csproj" />
|
|
||||||
</Folder>
|
|
||||||
<Folder Name="/tests/">
|
|
||||||
<Project Path="tests/TeleWave.Domain.Tests/TeleWave.Domain.Tests.csproj" />
|
|
||||||
<Project Path="tests/TeleWave.Application.Tests/TeleWave.Application.Tests.csproj" />
|
|
||||||
</Folder>
|
|
||||||
</Solution>
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
namespace TeleWave.Api.Common;
|
|
||||||
|
|
||||||
/// <summary>Единый ответ на создание сущности — её идентификатор.</summary>
|
|
||||||
public sealed record CreatedIdResponse(Guid Id);
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
namespace TeleWave.Api.Common;
|
|
||||||
|
|
||||||
public static class RateLimiting
|
|
||||||
{
|
|
||||||
public const string AuthPolicy = "auth";
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using TeleWave.Api.Common;
|
|
||||||
using TeleWave.Application.Admin.Users.BlockUser;
|
|
||||||
using TeleWave.Application.Admin.Users.CreateUser;
|
|
||||||
using TeleWave.Application.Admin.Users.DeleteUser;
|
|
||||||
using TeleWave.Application.Admin.Users.GetUser;
|
|
||||||
using TeleWave.Application.Admin.Users.ListUsers;
|
|
||||||
using TeleWave.Application.Admin.Users.ResetPassword;
|
|
||||||
using TeleWave.Application.Admin.Users.UnblockUser;
|
|
||||||
using TeleWave.Application.Common.Interfaces;
|
|
||||||
using TeleWave.Application.Common.Models;
|
|
||||||
using TeleWave.Infrastructure.Identity;
|
|
||||||
|
|
||||||
namespace TeleWave.Api.Endpoints;
|
|
||||||
|
|
||||||
public static class AdminUserEndpoints
|
|
||||||
{
|
|
||||||
public static IEndpointRouteBuilder MapAdminUserEndpoints(this IEndpointRouteBuilder app)
|
|
||||||
{
|
|
||||||
var admin = app.MapGroup("/api/admin/users")
|
|
||||||
.WithTags("Admin.Users")
|
|
||||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
|
||||||
|
|
||||||
admin.MapGet("", ListUsers).Produces<PagedList<UserSummaryDto>>();
|
|
||||||
admin.MapPost("", CreateUser).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
|
||||||
admin.MapGet("/{id:guid}", GetUser).Produces<UserSummaryDto>();
|
|
||||||
admin.MapPost("/{id:guid}/block", BlockUser).Produces(StatusCodes.Status204NoContent);
|
|
||||||
admin.MapPost("/{id:guid}/unblock", UnblockUser).Produces(StatusCodes.Status204NoContent);
|
|
||||||
admin
|
|
||||||
.MapPost("/{id:guid}/password", ResetPassword)
|
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
|
||||||
admin.MapDelete("/{id:guid}", DeleteUser).Produces(StatusCodes.Status204NoContent);
|
|
||||||
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> ListUsers(
|
|
||||||
int page,
|
|
||||||
int pageSize,
|
|
||||||
string? search,
|
|
||||||
Guid? roleId,
|
|
||||||
bool? isBlocked,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(
|
|
||||||
new ListUsersQuery(
|
|
||||||
page <= 0 ? 1 : page,
|
|
||||||
pageSize <= 0 ? 20 : pageSize,
|
|
||||||
search,
|
|
||||||
roleId,
|
|
||||||
isBlocked
|
|
||||||
),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return Results.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> CreateUser(
|
|
||||||
CreateUserBody body,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(
|
|
||||||
new CreateUserCommand(body.UserName, body.Password, body.RoleId),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return result.IsSuccess
|
|
||||||
? Results.Created(
|
|
||||||
$"/api/admin/users/{result.Value}",
|
|
||||||
new CreatedIdResponse(result.Value)
|
|
||||||
)
|
|
||||||
: result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> GetUser(
|
|
||||||
Guid id,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(new GetUserQuery(id), cancellationToken);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> BlockUser(
|
|
||||||
Guid id,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(new BlockUserCommand(id), cancellationToken);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> UnblockUser(
|
|
||||||
Guid id,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(new UnblockUserCommand(id), cancellationToken);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> ResetPassword(
|
|
||||||
Guid id,
|
|
||||||
ResetPasswordBody body,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(
|
|
||||||
new ResetUserPasswordCommand(id, body.NewPassword),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> DeleteUser(
|
|
||||||
Guid id,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(new DeleteUserCommand(id), cancellationToken);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed record CreateUserBody(string UserName, string Password, Guid RoleId);
|
|
||||||
|
|
||||||
public sealed record ResetPasswordBody(string NewPassword);
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
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);
|
|
||||||
@@ -1,343 +0,0 @@
|
|||||||
using System.Text;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using LiteCqrs;
|
|
||||||
using TeleWave.Api.Common;
|
|
||||||
using TeleWave.Application.Broadcast;
|
|
||||||
using TeleWave.Application.Broadcast.Bumpers;
|
|
||||||
using TeleWave.Application.Common.Interfaces;
|
|
||||||
using TeleWave.Domain.Broadcast;
|
|
||||||
using TeleWave.Infrastructure.Media;
|
|
||||||
|
|
||||||
namespace TeleWave.Api.Endpoints;
|
|
||||||
|
|
||||||
/// <summary>Эндпоинты ТВ-заставок канала: блоки (стиль/аудио/фон), подблоки и рендер превью.</summary>
|
|
||||||
public static partial class ChannelEndpoints
|
|
||||||
{
|
|
||||||
private static readonly Regex BumperSegmentFileName = new(
|
|
||||||
@"^seg\d{1,6}\.ts$",
|
|
||||||
RegexOptions.Compiled
|
|
||||||
);
|
|
||||||
|
|
||||||
private static async Task<IResult> AddBumperTemplate(
|
|
||||||
Guid id,
|
|
||||||
AddBumperTemplateBody body,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(
|
|
||||||
new AddBumperTemplateCommand(id, body.Name),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return result.IsSuccess
|
|
||||||
? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value))
|
|
||||||
: result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> UpdateBumperTemplate(
|
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
|
||||||
UpdateBumperTemplateBody body,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(
|
|
||||||
new UpdateBumperTemplateCommand(
|
|
||||||
id,
|
|
||||||
templateId,
|
|
||||||
body.Name,
|
|
||||||
body.BackgroundColor,
|
|
||||||
body.BackgroundColor2,
|
|
||||||
body.AccentColor,
|
|
||||||
body.TextColor
|
|
||||||
),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> RemoveBumperTemplate(
|
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(
|
|
||||||
new RemoveBumperTemplateCommand(id, templateId),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> UploadTemplateAudio(
|
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
|
||||||
string fileName,
|
|
||||||
HttpRequest request,
|
|
||||||
IBumperTemplateStorage storage,
|
|
||||||
IAudioProbe probe,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
if (ResolveBumperExtension(fileName, request, BumperFiles.AudioExtensions) is not { } ext)
|
|
||||||
return ChannelErrors.InvalidBumperFile.ToProblem();
|
|
||||||
|
|
||||||
await storage.SaveAudioAsync(templateId, ext, request.Body, cancellationToken);
|
|
||||||
|
|
||||||
// Длина заставки идёт по длине звука — замеряем ffprobe (при неудаче 0 → дефолтная длина).
|
|
||||||
var path = storage.AudioPath(templateId, ext);
|
|
||||||
var duration = path is null
|
|
||||||
? null
|
|
||||||
: await probe.TryGetDurationAsync(path, cancellationToken);
|
|
||||||
|
|
||||||
var result = await sender.Send(
|
|
||||||
new SetBumperTemplateAudioCommand(id, templateId, ext, duration?.TotalSeconds ?? 0),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
if (!result.IsSuccess)
|
|
||||||
storage.DeleteAudio(templateId);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> ClearTemplateAudio(
|
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(
|
|
||||||
new ClearBumperTemplateAudioCommand(id, templateId),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> SetTemplateBackground(
|
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
|
||||||
SetBumperTemplateBackgroundBody body,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(
|
|
||||||
new SetBumperTemplateBackgroundCommand(id, templateId, body.ImageId),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> ClearTemplateBackground(
|
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(
|
|
||||||
new ClearBumperTemplateBackgroundCommand(id, templateId),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> AddBumperVariant(
|
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
|
||||||
AddBumperVariantBody body,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(
|
|
||||||
new AddBumperTextVariantCommand(id, templateId, body.Name),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return result.IsSuccess
|
|
||||||
? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value))
|
|
||||||
: result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> UpdateBumperVariant(
|
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
|
||||||
Guid variantId,
|
|
||||||
UpdateBumperVariantBody body,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(
|
|
||||||
new UpdateBumperTextVariantCommand(
|
|
||||||
id,
|
|
||||||
templateId,
|
|
||||||
variantId,
|
|
||||||
body.Name,
|
|
||||||
body.Kind,
|
|
||||||
body.NowLabel,
|
|
||||||
body.NextLabel,
|
|
||||||
body.Line1,
|
|
||||||
body.Line2,
|
|
||||||
body.Trigger,
|
|
||||||
body.Weight
|
|
||||||
),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> RemoveBumperVariant(
|
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
|
||||||
Guid variantId,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(
|
|
||||||
new RemoveBumperTextVariantCommand(id, templateId, variantId),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Синхронно рендерит пример заставки блока (несколько секунд ffmpeg).</summary>
|
|
||||||
private static async Task<IResult> RenderPreview(
|
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(
|
|
||||||
new RenderBumperPreviewQuery(id, templateId),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return result.IsSuccess ? Results.NoContent() : result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Плейлист превью подблока: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
|
|
||||||
private static IResult PreviewPlaylist(
|
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
|
||||||
Guid variantId,
|
|
||||||
MediaPathResolver paths
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var previewId = BumperPreview.AssetId(variantId);
|
|
||||||
string indexPath;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
indexPath = paths.SegmentPath(previewId, "index.m3u8");
|
|
||||||
}
|
|
||||||
catch (UnauthorizedAccessException)
|
|
||||||
{
|
|
||||||
return Results.NotFound();
|
|
||||||
}
|
|
||||||
if (!File.Exists(indexPath))
|
|
||||||
return Results.NotFound();
|
|
||||||
|
|
||||||
var baseUrl =
|
|
||||||
$"/api/admin/channels/{id}/bumper/templates/{templateId}/preview/{variantId}/";
|
|
||||||
var sb = new StringBuilder();
|
|
||||||
foreach (var line in File.ReadLines(indexPath))
|
|
||||||
{
|
|
||||||
var trimmed = line.Trim();
|
|
||||||
if (trimmed.Length == 0)
|
|
||||||
continue;
|
|
||||||
// Комментарии/директивы — как есть; строки-сегменты (абсолютный путь от ffmpeg) → admin-URL.
|
|
||||||
sb.Append(trimmed.StartsWith('#') ? trimmed : baseUrl + Path.GetFileName(trimmed))
|
|
||||||
.Append('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
return Results.Text(sb.ToString(), "application/vnd.apple.mpegurl");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IResult PreviewSegment(
|
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
|
||||||
Guid variantId,
|
|
||||||
string file,
|
|
||||||
MediaPathResolver paths
|
|
||||||
)
|
|
||||||
{
|
|
||||||
if (!BumperSegmentFileName.IsMatch(file))
|
|
||||||
return Results.NotFound();
|
|
||||||
|
|
||||||
var previewId = BumperPreview.AssetId(variantId);
|
|
||||||
string path;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
path = paths.SegmentPath(previewId, file);
|
|
||||||
}
|
|
||||||
catch (UnauthorizedAccessException)
|
|
||||||
{
|
|
||||||
return Results.NotFound();
|
|
||||||
}
|
|
||||||
if (!File.Exists(path))
|
|
||||||
return Results.NotFound();
|
|
||||||
|
|
||||||
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Проверяет расширение файла (по allowlist) и размер (Content-Length). Возвращает
|
|
||||||
/// нормализованное расширение (с точкой, нижний регистр) или null при отказе.</summary>
|
|
||||||
private static string? ResolveBumperExtension(
|
|
||||||
string fileName,
|
|
||||||
HttpRequest request,
|
|
||||||
IReadOnlySet<string> allowedExtensions
|
|
||||||
)
|
|
||||||
{
|
|
||||||
if (request.ContentLength is > BumperFiles.MaxBytes or 0 or null)
|
|
||||||
return null;
|
|
||||||
var ext = Path.GetExtension(fileName).ToLowerInvariant();
|
|
||||||
return allowedExtensions.Contains(ext) ? ext : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed record AddBumperTemplateBody(string Name);
|
|
||||||
|
|
||||||
public sealed record UpdateBumperTemplateBody(
|
|
||||||
string Name,
|
|
||||||
string BackgroundColor,
|
|
||||||
string BackgroundColor2,
|
|
||||||
string AccentColor,
|
|
||||||
string TextColor
|
|
||||||
);
|
|
||||||
|
|
||||||
public sealed record SetBumperTemplateBackgroundBody(Guid ImageId);
|
|
||||||
|
|
||||||
public sealed record AddBumperVariantBody(string Name);
|
|
||||||
|
|
||||||
public sealed record UpdateBumperVariantBody(
|
|
||||||
string Name,
|
|
||||||
BumperTextKind Kind,
|
|
||||||
string NowLabel,
|
|
||||||
string NextLabel,
|
|
||||||
string Line1,
|
|
||||||
string Line2,
|
|
||||||
BumperTrigger Trigger,
|
|
||||||
int Weight
|
|
||||||
);
|
|
||||||
|
|
||||||
/// <summary>Ограничения на загружаемый звук блока заставки (фон-картинка — через общий реестр).</summary>
|
|
||||||
internal static class BumperFiles
|
|
||||||
{
|
|
||||||
public const long MaxBytes = 200L * 1024 * 1024; // 200 МБ
|
|
||||||
|
|
||||||
public static readonly IReadOnlySet<string> AudioExtensions = new HashSet<string>(
|
|
||||||
StringComparer.OrdinalIgnoreCase
|
|
||||||
)
|
|
||||||
{
|
|
||||||
".mp3",
|
|
||||||
".m4a",
|
|
||||||
".aac",
|
|
||||||
".ogg",
|
|
||||||
".opus",
|
|
||||||
".wav",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using TeleWave.Api.Common;
|
|
||||||
using TeleWave.Application.Broadcast.CreateOverride;
|
|
||||||
using TeleWave.Application.Broadcast.DeleteOverride;
|
|
||||||
using TeleWave.Domain.Broadcast;
|
|
||||||
|
|
||||||
namespace TeleWave.Api.Endpoints;
|
|
||||||
|
|
||||||
/// <summary>Эндпоинты временных override'ов / марафонов канала (разовые и еженедельные).</summary>
|
|
||||||
public static partial class ChannelEndpoints
|
|
||||||
{
|
|
||||||
private static async Task<IResult> CreateOverride(
|
|
||||||
Guid id,
|
|
||||||
CreateOverrideBody body,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(
|
|
||||||
new CreateProgrammingOverrideCommand(
|
|
||||||
id,
|
|
||||||
body.Mode,
|
|
||||||
body.Recurrence,
|
|
||||||
body.StartsAtUtc,
|
|
||||||
body.EndsAtUtc,
|
|
||||||
body.DayOfWeek,
|
|
||||||
body.StartMinute,
|
|
||||||
body.EndMinute,
|
|
||||||
body.Shows
|
|
||||||
),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return result.IsSuccess
|
|
||||||
? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value))
|
|
||||||
: result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> DeleteOverride(
|
|
||||||
Guid id,
|
|
||||||
Guid overrideId,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(
|
|
||||||
new DeleteProgrammingOverrideCommand(id, overrideId),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed record CreateOverrideBody(
|
|
||||||
OverrideMode Mode,
|
|
||||||
OverrideRecurrence Recurrence,
|
|
||||||
DateTimeOffset? StartsAtUtc,
|
|
||||||
DateTimeOffset? EndsAtUtc,
|
|
||||||
int? DayOfWeek,
|
|
||||||
int? StartMinute,
|
|
||||||
int? EndMinute,
|
|
||||||
IReadOnlyList<OverrideShowInput> Shows
|
|
||||||
);
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using TeleWave.Api.Common;
|
|
||||||
using TeleWave.Application.Broadcast.AddChannelAd;
|
|
||||||
using TeleWave.Application.Broadcast.AddChannelShow;
|
|
||||||
using TeleWave.Application.Broadcast.RemoveChannelAd;
|
|
||||||
using TeleWave.Application.Broadcast.RemoveChannelShow;
|
|
||||||
using TeleWave.Application.Broadcast.UpdateChannelShow;
|
|
||||||
using TeleWave.Domain.Broadcast;
|
|
||||||
|
|
||||||
namespace TeleWave.Api.Endpoints;
|
|
||||||
|
|
||||||
/// <summary>Эндпоинты канала: шоу в ротации и рекламный пул.</summary>
|
|
||||||
public static partial class ChannelEndpoints
|
|
||||||
{
|
|
||||||
private static async Task<IResult> AddShow(
|
|
||||||
Guid id,
|
|
||||||
AddChannelShowBody body,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(
|
|
||||||
new AddChannelShowCommand(
|
|
||||||
id,
|
|
||||||
body.ShowId,
|
|
||||||
body.Weight,
|
|
||||||
body.BlockMode,
|
|
||||||
body.BlockValue
|
|
||||||
),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return result.IsSuccess
|
|
||||||
? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value))
|
|
||||||
: result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> UpdateShow(
|
|
||||||
Guid id,
|
|
||||||
Guid channelShowId,
|
|
||||||
UpdateChannelShowBody body,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(
|
|
||||||
new UpdateChannelShowCommand(
|
|
||||||
id,
|
|
||||||
channelShowId,
|
|
||||||
body.Weight,
|
|
||||||
body.BlockMode,
|
|
||||||
body.BlockValue,
|
|
||||||
body.IsEnabled,
|
|
||||||
body.PreferredWeightMultiplier,
|
|
||||||
body.PreferredHours ?? []
|
|
||||||
),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> RemoveShow(
|
|
||||||
Guid id,
|
|
||||||
Guid channelShowId,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(
|
|
||||||
new RemoveChannelShowCommand(id, channelShowId),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> AddAd(
|
|
||||||
Guid id,
|
|
||||||
AddChannelAdBody body,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(
|
|
||||||
new AddChannelAdCommand(id, body.MediaAssetId),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return result.IsSuccess
|
|
||||||
? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value))
|
|
||||||
: result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> RemoveAd(
|
|
||||||
Guid id,
|
|
||||||
Guid channelAdId,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(
|
|
||||||
new RemoveChannelAdCommand(id, channelAdId),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed record AddChannelShowBody(
|
|
||||||
Guid ShowId,
|
|
||||||
int Weight,
|
|
||||||
BlockMode BlockMode,
|
|
||||||
int BlockValue
|
|
||||||
);
|
|
||||||
|
|
||||||
public sealed record UpdateChannelShowBody(
|
|
||||||
int Weight,
|
|
||||||
BlockMode BlockMode,
|
|
||||||
int BlockValue,
|
|
||||||
bool IsEnabled,
|
|
||||||
int PreferredWeightMultiplier,
|
|
||||||
IReadOnlyList<HourWindowInput> PreferredHours
|
|
||||||
);
|
|
||||||
|
|
||||||
public sealed record AddChannelAdBody(Guid MediaAssetId);
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using TeleWave.Api.Common;
|
|
||||||
using TeleWave.Application.Broadcast;
|
|
||||||
using TeleWave.Application.Broadcast.CreateChannel;
|
|
||||||
using TeleWave.Application.Broadcast.GetChannel;
|
|
||||||
using TeleWave.Application.Broadcast.GetSchedule;
|
|
||||||
using TeleWave.Application.Broadcast.ListChannels;
|
|
||||||
using TeleWave.Application.Broadcast.RegenerateSchedule;
|
|
||||||
using TeleWave.Application.Broadcast.UpdateChannelSettings;
|
|
||||||
using TeleWave.Domain.Broadcast;
|
|
||||||
using TeleWave.Infrastructure.Identity;
|
|
||||||
|
|
||||||
namespace TeleWave.Api.Endpoints;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Админ-эндпоинты канала. Реализация разнесена по partial-файлам под-ресурсов:
|
|
||||||
/// <c>ChannelEndpoints.Shows.cs</c> (шоу+реклама), <c>ChannelEndpoints.Bumpers.cs</c>
|
|
||||||
/// (блоки/подблоки/файлы/preview), <c>ChannelEndpoints.Overrides.cs</c> (override'ы). Здесь —
|
|
||||||
/// регистрация всех маршрутов и хендлеры уровня канала (создание/список/настройки/расписание).
|
|
||||||
/// </summary>
|
|
||||||
public static partial class ChannelEndpoints
|
|
||||||
{
|
|
||||||
public static IEndpointRouteBuilder MapChannelEndpoints(this IEndpointRouteBuilder app)
|
|
||||||
{
|
|
||||||
var admin = app.MapGroup("/api/admin/channels")
|
|
||||||
.WithTags("Admin.Channels")
|
|
||||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
|
||||||
|
|
||||||
admin.MapPost("", CreateChannel).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
|
||||||
admin.MapGet("", ListChannels).Produces<IReadOnlyList<ChannelSummaryDto>>();
|
|
||||||
admin.MapGet("/{id:guid}", GetChannel).Produces<ChannelDto>();
|
|
||||||
admin
|
|
||||||
.MapPut("/{id:guid}/settings", UpdateSettings)
|
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
|
||||||
|
|
||||||
admin
|
|
||||||
.MapPost("/{id:guid}/shows", AddShow)
|
|
||||||
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
|
||||||
admin
|
|
||||||
.MapPut("/{id:guid}/shows/{channelShowId:guid}", UpdateShow)
|
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
|
||||||
admin
|
|
||||||
.MapDelete("/{id:guid}/shows/{channelShowId:guid}", RemoveShow)
|
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
|
||||||
|
|
||||||
admin
|
|
||||||
.MapPost("/{id:guid}/ads", AddAd)
|
|
||||||
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
|
||||||
admin
|
|
||||||
.MapDelete("/{id:guid}/ads/{channelAdId:guid}", RemoveAd)
|
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
|
||||||
|
|
||||||
admin
|
|
||||||
.MapPost("/{id:guid}/bumper/templates", AddBumperTemplate)
|
|
||||||
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
|
||||||
admin
|
|
||||||
.MapPut("/{id:guid}/bumper/templates/{templateId:guid}", UpdateBumperTemplate)
|
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
|
||||||
admin
|
|
||||||
.MapDelete("/{id:guid}/bumper/templates/{templateId:guid}", RemoveBumperTemplate)
|
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
|
||||||
admin
|
|
||||||
.MapPut("/{id:guid}/bumper/templates/{templateId:guid}/audio", UploadTemplateAudio)
|
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
|
||||||
admin
|
|
||||||
.MapDelete("/{id:guid}/bumper/templates/{templateId:guid}/audio", ClearTemplateAudio)
|
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
|
||||||
admin
|
|
||||||
.MapPut(
|
|
||||||
"/{id:guid}/bumper/templates/{templateId:guid}/background",
|
|
||||||
SetTemplateBackground
|
|
||||||
)
|
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
|
||||||
admin
|
|
||||||
.MapDelete(
|
|
||||||
"/{id:guid}/bumper/templates/{templateId:guid}/background",
|
|
||||||
ClearTemplateBackground
|
|
||||||
)
|
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
|
||||||
|
|
||||||
admin
|
|
||||||
.MapPost("/{id:guid}/bumper/templates/{templateId:guid}/preview", RenderPreview)
|
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
|
||||||
admin.MapGet(
|
|
||||||
"/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/index.m3u8",
|
|
||||||
PreviewPlaylist
|
|
||||||
);
|
|
||||||
admin.MapGet(
|
|
||||||
"/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/{file}",
|
|
||||||
PreviewSegment
|
|
||||||
);
|
|
||||||
|
|
||||||
admin
|
|
||||||
.MapPost("/{id:guid}/bumper/templates/{templateId:guid}/variants", AddBumperVariant)
|
|
||||||
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
|
||||||
admin
|
|
||||||
.MapPut(
|
|
||||||
"/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}",
|
|
||||||
UpdateBumperVariant
|
|
||||||
)
|
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
|
||||||
admin
|
|
||||||
.MapDelete(
|
|
||||||
"/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}",
|
|
||||||
RemoveBumperVariant
|
|
||||||
)
|
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
|
||||||
|
|
||||||
admin
|
|
||||||
.MapPost("/{id:guid}/overrides", CreateOverride)
|
|
||||||
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
|
||||||
admin
|
|
||||||
.MapDelete("/{id:guid}/overrides/{overrideId:guid}", DeleteOverride)
|
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
|
||||||
|
|
||||||
admin.MapPost("/{id:guid}/regenerate", Regenerate).Produces(StatusCodes.Status204NoContent);
|
|
||||||
admin
|
|
||||||
.MapGet("/{id:guid}/schedule", GetSchedule)
|
|
||||||
.Produces<IReadOnlyList<ScheduleEntryDto>>();
|
|
||||||
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> CreateChannel(
|
|
||||||
CreateChannelCommand command,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(command, cancellationToken);
|
|
||||||
return result.IsSuccess
|
|
||||||
? Results.Created(
|
|
||||||
$"/api/admin/channels/{result.Value}",
|
|
||||||
new CreatedIdResponse(result.Value)
|
|
||||||
)
|
|
||||||
: result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> ListChannels(
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(new ListChannelsQuery(), cancellationToken);
|
|
||||||
return Results.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> GetChannel(
|
|
||||||
Guid id,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(new GetChannelQuery(id), cancellationToken);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> UpdateSettings(
|
|
||||||
Guid id,
|
|
||||||
UpdateChannelSettingsBody body,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(
|
|
||||||
new UpdateChannelSettingsCommand(
|
|
||||||
id,
|
|
||||||
body.Name,
|
|
||||||
body.IsEnabled,
|
|
||||||
body.AdInsertion,
|
|
||||||
body.AdsPerBreak,
|
|
||||||
body.BumpersEnabled,
|
|
||||||
body.Bumper,
|
|
||||||
body.FillerAssetId
|
|
||||||
),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> Regenerate(
|
|
||||||
Guid id,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(new RegenerateChannelScheduleCommand(id), cancellationToken);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> GetSchedule(
|
|
||||||
Guid id,
|
|
||||||
DateTimeOffset? from,
|
|
||||||
DateTimeOffset? to,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var fromUtc = from ?? DateTimeOffset.UtcNow;
|
|
||||||
var toUtc = to ?? fromUtc.AddDays(1);
|
|
||||||
var result = await sender.Send(
|
|
||||||
new GetChannelScheduleQuery(id, fromUtc, toUtc),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed record UpdateChannelSettingsBody(
|
|
||||||
string Name,
|
|
||||||
bool IsEnabled,
|
|
||||||
AdInsertion AdInsertion,
|
|
||||||
int AdsPerBreak,
|
|
||||||
bool BumpersEnabled,
|
|
||||||
BumperSettingsInput Bumper,
|
|
||||||
Guid? FillerAssetId
|
|
||||||
);
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using TeleWave.Api.Common;
|
|
||||||
using TeleWave.Application.Common.Interfaces;
|
|
||||||
using TeleWave.Application.Images;
|
|
||||||
using TeleWave.Application.Images.DeleteImage;
|
|
||||||
using TeleWave.Application.Images.GetImageFile;
|
|
||||||
using TeleWave.Application.Images.ListImages;
|
|
||||||
using TeleWave.Application.Images.UploadImage;
|
|
||||||
using TeleWave.Domain.Images;
|
|
||||||
using TeleWave.Infrastructure.Identity;
|
|
||||||
|
|
||||||
namespace TeleWave.Api.Endpoints;
|
|
||||||
|
|
||||||
public static class ImageEndpoints
|
|
||||||
{
|
|
||||||
private const long MaxBytes = 50L * 1024 * 1024; // 50 МБ
|
|
||||||
|
|
||||||
private static readonly IReadOnlySet<string> AllowedExtensions = new HashSet<string>(
|
|
||||||
StringComparer.OrdinalIgnoreCase
|
|
||||||
)
|
|
||||||
{
|
|
||||||
".jpg",
|
|
||||||
".jpeg",
|
|
||||||
".png",
|
|
||||||
".webp",
|
|
||||||
".bmp",
|
|
||||||
".gif",
|
|
||||||
};
|
|
||||||
|
|
||||||
public static IEndpointRouteBuilder MapImageEndpoints(this IEndpointRouteBuilder app)
|
|
||||||
{
|
|
||||||
var admin = app.MapGroup("/api/admin/images")
|
|
||||||
.WithTags("Admin.Images")
|
|
||||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
|
||||||
|
|
||||||
admin.MapGet("", ListImages).Produces<IReadOnlyList<ImageDto>>();
|
|
||||||
admin.MapPost("", UploadImage).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
|
||||||
admin.MapDelete("/{id:guid}", DeleteImage).Produces(StatusCodes.Status204NoContent);
|
|
||||||
|
|
||||||
// Публичная отдача файла (для <img> у зрителя и в админке).
|
|
||||||
app.MapGet("/api/images/{id:guid}", ServeImage).WithTags("Images");
|
|
||||||
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> ListImages(
|
|
||||||
ImageCategory category,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(new ListImagesQuery(category), cancellationToken);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> UploadImage(
|
|
||||||
ImageCategory category,
|
|
||||||
string fileName,
|
|
||||||
HttpRequest request,
|
|
||||||
IImageStore storage,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
if (request.ContentLength is > MaxBytes or 0 or null)
|
|
||||||
return ImageErrors.InvalidFile.ToProblem();
|
|
||||||
var ext = Path.GetExtension(fileName).ToLowerInvariant();
|
|
||||||
if (!AllowedExtensions.Contains(ext))
|
|
||||||
return ImageErrors.InvalidFile.ToProblem();
|
|
||||||
|
|
||||||
var created = await sender.Send(
|
|
||||||
new UploadImageCommand(category, ext, fileName),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
if (!created.IsSuccess)
|
|
||||||
return created.ToHttpResult();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await storage.SaveAsync(created.Value, ext, request.Body, cancellationToken);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Файл не сохранился — не оставляем висячую запись реестра.
|
|
||||||
await sender.Send(new DeleteImageCommand(created.Value), cancellationToken);
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Results.Created(
|
|
||||||
$"/api/images/{created.Value}",
|
|
||||||
new CreatedIdResponse(created.Value)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> DeleteImage(
|
|
||||||
Guid id,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(new DeleteImageCommand(id), cancellationToken);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> ServeImage(
|
|
||||||
Guid id,
|
|
||||||
HttpResponse response,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(new GetImageFileQuery(id), cancellationToken);
|
|
||||||
if (!result.IsSuccess)
|
|
||||||
return Results.NotFound();
|
|
||||||
|
|
||||||
response.Headers.CacheControl = "public, max-age=86400";
|
|
||||||
return Results.File(result.Value, ContentTypeFor(Path.GetExtension(result.Value)));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string ContentTypeFor(string extension) =>
|
|
||||||
extension.ToLowerInvariant() switch
|
|
||||||
{
|
|
||||||
".png" => "image/png",
|
|
||||||
".webp" => "image/webp",
|
|
||||||
".gif" => "image/gif",
|
|
||||||
".bmp" => "image/bmp",
|
|
||||||
_ => "image/jpeg",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using TeleWave.Api.Common;
|
|
||||||
using TeleWave.Application.Common.Interfaces;
|
|
||||||
using TeleWave.Application.Common.Models;
|
|
||||||
using TeleWave.Application.Media;
|
|
||||||
using TeleWave.Application.Media.Delete;
|
|
||||||
using TeleWave.Application.Media.GetMedia;
|
|
||||||
using TeleWave.Application.Media.ListMedia;
|
|
||||||
using TeleWave.Application.Media.Register;
|
|
||||||
using TeleWave.Domain.Media;
|
|
||||||
using TeleWave.Infrastructure.Identity;
|
|
||||||
using TeleWave.Infrastructure.Media;
|
|
||||||
|
|
||||||
namespace TeleWave.Api.Endpoints;
|
|
||||||
|
|
||||||
public static class MediaEndpoints
|
|
||||||
{
|
|
||||||
public static IEndpointRouteBuilder MapMediaEndpoints(this IEndpointRouteBuilder app)
|
|
||||||
{
|
|
||||||
var admin = app.MapGroup("/api/admin/media")
|
|
||||||
.WithTags("Admin.Media")
|
|
||||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
|
||||||
|
|
||||||
admin.MapPost("", Upload).Produces<UploadMediaResponse>(StatusCodes.Status201Created);
|
|
||||||
admin.MapGet("", List).Produces<PagedList<MediaAssetDto>>();
|
|
||||||
admin.MapGet("/{id:guid}", Get).Produces<MediaAssetDto>();
|
|
||||||
admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent);
|
|
||||||
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Потоковая загрузка: тело запроса — сырые байты файла, имя передаётся в query «fileName».
|
|
||||||
/// Файл стримится на диск без буферизации в память, затем регистрируется и уходит в обработку.
|
|
||||||
/// </summary>
|
|
||||||
private static async Task<IResult> Upload(
|
|
||||||
string fileName,
|
|
||||||
HttpRequest request,
|
|
||||||
IMediaStorage storage,
|
|
||||||
IMediaProcessingQueue queue,
|
|
||||||
ISender sender,
|
|
||||||
IOptions<MediaOptions> mediaOptions,
|
|
||||||
IOptions<StorageOptions> storageOptions,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(fileName))
|
|
||||||
return Results.Problem(
|
|
||||||
title: MediaErrors.EmptyFileName.Code,
|
|
||||||
detail: MediaErrors.EmptyFileName.Message,
|
|
||||||
statusCode: StatusCodes.Status400BadRequest
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!MediaFormats.IsAllowed(fileName))
|
|
||||||
return Results.Problem(
|
|
||||||
title: MediaErrors.UnsupportedFormat.Code,
|
|
||||||
detail: MediaErrors.UnsupportedFormat.Message,
|
|
||||||
statusCode: StatusCodes.Status400BadRequest
|
|
||||||
);
|
|
||||||
|
|
||||||
var contentLength = request.ContentLength ?? 0;
|
|
||||||
if (contentLength > mediaOptions.Value.MaxUploadBytes)
|
|
||||||
return Results.Problem(
|
|
||||||
title: MediaErrors.FileTooLarge.Code,
|
|
||||||
detail: MediaErrors.FileTooLarge.Message,
|
|
||||||
statusCode: StatusCodes.Status400BadRequest
|
|
||||||
);
|
|
||||||
|
|
||||||
var free = storage.GetAvailableFreeSpaceBytes();
|
|
||||||
if (free - contentLength < storageOptions.Value.MinFreeSpaceBytes)
|
|
||||||
return Results.Problem(
|
|
||||||
title: MediaErrors.InsufficientStorage.Code,
|
|
||||||
detail: MediaErrors.InsufficientStorage.Message,
|
|
||||||
statusCode: StatusCodes.Status409Conflict
|
|
||||||
);
|
|
||||||
|
|
||||||
var extension = Path.GetExtension(fileName);
|
|
||||||
var token = await storage.SaveUploadAsync(request.Body, extension, cancellationToken);
|
|
||||||
|
|
||||||
var result = await sender.Send(
|
|
||||||
new RegisterMediaAssetCommand(token, MediaSource.Upload, fileName),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
if (!result.IsSuccess)
|
|
||||||
{
|
|
||||||
storage.DeleteUpload(token);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
queue.Enqueue(result.Value);
|
|
||||||
return Results.Created(
|
|
||||||
$"/api/admin/media/{result.Value}",
|
|
||||||
new UploadMediaResponse(result.Value)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> List(
|
|
||||||
int page,
|
|
||||||
int pageSize,
|
|
||||||
MediaAssetStatus[]? status,
|
|
||||||
string? search,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(
|
|
||||||
new ListMediaAssetsQuery(
|
|
||||||
page <= 0 ? 1 : page,
|
|
||||||
pageSize <= 0 ? 20 : pageSize,
|
|
||||||
status ?? [],
|
|
||||||
search
|
|
||||||
),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return Results.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> Get(
|
|
||||||
Guid id,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(new GetMediaAssetQuery(id), cancellationToken);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> Delete(
|
|
||||||
Guid id,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(new DeleteMediaAssetCommand(id), cancellationToken);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed record UploadMediaResponse(Guid Id);
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using TeleWave.Api.Common;
|
|
||||||
using TeleWave.Application.Common.Interfaces;
|
|
||||||
using TeleWave.Application.Images.DeleteImage;
|
|
||||||
using TeleWave.Application.Images.UploadImage;
|
|
||||||
using TeleWave.Application.Metadata;
|
|
||||||
using TeleWave.Application.Metadata.ApplyShowMetadata;
|
|
||||||
using TeleWave.Application.Metadata.ClearShowMetadata;
|
|
||||||
using TeleWave.Application.Metadata.GetProviders;
|
|
||||||
using TeleWave.Application.Metadata.RefreshEpisodes;
|
|
||||||
using TeleWave.Application.Metadata.SearchShows;
|
|
||||||
using TeleWave.Application.Metadata.SetShowPoster;
|
|
||||||
using TeleWave.Application.Metadata.UpdateShowMetadata;
|
|
||||||
using TeleWave.Domain.Images;
|
|
||||||
using TeleWave.Infrastructure.Identity;
|
|
||||||
|
|
||||||
namespace TeleWave.Api.Endpoints;
|
|
||||||
|
|
||||||
public static class MetadataEndpoints
|
|
||||||
{
|
|
||||||
private const long MaxPosterBytes = 10L * 1024 * 1024; // 10 МБ
|
|
||||||
|
|
||||||
private static readonly IReadOnlySet<string> PosterExtensions = new HashSet<string>(
|
|
||||||
StringComparer.OrdinalIgnoreCase
|
|
||||||
)
|
|
||||||
{
|
|
||||||
".jpg",
|
|
||||||
".jpeg",
|
|
||||||
".png",
|
|
||||||
".webp",
|
|
||||||
};
|
|
||||||
|
|
||||||
public static IEndpointRouteBuilder MapMetadataEndpoints(this IEndpointRouteBuilder app)
|
|
||||||
{
|
|
||||||
var admin = app.MapGroup("/api/admin/metadata")
|
|
||||||
.WithTags("Admin.Metadata")
|
|
||||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
|
||||||
|
|
||||||
admin.MapGet("/providers", GetProviders).Produces<IReadOnlyList<string>>();
|
|
||||||
admin.MapGet("/search", Search).Produces<IReadOnlyList<MetadataCandidate>>();
|
|
||||||
admin.MapPost("/shows/{showId:guid}/apply", Apply).Produces(StatusCodes.Status204NoContent);
|
|
||||||
admin.MapPut("/shows/{showId:guid}", Update).Produces(StatusCodes.Status204NoContent);
|
|
||||||
admin.MapDelete("/shows/{showId:guid}", Clear).Produces(StatusCodes.Status204NoContent);
|
|
||||||
admin
|
|
||||||
.MapPut("/shows/{showId:guid}/poster", UploadPoster)
|
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
|
||||||
admin
|
|
||||||
.MapPut("/shows/{showId:guid}/poster-image", SetPosterImage)
|
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
|
||||||
admin.MapPost("/shows/{showId:guid}/refresh-episodes", RefreshEpisodes).Produces<int>();
|
|
||||||
|
|
||||||
// Постеры шоу и кадры серий теперь в общем реестре и отдаются по /api/images/{id}.
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> GetProviders(
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var providers = await sender.Send(new GetMetadataProvidersQuery(), cancellationToken);
|
|
||||||
return Results.Ok(providers);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> Search(
|
|
||||||
string provider,
|
|
||||||
string query,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(
|
|
||||||
new SearchShowMetadataQuery(provider, query),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> Apply(
|
|
||||||
Guid showId,
|
|
||||||
ApplyMetadataBody body,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(
|
|
||||||
new ApplyShowMetadataCommand(showId, body.Provider, body.ExternalId),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> Update(
|
|
||||||
Guid showId,
|
|
||||||
UpdateMetadataBody body,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(
|
|
||||||
new UpdateShowMetadataCommand(showId, body.Description, body.Year),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> Clear(
|
|
||||||
Guid showId,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(new ClearShowMetadataCommand(showId), cancellationToken);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> UploadPoster(
|
|
||||||
Guid showId,
|
|
||||||
string fileName,
|
|
||||||
HttpRequest request,
|
|
||||||
IImageStore imageStore,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var ext = Path.GetExtension(fileName).ToLowerInvariant();
|
|
||||||
if (
|
|
||||||
request.ContentLength is > MaxPosterBytes or 0 or null
|
|
||||||
|| !PosterExtensions.Contains(ext)
|
|
||||||
)
|
|
||||||
return MetadataErrors.InvalidPoster.ToProblem();
|
|
||||||
|
|
||||||
// Регистрируем постер в общем реестре (категория ShowPoster) и привязываем к шоу.
|
|
||||||
var created = await sender.Send(
|
|
||||||
new UploadImageCommand(ImageCategory.ShowPoster, ext, fileName),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
if (!created.IsSuccess)
|
|
||||||
return created.ToHttpResult();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await imageStore.SaveAsync(created.Value, ext, request.Body, cancellationToken);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
await sender.Send(new DeleteImageCommand(created.Value), cancellationToken);
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = await sender.Send(
|
|
||||||
new SetShowPosterCommand(showId, created.Value),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
if (!result.IsSuccess)
|
|
||||||
await sender.Send(new DeleteImageCommand(created.Value), cancellationToken);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> SetPosterImage(
|
|
||||||
Guid showId,
|
|
||||||
SetPosterImageBody body,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(
|
|
||||||
new SetShowPosterCommand(showId, body.ImageId),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> RefreshEpisodes(
|
|
||||||
Guid showId,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(
|
|
||||||
new RefreshShowEpisodesMetadataCommand(showId),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed record ApplyMetadataBody(string Provider, string ExternalId);
|
|
||||||
|
|
||||||
public sealed record UpdateMetadataBody(string? Description, int? Year);
|
|
||||||
|
|
||||||
public sealed record SetPosterImageBody(Guid? ImageId);
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
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);
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using TeleWave.Api.Common;
|
|
||||||
using TeleWave.Application.Settings;
|
|
||||||
using TeleWave.Application.Settings.GetSiteSettings;
|
|
||||||
using TeleWave.Application.Settings.UpdateSiteSettings;
|
|
||||||
using TeleWave.Infrastructure.Identity;
|
|
||||||
|
|
||||||
namespace TeleWave.Api.Endpoints;
|
|
||||||
|
|
||||||
public static class SettingsEndpoints
|
|
||||||
{
|
|
||||||
public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder app)
|
|
||||||
{
|
|
||||||
var admin = app.MapGroup("/api/admin/settings")
|
|
||||||
.WithTags("Admin.Settings")
|
|
||||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
|
||||||
|
|
||||||
admin.MapGet("", GetSettings).Produces<SiteSettingsDto>();
|
|
||||||
admin.MapPut("", UpdateSettings).Produces(StatusCodes.Status204NoContent);
|
|
||||||
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> GetSettings(
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var settings = await sender.Send(new GetSiteSettingsQuery(), cancellationToken);
|
|
||||||
return Results.Ok(settings);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> UpdateSettings(
|
|
||||||
UpdateSiteSettingsBody body,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(
|
|
||||||
new UpdateSiteSettingsCommand(body.RegistrationEnabled),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed record UpdateSiteSettingsBody(bool RegistrationEnabled);
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using TeleWave.Api.Common;
|
|
||||||
using TeleWave.Application.Library;
|
|
||||||
using TeleWave.Application.Library.AddEpisode;
|
|
||||||
using TeleWave.Application.Library.CreateShow;
|
|
||||||
using TeleWave.Application.Library.DeleteShow;
|
|
||||||
using TeleWave.Application.Library.GetShow;
|
|
||||||
using TeleWave.Application.Library.ListShows;
|
|
||||||
using TeleWave.Application.Library.RemoveEpisode;
|
|
||||||
using TeleWave.Application.Library.RenameShow;
|
|
||||||
using TeleWave.Application.Library.SetShowOriginalName;
|
|
||||||
using TeleWave.Infrastructure.Identity;
|
|
||||||
|
|
||||||
namespace TeleWave.Api.Endpoints;
|
|
||||||
|
|
||||||
public static class ShowEndpoints
|
|
||||||
{
|
|
||||||
public static IEndpointRouteBuilder MapShowEndpoints(this IEndpointRouteBuilder app)
|
|
||||||
{
|
|
||||||
var admin = app.MapGroup("/api/admin/shows")
|
|
||||||
.WithTags("Admin.Shows")
|
|
||||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
|
||||||
|
|
||||||
admin.MapPost("", CreateShow).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
|
||||||
admin.MapGet("", ListShows).Produces<IReadOnlyList<ShowSummaryDto>>();
|
|
||||||
admin.MapGet("/{id:guid}", GetShow).Produces<ShowDto>();
|
|
||||||
admin.MapPut("/{id:guid}/name", Rename).Produces(StatusCodes.Status204NoContent);
|
|
||||||
admin
|
|
||||||
.MapPut("/{id:guid}/original-name", SetOriginalName)
|
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
|
||||||
admin.MapDelete("/{id:guid}", DeleteShow).Produces(StatusCodes.Status204NoContent);
|
|
||||||
admin
|
|
||||||
.MapPost("/{id:guid}/episodes", AddEpisode)
|
|
||||||
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
|
||||||
admin
|
|
||||||
.MapDelete("/{id:guid}/episodes/{episodeId:guid}", RemoveEpisode)
|
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
|
||||||
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> CreateShow(
|
|
||||||
CreateShowCommand command,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(command, cancellationToken);
|
|
||||||
return result.IsSuccess
|
|
||||||
? Results.Created(
|
|
||||||
$"/api/admin/shows/{result.Value}",
|
|
||||||
new CreatedIdResponse(result.Value)
|
|
||||||
)
|
|
||||||
: result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> ListShows(
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(new ListShowsQuery(), cancellationToken);
|
|
||||||
return Results.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> GetShow(
|
|
||||||
Guid id,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(new GetShowQuery(id), cancellationToken);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> Rename(
|
|
||||||
Guid id,
|
|
||||||
RenameShowBody body,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(new RenameShowCommand(id, body.Name), cancellationToken);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> SetOriginalName(
|
|
||||||
Guid id,
|
|
||||||
SetShowOriginalNameBody body,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(
|
|
||||||
new SetShowOriginalNameCommand(id, body.OriginalName),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> DeleteShow(
|
|
||||||
Guid id,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(new DeleteShowCommand(id), cancellationToken);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> AddEpisode(
|
|
||||||
Guid id,
|
|
||||||
AddEpisodeBody body,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(
|
|
||||||
new AddEpisodeCommand(id, body.MediaAssetId),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return result.IsSuccess
|
|
||||||
? Results.Created($"/api/admin/shows/{id}", new CreatedIdResponse(result.Value))
|
|
||||||
: result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> RemoveEpisode(
|
|
||||||
Guid id,
|
|
||||||
Guid episodeId,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(new RemoveEpisodeCommand(id, episodeId), cancellationToken);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed record AddEpisodeBody(Guid MediaAssetId);
|
|
||||||
|
|
||||||
public sealed record RenameShowBody(string Name);
|
|
||||||
|
|
||||||
public sealed record SetShowOriginalNameBody(string? OriginalName);
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
using System.Globalization;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using LiteCqrs;
|
|
||||||
using Microsoft.Extensions.Hosting;
|
|
||||||
using TeleWave.Api.Common;
|
|
||||||
using TeleWave.Application.Common.Interfaces;
|
|
||||||
using TeleWave.Application.Streaming;
|
|
||||||
using TeleWave.Application.Streaming.GetLivePlaylist;
|
|
||||||
using TeleWave.Application.Streaming.GetPublicEpg;
|
|
||||||
using TeleWave.Application.Streaming.ListPublicChannels;
|
|
||||||
using TeleWave.Infrastructure.Media;
|
|
||||||
using TeleWave.Infrastructure.Streaming;
|
|
||||||
|
|
||||||
namespace TeleWave.Api.Endpoints;
|
|
||||||
|
|
||||||
public static class StreamingEndpoints
|
|
||||||
{
|
|
||||||
private const string StreamCookieName = "tw_stream";
|
|
||||||
private static readonly Regex SegmentFileName = new(@"^seg\d{1,6}\.ts$", RegexOptions.Compiled);
|
|
||||||
|
|
||||||
public static IEndpointRouteBuilder MapStreamingEndpoints(this IEndpointRouteBuilder app)
|
|
||||||
{
|
|
||||||
// Публичный API канала (Bearer): список, EPG, выдача stream-cookie.
|
|
||||||
var channels = app.MapGroup("/api/channels").WithTags("Channels").RequireAuthorization();
|
|
||||||
channels.MapGet("", ListChannels).Produces<IReadOnlyList<PublicChannelDto>>();
|
|
||||||
channels.MapPost("/{slug}/watch", Watch).Produces(StatusCodes.Status204NoContent);
|
|
||||||
channels.MapGet("/{slug}/epg", Epg);
|
|
||||||
|
|
||||||
// Раздача эфира (cookie tw_stream): плейлист и сегменты — их грузит <video>/hls.js.
|
|
||||||
app.MapGet("/api/channels/{slug}/live.m3u8", LivePlaylist).WithTags("Streaming");
|
|
||||||
app.MapGet("/api/stream/{assetId:guid}/{file}", Segment).WithTags("Streaming");
|
|
||||||
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> ListChannels(
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var result = await sender.Send(new ListPublicChannelsQuery(), cancellationToken);
|
|
||||||
return Results.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IResult Watch(
|
|
||||||
string slug,
|
|
||||||
ICurrentUser currentUser,
|
|
||||||
StreamTokenService tokens,
|
|
||||||
HttpRequest request,
|
|
||||||
HttpResponse response,
|
|
||||||
IHostEnvironment env
|
|
||||||
)
|
|
||||||
{
|
|
||||||
if (currentUser.UserId is not { } userId)
|
|
||||||
return Results.Unauthorized();
|
|
||||||
|
|
||||||
var (token, expiresAt) = tokens.Issue(userId);
|
|
||||||
response.Cookies.Append(
|
|
||||||
StreamCookieName,
|
|
||||||
token,
|
|
||||||
new CookieOptions
|
|
||||||
{
|
|
||||||
HttpOnly = true,
|
|
||||||
// Вне Development — всегда Secure (прод за внешним TLS-прокси; request.IsHttps ненадёжен
|
|
||||||
// при неполной настройке ForwardedHeaders). См. UseSecureCookie в AuthEndpoints.
|
|
||||||
Secure = !env.IsDevelopment() || request.IsHttps,
|
|
||||||
SameSite = SameSiteMode.Strict,
|
|
||||||
Path = "/api",
|
|
||||||
Expires = expiresAt,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return Results.NoContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> Epg(
|
|
||||||
string slug,
|
|
||||||
DateTimeOffset? from,
|
|
||||||
DateTimeOffset? to,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var fromUtc = from ?? DateTimeOffset.UtcNow;
|
|
||||||
var toUtc = to ?? fromUtc.AddHours(12);
|
|
||||||
var result = await sender.Send(
|
|
||||||
new GetPublicEpgQuery(slug, fromUtc, toUtc),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> LivePlaylist(
|
|
||||||
string slug,
|
|
||||||
HttpRequest request,
|
|
||||||
HttpResponse response,
|
|
||||||
StreamTokenService tokens,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
if (!tokens.Validate(request.Cookies[StreamCookieName]))
|
|
||||||
return Results.Unauthorized();
|
|
||||||
|
|
||||||
var result = await sender.Send(
|
|
||||||
new GetLivePlaylistQuery(slug, DateTimeOffset.UtcNow),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
if (!result.IsSuccess)
|
|
||||||
return result.ToHttpResult();
|
|
||||||
if (result.Value.Segments.Count == 0)
|
|
||||||
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
|
||||||
|
|
||||||
response.Headers.CacheControl = "no-cache";
|
|
||||||
return Results.Text(Render(result.Value), "application/vnd.apple.mpegurl");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IResult Segment(
|
|
||||||
Guid assetId,
|
|
||||||
string file,
|
|
||||||
HttpRequest request,
|
|
||||||
HttpResponse response,
|
|
||||||
StreamTokenService tokens,
|
|
||||||
MediaPathResolver paths
|
|
||||||
)
|
|
||||||
{
|
|
||||||
if (!tokens.Validate(request.Cookies[StreamCookieName]))
|
|
||||||
return Results.Unauthorized();
|
|
||||||
if (!SegmentFileName.IsMatch(file))
|
|
||||||
return Results.NotFound();
|
|
||||||
|
|
||||||
string path;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
path = paths.SegmentPath(assetId, file);
|
|
||||||
}
|
|
||||||
catch (UnauthorizedAccessException)
|
|
||||||
{
|
|
||||||
return Results.NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!File.Exists(path))
|
|
||||||
return Results.NotFound();
|
|
||||||
|
|
||||||
response.Headers.CacheControl = "public, max-age=31536000, immutable";
|
|
||||||
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string Render(LivePlaylistDto playlist)
|
|
||||||
{
|
|
||||||
var extinf = playlist.TargetDuration.ToString("F6", CultureInfo.InvariantCulture);
|
|
||||||
var sb = new StringBuilder();
|
|
||||||
sb.Append("#EXTM3U\n");
|
|
||||||
sb.Append("#EXT-X-VERSION:3\n");
|
|
||||||
sb.Append(
|
|
||||||
CultureInfo.InvariantCulture,
|
|
||||||
$"#EXT-X-TARGETDURATION:{playlist.TargetDuration}\n"
|
|
||||||
);
|
|
||||||
sb.Append(
|
|
||||||
CultureInfo.InvariantCulture,
|
|
||||||
$"#EXT-X-MEDIA-SEQUENCE:{playlist.MediaSequence}\n"
|
|
||||||
);
|
|
||||||
|
|
||||||
foreach (var segment in playlist.Segments)
|
|
||||||
{
|
|
||||||
if (segment.Discontinuity)
|
|
||||||
sb.Append("#EXT-X-DISCONTINUITY\n");
|
|
||||||
sb.Append(CultureInfo.InvariantCulture, $"#EXTINF:{extinf},\n");
|
|
||||||
sb.Append(
|
|
||||||
CultureInfo.InvariantCulture,
|
|
||||||
$"/api/stream/{segment.AssetId:N}/seg{segment.LocalIndex:D5}.ts\n"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return sb.ToString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
using System.Net;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
using System.Threading.RateLimiting;
|
|
||||||
using Microsoft.AspNetCore.HttpOverrides;
|
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
|
||||||
using Scalar.AspNetCore;
|
|
||||||
using Serilog;
|
|
||||||
using TeleWave.Api.Common;
|
|
||||||
using TeleWave.Api.Endpoints;
|
|
||||||
using TeleWave.Application;
|
|
||||||
using TeleWave.Infrastructure;
|
|
||||||
using TeleWave.Infrastructure.Identity;
|
|
||||||
using TeleWave.Infrastructure.Persistence;
|
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
|
||||||
|
|
||||||
// Загрузка медиа стримится на диск; поднимаем лимит тела запроса Kestrel до максимума загрузки
|
|
||||||
// (иначе дефолтные ~30 МБ рубят большие файлы). Собственный контроль размера — в MediaEndpoints.
|
|
||||||
builder.WebHost.ConfigureKestrel(options =>
|
|
||||||
options.Limits.MaxRequestBodySize =
|
|
||||||
builder.Configuration.GetValue<long?>("Media:MaxUploadBytes") ?? 20L * 1024 * 1024 * 1024
|
|
||||||
);
|
|
||||||
|
|
||||||
// Структурное логирование (Serilog), конфигурация из appsettings/env.
|
|
||||||
builder.Services.AddSerilog(
|
|
||||||
(services, configuration) =>
|
|
||||||
configuration
|
|
||||||
.ReadFrom.Configuration(builder.Configuration)
|
|
||||||
.ReadFrom.Services(services)
|
|
||||||
.Enrich.FromLogContext()
|
|
||||||
);
|
|
||||||
|
|
||||||
// За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера), но ТОЛЬКО от явно
|
|
||||||
// перечисленных адресов/сетей прокси — иначе клиент может подделать свой IP/схему напрямую, минуя
|
|
||||||
// прокси. По умолчанию (без конфигурации) остаётся дефолт ASP.NET Core — доверие только loopback;
|
|
||||||
// для прод-топологии прокси задаётся через ForwardedHeaders__KnownProxies/KnownNetworks (см. .env.example).
|
|
||||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
|
||||||
{
|
|
||||||
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
|
||||||
|
|
||||||
foreach (
|
|
||||||
var proxy in builder
|
|
||||||
.Configuration.GetSection("ForwardedHeaders:KnownProxies")
|
|
||||||
.Get<string[]>()
|
|
||||||
?? []
|
|
||||||
)
|
|
||||||
options.KnownProxies.Add(IPAddress.Parse(proxy));
|
|
||||||
|
|
||||||
foreach (
|
|
||||||
var network in builder
|
|
||||||
.Configuration.GetSection("ForwardedHeaders:KnownNetworks")
|
|
||||||
.Get<string[]>()
|
|
||||||
?? []
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var parts = network.Split('/');
|
|
||||||
options.KnownIPNetworks.Add(
|
|
||||||
new System.Net.IPNetwork(IPAddress.Parse(parts[0]), int.Parse(parts[1]))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
builder.Services.AddHttpContextAccessor();
|
|
||||||
builder.Services.AddApplication();
|
|
||||||
builder.Services.AddInfrastructure(builder.Configuration);
|
|
||||||
|
|
||||||
var authPermitLimit = builder.Configuration.GetValue("RateLimiting:AuthPermitLimit", 20);
|
|
||||||
builder.Services.AddRateLimiter(options =>
|
|
||||||
{
|
|
||||||
// Партиционируем по IP клиента (реальный адрес доступен после UseForwardedHeaders): единый
|
|
||||||
// непартиционированный лимит превращается в DoS — один клиент исчерпывает окно логина для всех.
|
|
||||||
options.AddPolicy(
|
|
||||||
RateLimiting.AuthPolicy,
|
|
||||||
httpContext =>
|
|
||||||
RateLimitPartition.GetFixedWindowLimiter(
|
|
||||||
httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
|
||||||
_ => new FixedWindowRateLimiterOptions
|
|
||||||
{
|
|
||||||
PermitLimit = authPermitLimit,
|
|
||||||
Window = TimeSpan.FromMinutes(1),
|
|
||||||
QueueLimit = 0,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Энумы сериализуются строками, не числами — самодокументируемый JSON.
|
|
||||||
builder.Services.ConfigureHttpJsonOptions(options =>
|
|
||||||
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())
|
|
||||||
);
|
|
||||||
|
|
||||||
builder.Services.AddProblemDetails();
|
|
||||||
builder.Services.AddOpenApi();
|
|
||||||
builder.Services.AddHealthChecks().AddDbContextCheck<AppDbContext>();
|
|
||||||
|
|
||||||
var app = builder.Build();
|
|
||||||
|
|
||||||
// Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте.
|
|
||||||
await app.Services.ApplyMigrationsAsync();
|
|
||||||
await app.Services.RelocateLegacyImagesAsync();
|
|
||||||
await app.Services.SeedDataAsync();
|
|
||||||
|
|
||||||
app.UseForwardedHeaders();
|
|
||||||
app.UseSerilogRequestLogging();
|
|
||||||
app.UseExceptionHandler();
|
|
||||||
|
|
||||||
app.UseRateLimiter();
|
|
||||||
|
|
||||||
app.UseAuthentication();
|
|
||||||
app.UseAuthorization();
|
|
||||||
|
|
||||||
app.MapOpenApi();
|
|
||||||
app.MapScalarApiReference();
|
|
||||||
|
|
||||||
app.MapHealthChecks("/health");
|
|
||||||
|
|
||||||
app.MapAuthEndpoints();
|
|
||||||
app.MapRoleEndpoints();
|
|
||||||
app.MapAdminUserEndpoints();
|
|
||||||
app.MapMediaEndpoints();
|
|
||||||
app.MapShowEndpoints();
|
|
||||||
app.MapChannelEndpoints();
|
|
||||||
app.MapStreamingEndpoints();
|
|
||||||
app.MapMaintenanceEndpoints();
|
|
||||||
app.MapSettingsEndpoints();
|
|
||||||
app.MapMetadataEndpoints();
|
|
||||||
app.MapImageEndpoints();
|
|
||||||
|
|
||||||
// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов.
|
|
||||||
app.UseDefaultFiles();
|
|
||||||
app.UseStaticFiles();
|
|
||||||
app.MapFallbackToFile("index.html");
|
|
||||||
|
|
||||||
app.Run();
|
|
||||||
|
|
||||||
/// <summary>Делает неявный класс Program доступным для WebApplicationFactory<Program> в интеграционных тестах.</summary>
|
|
||||||
public partial class Program;
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
|
||||||
"profiles": {
|
|
||||||
"http": {
|
|
||||||
"commandName": "Project",
|
|
||||||
"dotnetRunMessages": true,
|
|
||||||
"launchBrowser": false,
|
|
||||||
"applicationUrl": "http://localhost:8080",
|
|
||||||
"environmentVariables": {
|
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"Logging": {
|
|
||||||
"LogLevel": {
|
|
||||||
"Default": "Information",
|
|
||||||
"Microsoft.AspNetCore": "Warning"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"AdminSeed": {
|
|
||||||
"Username": "admin",
|
|
||||||
"Password": "Passw0rd!Dev"
|
|
||||||
},
|
|
||||||
"Storage": {
|
|
||||||
"RootPath": ".dev-media"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
{
|
|
||||||
"ConnectionStrings": {
|
|
||||||
"Default": "Host=localhost;Port=5432;Database=telewave;Username=telewave;Password=telewave"
|
|
||||||
},
|
|
||||||
"Jwt": {
|
|
||||||
"Issuer": "TeleWave",
|
|
||||||
"Audience": "TeleWave",
|
|
||||||
"SigningKey": "change-me-min-32-chars-random-secret",
|
|
||||||
"AccessTokenMinutes": 15,
|
|
||||||
"RefreshTokenDays": 30
|
|
||||||
},
|
|
||||||
"AdminSeed": {
|
|
||||||
"Username": "",
|
|
||||||
"Password": ""
|
|
||||||
},
|
|
||||||
"Storage": {
|
|
||||||
"RootPath": "/media",
|
|
||||||
"SegmentSeconds": 2,
|
|
||||||
"LiveWindowSegments": 10,
|
|
||||||
"KeepOriginals": false,
|
|
||||||
"MinFreeSpaceBytes": 10737418240
|
|
||||||
},
|
|
||||||
"Scheduler": {
|
|
||||||
"HorizonDays": 3,
|
|
||||||
"RetentionHours": 24,
|
|
||||||
"TickMinutes": 30
|
|
||||||
},
|
|
||||||
"Media": {
|
|
||||||
"FfmpegPath": "ffmpeg",
|
|
||||||
"FfprobePath": "ffprobe",
|
|
||||||
"MaxUploadBytes": 21474836480,
|
|
||||||
"TranscodeThreads": 3,
|
|
||||||
"InboxScanSeconds": 15,
|
|
||||||
"NormalizeLoudness": true,
|
|
||||||
"LoudnessTargetLufs": -16
|
|
||||||
},
|
|
||||||
"Bumpers": {
|
|
||||||
"Width": 1280,
|
|
||||||
"Height": 720,
|
|
||||||
"FontFileSans": "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
|
||||||
"FontFileSerif": "/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf",
|
|
||||||
"TemplateVersion": 1
|
|
||||||
},
|
|
||||||
"Metadata": {
|
|
||||||
"Language": "ru-RU",
|
|
||||||
"Tmdb": {
|
|
||||||
"ApiKey": "",
|
|
||||||
"BaseUrl": "https://api.themoviedb.org/3",
|
|
||||||
"ImageBaseUrl": "https://image.tmdb.org/t/p",
|
|
||||||
"PosterSize": "w500",
|
|
||||||
"StillSize": "w300"
|
|
||||||
},
|
|
||||||
"Omdb": {
|
|
||||||
"ApiKey": "",
|
|
||||||
"BaseUrl": "https://www.omdbapi.com"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"Serilog": {
|
|
||||||
"Using": [ "Serilog.Sinks.Console" ],
|
|
||||||
"MinimumLevel": {
|
|
||||||
"Default": "Information",
|
|
||||||
"Override": {
|
|
||||||
"Microsoft.AspNetCore": "Warning",
|
|
||||||
"Microsoft.EntityFrameworkCore": "Warning",
|
|
||||||
"System.Net.Http.HttpClient": "Warning"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"WriteTo": [ { "Name": "Console" } ],
|
|
||||||
"Enrich": [ "FromLogContext" ]
|
|
||||||
},
|
|
||||||
"AllowedHosts": "*"
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html>
|
|
||||||
<head><meta charset="utf-8" /><title>TeleWave</title></head>
|
|
||||||
<body>
|
|
||||||
<p>Frontend build not present yet — run the Vite build to populate wwwroot.</p>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using TeleWave.Application.Common.Models;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Admin.Roles.ChangeUserRole;
|
|
||||||
|
|
||||||
public sealed record ChangeUserRoleCommand(Guid UserId, Guid RoleId) : ICommand<Result>;
|
|
||||||
-14
@@ -1,14 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
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>>;
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
using FluentValidation;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Admin.Roles.CreateRole;
|
|
||||||
|
|
||||||
public sealed class CreateRoleCommandValidator : AbstractValidator<CreateRoleCommand>
|
|
||||||
{
|
|
||||||
public CreateRoleCommandValidator()
|
|
||||||
{
|
|
||||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(64);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using TeleWave.Application.Common.Models;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Admin.Roles.DeleteRole;
|
|
||||||
|
|
||||||
public sealed record DeleteRoleCommand(Guid Id) : ICommand<Result>;
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using TeleWave.Application.Common.Interfaces;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Admin.Roles.ListRoles;
|
|
||||||
|
|
||||||
public sealed record ListRolesQuery : IQuery<IReadOnlyList<RoleDto>>;
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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 с последнего администратора."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
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>>;
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
using FluentValidation;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Admin.Roles.UpdateRole;
|
|
||||||
|
|
||||||
public sealed class UpdateRoleCommandValidator : AbstractValidator<UpdateRoleCommand>
|
|
||||||
{
|
|
||||||
public UpdateRoleCommandValidator()
|
|
||||||
{
|
|
||||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(64);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using TeleWave.Application.Common.Models;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Admin.Users.BlockUser;
|
|
||||||
|
|
||||||
public sealed record BlockUserCommand(Guid UserId) : ICommand<Result>;
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
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>>;
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using TeleWave.Application.Common.Models;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Admin.Users.DeleteUser;
|
|
||||||
|
|
||||||
public sealed record DeleteUserCommand(Guid UserId) : ICommand<Result>;
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using TeleWave.Application.Common.Interfaces;
|
|
||||||
using TeleWave.Application.Common.Models;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Admin.Users.GetUser;
|
|
||||||
|
|
||||||
public sealed record GetUserQuery(Guid Id) : IQuery<Result<UserSummaryDto>>;
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using TeleWave.Application.Common.Interfaces;
|
|
||||||
using TeleWave.Application.Common.Models;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Admin.Users.GetUser;
|
|
||||||
|
|
||||||
public sealed class GetUserQueryHandler(IIdentityService identityService)
|
|
||||||
: IQueryHandler<GetUserQuery, Result<UserSummaryDto>>
|
|
||||||
{
|
|
||||||
public async Task<Result<UserSummaryDto>> Handle(
|
|
||||||
GetUserQuery query,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var user = await identityService.GetUserAsync(query.Id, cancellationToken);
|
|
||||||
return user is null
|
|
||||||
? Result.Failure<UserSummaryDto>(UserErrors.NotFound)
|
|
||||||
: Result.Success(user);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using TeleWave.Application.Common.Interfaces;
|
|
||||||
using TeleWave.Application.Common.Models;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Admin.Users.ListUsers;
|
|
||||||
|
|
||||||
public sealed record ListUsersQuery(
|
|
||||||
int Page,
|
|
||||||
int PageSize,
|
|
||||||
string? Search,
|
|
||||||
Guid? RoleId,
|
|
||||||
bool? IsBlocked
|
|
||||||
) : IQuery<PagedList<UserSummaryDto>>;
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using TeleWave.Application.Common.Interfaces;
|
|
||||||
using TeleWave.Application.Common.Models;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Admin.Users.ListUsers;
|
|
||||||
|
|
||||||
public sealed class ListUsersQueryHandler(IIdentityService identityService)
|
|
||||||
: IQueryHandler<ListUsersQuery, PagedList<UserSummaryDto>>
|
|
||||||
{
|
|
||||||
public Task<PagedList<UserSummaryDto>> Handle(
|
|
||||||
ListUsersQuery query,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
) =>
|
|
||||||
identityService.ListUsersAsync(
|
|
||||||
query.Page,
|
|
||||||
query.PageSize,
|
|
||||||
query.Search,
|
|
||||||
query.RoleId,
|
|
||||||
query.IsBlocked,
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
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
@@ -1,14 +0,0 @@
|
|||||||
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
@@ -1,13 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using TeleWave.Application.Common.Models;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Admin.Users.UnblockUser;
|
|
||||||
|
|
||||||
public sealed record UnblockUserCommand(Guid UserId) : ICommand<Result>;
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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",
|
|
||||||
"Нельзя заблокировать собственный аккаунт."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
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",
|
|
||||||
"Регистрация на сайте отключена администратором."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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
|
|
||||||
);
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using TeleWave.Application.Common.Models;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Auth.ChangePassword;
|
|
||||||
|
|
||||||
public sealed record ChangePasswordCommand(string CurrentPassword, string NewPassword)
|
|
||||||
: ICommand<Result>;
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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
@@ -1,12 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using TeleWave.Application.Common.Models;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Auth.ChangeUserName;
|
|
||||||
|
|
||||||
public sealed record ChangeUserNameCommand(string NewUserName) : ICommand<Result>;
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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
@@ -1,11 +0,0 @@
|
|||||||
using FluentValidation;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Auth.ChangeUserName;
|
|
||||||
|
|
||||||
public sealed class ChangeUserNameCommandValidator : AbstractValidator<ChangeUserNameCommand>
|
|
||||||
{
|
|
||||||
public ChangeUserNameCommandValidator()
|
|
||||||
{
|
|
||||||
RuleFor(x => x.NewUserName).NotEmpty().MinimumLength(3).MaximumLength(64);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using TeleWave.Application.Common.Models;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Auth.DeleteMyAccount;
|
|
||||||
|
|
||||||
public sealed record DeleteMyAccountCommand : ICommand<Result>;
|
|
||||||
-22
@@ -1,22 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using TeleWave.Application.Common.Models;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Auth.Login;
|
|
||||||
|
|
||||||
public sealed record LoginCommand(string UserName, string Password) : ICommand<Result<AuthResult>>;
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using TeleWave.Application.Common.Models;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Auth.Logout;
|
|
||||||
|
|
||||||
public sealed record LogoutCommand(string RawToken) : ICommand<Result>;
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using TeleWave.Application.Common.Models;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Auth.Me;
|
|
||||||
|
|
||||||
public sealed record GetCurrentUserQuery : IQuery<Result<CurrentUserDto>>;
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using TeleWave.Application.Common.Models;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Auth.Refresh;
|
|
||||||
|
|
||||||
public sealed record RefreshCommand(string RawToken) : ICommand<Result<AuthResult>>;
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using TeleWave.Application.Common.Models;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Auth.Register;
|
|
||||||
|
|
||||||
public sealed record RegisterCommand(string UserName, string Password)
|
|
||||||
: ICommand<Result<AuthResult>>;
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using TeleWave.Application.Common.Models;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.AddChannelAd;
|
|
||||||
|
|
||||||
public sealed record AddChannelAdCommand(Guid ChannelId, Guid MediaAssetId)
|
|
||||||
: ICommand<Result<Guid>>;
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using TeleWave.Application.Common.Interfaces;
|
|
||||||
using TeleWave.Application.Common.Models;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.AddChannelAd;
|
|
||||||
|
|
||||||
public sealed class AddChannelAdCommandHandler(IAppDbContext dbContext)
|
|
||||||
: ICommandHandler<AddChannelAdCommand, Result<Guid>>
|
|
||||||
{
|
|
||||||
public async Task<Result<Guid>> Handle(
|
|
||||||
AddChannelAdCommand command,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var channel = await dbContext
|
|
||||||
.Channels.Include(c => c.Ads)
|
|
||||||
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
|
|
||||||
if (channel is null)
|
|
||||||
return Result.Failure<Guid>(ChannelErrors.NotFound);
|
|
||||||
|
|
||||||
var assetExists = await dbContext.MediaAssets.AnyAsync(
|
|
||||||
a => a.Id == command.MediaAssetId,
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
if (!assetExists)
|
|
||||||
return Result.Failure<Guid>(ChannelErrors.AssetNotFound);
|
|
||||||
|
|
||||||
if (channel.HasAd(command.MediaAssetId))
|
|
||||||
return Result.Failure<Guid>(ChannelErrors.AdAlreadyAdded);
|
|
||||||
|
|
||||||
var ad = channel.AddAd(command.MediaAssetId);
|
|
||||||
return Result.Success(ad.Id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using TeleWave.Application.Common.Models;
|
|
||||||
using TeleWave.Domain.Broadcast;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.AddChannelShow;
|
|
||||||
|
|
||||||
public sealed record AddChannelShowCommand(
|
|
||||||
Guid ChannelId,
|
|
||||||
Guid ShowId,
|
|
||||||
int Weight,
|
|
||||||
BlockMode BlockMode,
|
|
||||||
int BlockValue
|
|
||||||
) : ICommand<Result<Guid>>;
|
|
||||||
-40
@@ -1,40 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using TeleWave.Application.Common.Interfaces;
|
|
||||||
using TeleWave.Application.Common.Models;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.AddChannelShow;
|
|
||||||
|
|
||||||
public sealed class AddChannelShowCommandHandler(IAppDbContext dbContext)
|
|
||||||
: ICommandHandler<AddChannelShowCommand, Result<Guid>>
|
|
||||||
{
|
|
||||||
public async Task<Result<Guid>> Handle(
|
|
||||||
AddChannelShowCommand command,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var channel = await dbContext
|
|
||||||
.Channels.Include(c => c.Shows)
|
|
||||||
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
|
|
||||||
if (channel is null)
|
|
||||||
return Result.Failure<Guid>(ChannelErrors.NotFound);
|
|
||||||
|
|
||||||
var showExists = await dbContext.Shows.AnyAsync(
|
|
||||||
s => s.Id == command.ShowId,
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
if (!showExists)
|
|
||||||
return Result.Failure<Guid>(ChannelErrors.ShowNotFound);
|
|
||||||
|
|
||||||
if (channel.HasShow(command.ShowId))
|
|
||||||
return Result.Failure<Guid>(ChannelErrors.ShowAlreadyAdded);
|
|
||||||
|
|
||||||
var channelShow = channel.AddShow(
|
|
||||||
command.ShowId,
|
|
||||||
command.Weight,
|
|
||||||
command.BlockMode,
|
|
||||||
command.BlockValue
|
|
||||||
);
|
|
||||||
return Result.Success(channelShow.Id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
using FluentValidation;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.AddChannelShow;
|
|
||||||
|
|
||||||
public sealed class AddChannelShowCommandValidator : AbstractValidator<AddChannelShowCommand>
|
|
||||||
{
|
|
||||||
public AddChannelShowCommandValidator()
|
|
||||||
{
|
|
||||||
RuleFor(x => x.Weight).InclusiveBetween(1, 1000);
|
|
||||||
RuleFor(x => x.BlockValue).InclusiveBetween(1, 10000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using TeleWave.Application.Common.Models;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
|
||||||
|
|
||||||
/// <summary>Добавить новый блок заставки на канал (звук/фон загружаются отдельно).</summary>
|
|
||||||
public sealed record AddBumperTemplateCommand(Guid ChannelId, string Name) : ICommand<Result<Guid>>;
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using TeleWave.Application.Common.Interfaces;
|
|
||||||
using TeleWave.Application.Common.Models;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
|
||||||
|
|
||||||
public sealed class AddBumperTemplateCommandHandler(IAppDbContext dbContext)
|
|
||||||
: ICommandHandler<AddBumperTemplateCommand, Result<Guid>>
|
|
||||||
{
|
|
||||||
public async Task<Result<Guid>> Handle(
|
|
||||||
AddBumperTemplateCommand command,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var channel = await dbContext
|
|
||||||
.Channels.Include(c => c.BumperTemplates)
|
|
||||||
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
|
|
||||||
if (channel is null)
|
|
||||||
return Result.Failure<Guid>(ChannelErrors.NotFound);
|
|
||||||
|
|
||||||
var name = string.IsNullOrWhiteSpace(command.Name)
|
|
||||||
? $"Заставка {channel.BumperTemplates.Count + 1}"
|
|
||||||
: command.Name.Trim();
|
|
||||||
var template = channel.AddBumperTemplate(name);
|
|
||||||
return Result.Success(template.Id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using TeleWave.Application.Common.Models;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
|
||||||
|
|
||||||
/// <summary>Добавить подблок (текст-вариант) в блок заставки.</summary>
|
|
||||||
public sealed record AddBumperTextVariantCommand(Guid ChannelId, Guid TemplateId, string Name)
|
|
||||||
: ICommand<Result<Guid>>;
|
|
||||||
-33
@@ -1,33 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using TeleWave.Application.Common.Interfaces;
|
|
||||||
using TeleWave.Application.Common.Models;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
|
||||||
|
|
||||||
public sealed class AddBumperTextVariantCommandHandler(IAppDbContext dbContext)
|
|
||||||
: ICommandHandler<AddBumperTextVariantCommand, Result<Guid>>
|
|
||||||
{
|
|
||||||
public async Task<Result<Guid>> Handle(
|
|
||||||
AddBumperTextVariantCommand command,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var channel = await dbContext
|
|
||||||
.Channels.Include(c => c.BumperTemplates)
|
|
||||||
.ThenInclude(t => t.Variants)
|
|
||||||
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
|
|
||||||
if (channel is null)
|
|
||||||
return Result.Failure<Guid>(ChannelErrors.NotFound);
|
|
||||||
|
|
||||||
var template = channel.FindBumperTemplate(command.TemplateId);
|
|
||||||
if (template is null)
|
|
||||||
return Result.Failure<Guid>(ChannelErrors.BumperTemplateNotFound);
|
|
||||||
|
|
||||||
var name = string.IsNullOrWhiteSpace(command.Name)
|
|
||||||
? $"Текст {template.Variants.Count + 1}"
|
|
||||||
: command.Name.Trim();
|
|
||||||
var variant = template.AddVariant(name);
|
|
||||||
return Result.Success(variant.Id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Инфраструктурные настройки рендера ТВ-заставок (общие для всех каналов): разрешение, пути к
|
|
||||||
/// шрифтам, версия шаблона. Оформление и правила (цвета, подписи, длительность, интервал) задаются
|
|
||||||
/// на каждом канале — см. <c>Channel.UpdateBumperSettings</c>.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class BumperOptions
|
|
||||||
{
|
|
||||||
public const string SectionName = "Bumpers";
|
|
||||||
|
|
||||||
public int Width { get; init; } = 1280;
|
|
||||||
public int Height { get; init; } = 720;
|
|
||||||
|
|
||||||
/// <summary>Пути к TTF-шрифтам с кириллицей внутри контейнера (см. Dockerfile, fonts-dejavu-core).</summary>
|
|
||||||
public string FontFileSans { get; init; } =
|
|
||||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf";
|
|
||||||
public string FontFileSerif { get; init; } =
|
|
||||||
"/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf";
|
|
||||||
|
|
||||||
/// <summary>Версия шаблона рендера — входит в кэш-ключ заставки; меняй при правке ЛОГИКИ рендера
|
|
||||||
/// (не оформления канала), чтобы пересобрать уже отрендеренные заставки.</summary>
|
|
||||||
public int TemplateVersion { get; init; } = 1;
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
using System.Security.Cryptography;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Детерминированный id ассета-превью для блока заставки: один и тот же на каждый повторный рендер,
|
|
||||||
/// поэтому предпросмотр перезаписывает единственный каталог assets/{id}, а не плодит новые.
|
|
||||||
/// </summary>
|
|
||||||
public static class BumperPreview
|
|
||||||
{
|
|
||||||
public static Guid AssetId(Guid templateId) => new(MD5.HashData(templateId.ToByteArray()));
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using TeleWave.Application.Common.Models;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
|
||||||
|
|
||||||
/// <summary>Удалить загруженный звук блока (вернуться к синтезированному джинглу).</summary>
|
|
||||||
public sealed record ClearBumperTemplateAudioCommand(Guid ChannelId, Guid TemplateId)
|
|
||||||
: ICommand<Result>;
|
|
||||||
-32
@@ -1,32 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using TeleWave.Application.Common.Interfaces;
|
|
||||||
using TeleWave.Application.Common.Models;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
|
||||||
|
|
||||||
public sealed class ClearBumperTemplateAudioCommandHandler(
|
|
||||||
IAppDbContext dbContext,
|
|
||||||
IBumperTemplateStorage storage
|
|
||||||
) : ICommandHandler<ClearBumperTemplateAudioCommand, Result>
|
|
||||||
{
|
|
||||||
public async Task<Result> Handle(
|
|
||||||
ClearBumperTemplateAudioCommand command,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var channel = await dbContext
|
|
||||||
.Channels.Include(c => c.BumperTemplates)
|
|
||||||
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
|
|
||||||
if (channel is null)
|
|
||||||
return Result.Failure(ChannelErrors.NotFound);
|
|
||||||
|
|
||||||
var template = channel.FindBumperTemplate(command.TemplateId);
|
|
||||||
if (template is null)
|
|
||||||
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
|
|
||||||
|
|
||||||
template.ClearAudio();
|
|
||||||
storage.DeleteAudio(command.TemplateId);
|
|
||||||
return Result.Success();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user