Files
PnvPanel/docs/api-design.md
T
Leonid Pershin ad94c6ef22
CI / Backend (build + test) (push) Successful in 1m33s
CI / Frontend (lint + typecheck + build) (push) Successful in 29s
Update documentation and clarify MVP status
- Revised the CLAUDE.md and README.md files to reflect the current MVP status, emphasizing completed features and intentionally omitted elements such as traffic limits and billing.
- Enhanced clarity in the documentation regarding the architecture, tech stack, and user roles.
- Removed the outdated roadmap section and streamlined references to tech stack decisions.
- Updated API design documentation to clarify the absence of versioning in the MVP and the handling of configuration details.
2026-07-02 21:11:59 +03:00

213 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# API Design
REST поверх HTTP/JSON, авторизация — `Authorization: Bearer <access-token>` (кроме публичных
эндпоинтов). Ошибки — `application/problem+json`. Пагинация — `?page=&pageSize=`, ответ `PagedList<T>`
(`items`, `total`, `page`, `pageSize`) — используется не везде, см. таблицы ниже. Все даты — ISO-8601 UTC.
Тела запросов/ответов — camelCase JSON; енумы сериализуются строками (`"Active"`, не `0`).
Базовый префикс: `/api` (без версионирования). Схема генерируется нативным
`Microsoft.AspNetCore.OpenApi` (`/openapi/v1.json`) и Scalar UI (`/scalar`) — каждый эндпоинт
аннотирован `.Produces<T>()`, так что схема полностью описывает и тела запросов, и тела ответов.
Ниже — полный контракт, сверенный построчно с кодом (`backend/src/PnvPanel.Api/Endpoints/*.cs`).
## Auth
| Метод | Путь | Роль | Тело запроса | Тело ответа |
| ----- | --------------------------- | ------ | ------------------------------------------ | -------------------------------------- |
| POST | `/api/auth/register` | — | `{ userName, password }` | `{ id, userName }` |
| POST | `/api/auth/login` | — | `{ userName, password }` | `{ accessToken, expiresAt, user }` + refresh в httpOnly cookie |
| POST | `/api/auth/refresh` | — | — (refresh из cookie) | то же, что login; ротация cookie |
| POST | `/api/auth/logout` | user | — | `204 No Content` |
| POST | `/api/auth/change-password` | user | `{ currentPassword, newPassword }` | `204 No Content` |
| POST | `/api/auth/change-username` | user | `{ newUserName }` | `204 No Content` |
| GET | `/api/auth/me` | user | — | `{ id, userName, role, isActivated, telegramLinked }` |
| DELETE| `/api/auth/me` | user | — | `204 No Content` |
`user`/ответ `/me`**`role` строкой** (одна роль, не массив). Группа `/api/auth/*` под общим
rate-limit'ом (`RateLimiting:AuthPermitLimit`, по умолчанию 20 запросов/мин).
> **Вход по username.** Email в системе не используется. Забыт пароль: при привязанном
> Telegram — вход без пароля через бота и смена пароля в настройках; иначе — сброс админом (см. Admin).
## Auth — Telegram (привязка и passwordless-вход)
Группа `/api/auth/telegram/*`, тот же rate-limit, что и `/api/auth/*`.
| Метод | Путь | Роль | Тело ответа |
| ----- | --------------------------------------------- | ---- | ------------------------------------------------------ |
| POST | `/api/auth/telegram/link-token` | user | `{ deepLink, expiresAt }` |
| POST | `/api/auth/telegram/unlink` | user | `204 No Content` |
| POST | `/api/auth/telegram/login-request` | — | `{ requestId, deepLink, expiresAt }` |
| GET | `/api/auth/telegram/login-request/{id}` | — | см. ниже |
`deepLink``null`, если `Telegram:BotToken` не настроен или Bot API недоступен (username бота
панель получает сама через `getMe`, см. [telegram-bot.md](telegram-bot.md#конфигурация)), иначе
`https://t.me/<bot>?start=link_<token>` / `?start=login_<requestId>`. **QR backend не рендерит**
фронт строит QR из `deepLink` сам (`qrcode.react`).
`GET …/login-request/{id}` (поллинг) → варианты ответа:
```json
// ожидание / отклонено / истекло — accessToken/expiresAt/user всегда null, кроме Approved
{ "status": "Pending", "accessToken": null, "expiresAt": null, "user": null }
// подтверждено — выпуск токенов (refresh уходит в httpOnly cookie), запрос помечается Consumed
{ "status": "Approved", "accessToken": "…", "expiresAt": "…", "user": { "id": "…", "userName": "…", "role": "user", "isActivated": true, "telegramLinked": true } }
```
`status` — одно из `Pending`/`Approved`/`Rejected`/`Expired`/`Consumed`.
> Апдейты Telegram (`/start`, кнопки, `/configs`) обрабатывает in-process бот (long polling), а не
> HTTP-эндпоинты. Команды бота — в [telegram-bot.md](telegram-bot.md).
## Configs (пользователь)
Группа `/api` (не вложена дальше), `RequireAuthorization()`.
| Метод | Путь | Тело запроса | Тело ответа |
| ------ | --------------------------------- | ------------------------------------ | --------------------------------------- |
| GET | `/api/inbounds/available` | — | `AvailableInboundDto[]` |
| GET | `/api/configs` | — | `{ configs: VpnConfigDto[], maxConfigs }`**без пагинации**, весь список сразу |
| POST | `/api/configs` | `{ inboundId, label?, deviceLimit? }`| `VpnConfigDto` (`200 OK`, не 201) |
| PATCH | `/api/configs/{id}` | `{ label?, deviceLimit? }` | `VpnConfigDto` |
| POST | `/api/configs/{id}/rotate` | — | `VpnConfigDto` (новый `id` тот же, новый `SubscriptionToken`) |
| DELETE | `/api/configs/{id}` | — | `204 No Content` |
| GET | `/api/configs/{id}/link` | — | `{ connectionString, subscriptionUrl }` |
| GET | `/api/subscription` | — | `{ subscriptionUrl }` |
**Нет отдельного `GET /api/configs/{id}`** — детали конфига берутся из списка `GET /api/configs`.
`VpnConfigDto`: `{ id, label, protocol, location, deviceLimit, usedUpBytes, usedDownBytes, expiresAt,
status, createdAt }`. `expiresAt` всегда `null` (лимиты по сроку не реализованы — см.
[domain-model.md](domain-model.md)). Ссылка подключения **не приходит вместе с созданием** — фронт
запрашивает `GET .../link` отдельно, по кнопке на карточке конфига; QR строится на фронте из
`connectionString`.
`POST /api/configs` без активации → `403` (`Configs.NotActivated`); сверх квоты роли → `409`
(`Configs.QuotaExceeded`).
## Apps — каталог приложений
| Метод | Путь | Роль | Тело запроса | Тело ответа |
| ------ | -------------------------- | ----- | --------------------------------------------------------------------------------- | ------------- |
| GET | `/api/apps` | user | — | `Record<OsPlatform, ClientAppDto[]>` |
| GET | `/api/admin/apps` | admin | — | `AdminAppDto[]` (вкл. выключенные) |
| POST | `/api/admin/apps` | admin | `{ name, downloadUrl, operatingSystem, description?, iconUrl?, sortOrder }` | `AdminAppDto` |
| PUT | `/api/admin/apps/{id}` | admin | `{ name, downloadUrl, operatingSystem, description?, iconUrl?, sortOrder, isEnabled }` | `AdminAppDto` |
| DELETE | `/api/admin/apps/{id}` | admin | — | `204 No Content` |
`GET /api/apps` → пример (только `isEnabled == true`, ОС без приложений в ответе отсутствует):
```json
{
"Android": [ { "id": "…", "name": "v2rayNG", "downloadUrl": "https://…", "description": null, "iconUrl": null } ],
"IOS": [ { "id": "…", "name": "Hiddify", "downloadUrl": "https://…", "description": null, "iconUrl": null } ]
}
```
Значение `OsPlatform` в C#/JSON`IOS` (не `iOS`).
## Activation (пользователь)
| Метод | Путь | Роль | Тело запроса | Тело ответа |
| ----- | --------------------------- | ---- | ----------------- | -------------------------------------------------------- |
| GET | `/api/activation/status` | user | — | `{ isActivated, pendingRequest: { id, comment, createdAt } \| null }` |
| POST | `/api/activation/request` | user | `{ comment? }` | `{ id, comment, createdAt }` |
## Admin — Activation, Roles
| Метод | Путь | Роль | Тело запроса | Тело ответа |
| ------ | ----------------------------------------------- | ----- | ----------------------- | ------------- |
| GET | `/api/admin/activation-requests` | admin | query: `statusFilter?, page=1, pageSize=20` | `PagedList<ActivationRequestAdminDto>` |
| POST | `/api/admin/activation-requests/{id}/approve` | admin | — | `204 No Content` |
| POST | `/api/admin/activation-requests/{id}/reject` | admin | `{ reason? }` | `204 No Content` |
| GET | `/api/admin/roles` | admin | — | `RoleDto[]` |
| POST | `/api/admin/roles` | admin | `{ name, maxConfigs }` | `RoleDto` |
| PUT | `/api/admin/roles/{id}` | admin | `{ maxConfigs }` | `RoleDto` |
| DELETE | `/api/admin/roles/{id}` | admin | — | `204 No Content` (системные `admin`/`user` удалить нельзя) |
| PATCH | `/api/admin/users/{id}/role` | admin | `{ roleId }` | `204 No Content` |
Нет отдельного эндпоинта «активировать напрямую без запроса» — активация только через
approve/reject над `ActivationRequest`.
## Admin — Nodes
| Метод | Путь | Роль | Тело запроса | Тело ответа |
| ------ | ----------------------------- | ----- | ---------------------------------------------------------------------------- | ------------- |
| GET | `/api/admin/nodes` | admin | — | `NodeDto[]` |
| POST | `/api/admin/nodes` | admin | `{ name, baseAddress, username, password, location? }` | `NodeDto` |
| PUT | `/api/admin/nodes/{id}` | admin | `{ name, location?, isEnabled, username?, password? }` | `NodeDto` |
| DELETE | `/api/admin/nodes/{id}` | admin | — | `204 No Content` |
| POST | `/api/admin/nodes/{id}/sync` | admin | — | `{ inboundsSynced, status }` |
| POST | `/api/admin/nodes/{id}/probe` | admin | — | `{ isReachable, errorMessage, status }` |
`DELETE /api/admin/nodes/{id}` удаляет её инбаунды каскадно **без проверки существующих конфигов**
на них — известный пробел (см. [tech-stack.md](tech-stack.md)), а не осознанная защита.
`username`/`password` в `PUT` — оба опциональны; креденшлы меняются, только если заданы **оба**.
## Admin — Inbounds
| Метод | Путь | Роль | Тело запроса | Тело ответа |
| ----- | -------------------------------------- | ----- | ---------------------------------------------------------------------------- | ------------- |
| GET | `/api/admin/inbounds` | admin | query: `nodeId?` | `InboundDto[]` |
| PUT | `/api/admin/inbounds/{id}/publish` | admin | `{ isPublished, displayName?, allowedRoleIds?, maxClients? }` | `InboundDto` |
## Admin — Users & Stats
| Метод | Путь | Роль | Тело запроса | Тело ответа |
| ------ | ---------------------------------------- | ----- | --------------------------- | ------------- |
| GET | `/api/admin/users` | admin | query: `page, pageSize, search?` | `PagedList<UserSummaryDto>` |
| PATCH | `/api/admin/users/{id}/block` | admin | — | `204 No Content` |
| PATCH | `/api/admin/users/{id}/unblock` | admin | — | `204 No Content` |
| POST | `/api/admin/users/{id}/reset-password` | admin | `{ newPassword }` | `204 No Content` |
| GET | `/api/admin/users/{id}/configs` | admin | — | `VpnConfigDto[]` |
| DELETE | `/api/admin/configs/{id}` | admin | — | `204 No Content` (принудительный отзыв любого конфига) |
| GET | `/api/admin/stats` | admin | — | `StatsDto` |
| GET | `/api/admin/audit` | admin | query: `page, pageSize` | `PagedList<AuditLogDto>` |
**Блокировка/разблокировка — два отдельных эндпоинта без тела**, не один переключатель `isBlocked`.
`StatsDto`: `{ totalUsers, activatedUsers, pendingActivationRequests, totalNodes, onlineNodes,
totalConfigs, activeConfigs, totalUsedUpBytes, totalUsedDownBytes }` — считается на лету при запросе,
не кэшируется.
## Public — Subscription
| Метод | Путь | Роль | Ответ |
| ----- | ----------------- | ---- | ---------------------------------------------------------------- |
| GET | `/sub/{token}` | — | `text/plain`, base64 от списка connection strings, `\n`-разделены |
Вне `/api` (публичный эндпоинт для VPN-клиентов), под тем же rate-limit'ом, что и `/api/auth/*`.
Токен — либо `AppUser.SubscriptionToken` (**все активные конфиги юзера**), либо
`VpnConfig.SubscriptionToken` (**один конфиг**); пробуются по очереди, первый успешный — в ответе.
Неизвестный/погашенный токен → `404`. Заголовки ответа:
`Subscription-Userinfo: upload=<up>; download=<down>; total=<up+down>; expire=<unix|0>` и
`Profile-Update-Interval: 12` — их читают клиенты (v2rayN/Nekoray и т.п.), чтобы показать остаток.
## SignalR — Hub `/hubs/panel`
Авторизация — тем же JWT. Группы: `user:{userId}` (личные события), `admins` (админам).
### Server → Client
| Событие | Payload | Кому |
| ---------------------- | ------------------------------------------------------------- | ------------ |
| `configTrafficUpdated` | `{ configId, usedUpBytes, usedDownBytes }` | владельцу |
| `configStatusChanged` | `{ configId, status }` | владельцу |
| `nodeStatusChanged` | `{ nodeId, status, lastSyncAt }` | `admins` |
| `activationRequested` | `{ requestId, userId, userName, comment, createdAt }` | `admins` |
| `userActivated` | `{ userId }` | владельцу |
### Client → Server
Клиент только слушает; группировка по пользователю происходит на сервере при подключении, по
`UserId` из JWT (плюс `admins`, если роль админская).
## Коды ошибок
| Код | Когда |
| --- | -------------------------------------------------------------------- |
| 400 | Ошибка валидации (FluentValidation, не на все команды — см. [backend-conventions.md](backend-conventions.md)) |
| 401 | Нет/просрочен/невалиден access-токен |
| 403 | Нет прав по роли, либо `Configs.NotActivated` |
| 404 | Ресурс не найден |
| 409 | Конфликт домена: `Configs.QuotaExceeded`, дубликат имени пользователя при регистрации, уже есть `Pending`-запрос активации |
| 422 | Прочие управляемые ошибки, не подошедшие под коды выше |
| 429 | Rate limit (`/api/auth/*`, `/api/auth/telegram/*`, `/sub/{token}`) |
| 500 | Необработанное исключение (перехватывается `UseExceptionHandler()`, тело без деталей) |
`502`/недоступность 3x-ui наружу не пробрасывается — ошибка гейтвея становится `Result.Failure` и
маппится в один из кодов выше (обычно 422), либо конфиг остаётся в старом статусе, если это фоновая синхронизация.