- Removed the `BotUsername` property from `TelegramOptions` and updated the `.env.example` to reflect this change, as the bot's username is now dynamically retrieved via the Bot API. - Introduced `ITelegramBotInfo` to cache the bot's username, improving the handling of deep links in `TelegramEndpoints`. - Updated API documentation to clarify that the deep link is now dependent on the bot's token and its availability through the Bot API, enhancing clarity for developers.
212 lines
19 KiB
Markdown
212 lines
19 KiB
Markdown
# 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` (**без версионирования в MVP**). Схема генерируется нативным
|
||
`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` |
|
||
| 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` в MVP всегда `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), либо конфиг остаётся в старом статусе, если это фоновая синхронизация.
|