Files
PnvPanel/docs/api-design.md
T
Leonid Pershin 8dfeb05912
CI / Backend (build + test) (push) Successful in 1m21s
CI / Frontend (lint + typecheck + build) (push) Successful in 37s
Add admin maintenance endpoints and file deletion functionality
- Introduced a new `/api/admin/maintenance` route for administrative maintenance tasks, requiring admin authorization.
- Implemented the `DeleteAsync` method in `IFileStorage` to allow for the deletion of files associated with closed support tickets.
- Updated API documentation to include details about the new maintenance operations and their effects on closed tickets.
- Enhanced frontend routing to include the new maintenance section in the admin panel, improving navigation for administrators.
- Added localization support for maintenance-related actions in both Russian and English.
2026-07-14 12:04:10 +03:00

28 KiB
Raw Blame History

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/ответ /merole строкой (одна роль, не массив). Группа /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} см. ниже

deepLinknull, если Telegram:BotToken не настроен или Bot API недоступен (username бота панель получает сама через getMe, см. telegram-bot.md), иначе https://t.me/<bot>?start=link_<token> / ?start=login_<requestId>. QR backend не рендерит — фронт строит QR из deepLink сам (qrcode.react).

GET …/login-request/{id} (поллинг) → варианты ответа:

// ожидание / отклонено / истекло — 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.

Configs (пользователь)

Группа /api (не вложена дальше), RequireAuthorization().

Метод Путь Тело запроса Тело ответа
GET /api/inbounds/available AvailableInboundDto[]
GET /api/configs { configs: VpnConfigDto[], maxConfigs }без пагинации, весь список сразу
POST /api/configs { inboundId, label? } VpnConfigDto (200 OK, не 201)
PATCH /api/configs/{id} { label? } 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, usedUpBytes, usedDownBytes, expiresAt, status, createdAt }. expiresAt всегда null (лимиты по сроку не реализованы — см. domain-model.md). Ссылка подключения не приходит вместе с созданием — фронт запрашивает GET .../link отдельно, по кнопке на карточке конфига; QR строится на фронте из connectionString.

Все /api/configs/*, /api/news, /api/apps без активации → 403 (Auth.NotActivated, единая проверка RequireActivationBehavior); создание сверх квоты роли → 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, ОС без приложений в ответе отсутствует):

{
  "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).

News — лента новостей

Метод Путь Роль Тело запроса Тело ответа
GET /api/news user query: page, pageSize PagedList<NewsPostDto>
GET /api/admin/news admin query: page, pageSize PagedList<NewsPostDto>
POST /api/admin/news admin { title, body } NewsPostDto
PUT /api/admin/news/{id} admin { title, body } NewsPostDto
DELETE /api/admin/news/{id} admin 204 No Content

Нет черновиков/отложенной публикации — POST сразу видна всем аутентифицированным пользователям и триггерит SignalR-событие newsPublished (см. ниже). NewsPostDto: { id, title, body, createdAt, updatedAt }.

Activation (пользователь)

Метод Путь Роль Тело запроса Тело ответа
GET /api/activation/status user { isActivated, pendingRequest: { id, comment, createdAt } | null }
POST /api/activation/request user { comment? } { id, comment, createdAt }

Support (пользователь)

Группа /api/support, RequireAuthorization() + IRequiresActivation (кроме GET /attachments/{id}, который тоже требует активации, но не привязан к типу тикета). Создание баг-репорта и добавление комментария — multipart/form-data (вложения), остальное — JSON.

Метод Путь Тело запроса Тело ответа
GET /api/support/roles RoleDto[] (без admin) — для выбора существующей роли в заявке
GET /api/support/tickets query: type?, status?, page=1, pageSize=20 PagedList<TicketSummaryDto> (только свои)
GET /api/support/tickets/{id} TicketDetailDto (404, если не свой)
POST /api/support/tickets/bug-reports multipart: message + files[] (до 5, изображения до 5 МБ) TicketDetailDto
POST /api/support/tickets/role-requests { existingRoleId? | (newRoleName, newRoleMaxConfigs, newRoleMaxIpLimit), justification } TicketDetailDto
POST /api/support/tickets/{id}/comments multipart: body + files[] TicketCommentDto
POST /api/support/tickets/{id}/reopen 204 No Content (только владелец, только из Resolved)
GET /api/support/attachments/{id} бинарный поток с Content-Type вложения

TicketSummaryDto: { id, userId, userName, type, status, createdAt, lastActivityAt } — один DTO для своего и админского списков. TicketDetailDto добавляет requestedRoleId, requestedRoleName, proposedRoleName, proposedMaxConfigs, proposedMaxIpLimit, comments: TicketCommentDto[]. TicketCommentDto: { id, authorId, authorName, body, createdAt, attachments: TicketAttachmentDto[] }.

Ровно одна из двух заявок на роль: либо existingRoleId (роль admin запрещена — 403 Support.CannotRequestAdminRole), либо все три поля новой роли. Заявка при существующем открытом запросе на роль → 409 Support.RoleRequestAlreadyPending. POST …/comments на Closed-тикете → 409 Support.TicketClosed. Вложения отдаются не статикой — <img src> не может передать Authorization-заголовок, фронт качает их как Blob через fetch и рендерит Object URL.

Admin — Support

Группа /api/admin/support, RequireAuthorization(RoleNames.Admin) (активация не проверяется — сеяный админ активирован всегда).

Метод Путь Тело запроса Тело ответа
GET /api/admin/support/tickets query: type?, status?, page, pageSize PagedList<TicketSummaryDto> (все пользователи)
GET /api/admin/support/tickets/{id} TicketDetailDto
POST /api/admin/support/tickets/{id}/comments multipart: body + files[] TicketCommentDto
POST /api/admin/support/tickets/{id}/resolve 204 No Content (любой тип, только из Open)
POST /api/admin/support/tickets/{id}/close 204 No Content (любой тип, финал)
POST /api/admin/support/tickets/{id}/approve 204 No Content (только RoleRequest/Open; создаёt/назначает роль)
POST /api/admin/support/tickets/{id}/reject { reason? } 204 No Content (только RoleRequest; reason уходит комментарием)

approve/reject — единственный способ решить заявку на роль (нельзя одобрить через resolve). При одобрении: если заявка на существующую роль — сразу ChangeUserRoleCommand-эквивалент; если на новую — сперва создаётся AppRole (IRoleService.CreateRoleAsync), затем назначается. То же самое администратор может сделать из Telegram, не заходя на сайт — инлайн-кнопки на уведомлении о заявке (см. telegram-bot.md); для баг-репортов в Telegram только кнопка-ссылка на /admin/support?ticket={id} — переписка и вложения только на сайте (отдельного роута на конкретный тикет нет, ?ticket= открывает диалог поверх списка).

Admin — Maintenance

Группа /api/admin/maintenance, RequireAuthorization(RoleNames.Admin). Вкладка «Обслуживание» — разовые операции подчистки, задумана расширяемой (следующие кандидаты: очистка старых новостей и т.п.).

Метод Путь Тело ответа
DELETE /api/admin/maintenance/tickets/closed { deletedCount } — удаляет все тикеты в статусе Closed вместе с комментариями и вложениями (файлы стираются с диска через IFileStorage.DeleteAsync)

Тикет/комментарий/вложение — плоские сущности без FK-каскада (см. SupportTicket), поэтому хендлер удаляет вручную в порядке вложения → комментарии → тикеты.

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, maxIpLimit } RoleDto
PUT /api/admin/roles/{id} admin { maxConfigs, maxIpLimit } 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), а не осознанная защита. 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
DELETE /api/admin/users/{id} admin 204 No Content (отзывает все конфиги пользователя в 3x-ui, затем удаляет учётку; себя удалить нельзя)
GET /api/admin/users/{id}/configs admin VpnConfigDto[]
GET /api/admin/configs admin query: page, pageSize, search?, status? PagedList<AdminVpnConfigDto>
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>

AdminVpnConfigDto — глобальный список конфигов для админа (не скоупится одним пользователем, в отличие от VpnConfigDto): { id, userId, userName, label, clientEmail, protocol, location, nodeName, usedUpBytes, usedDownBytes, expiresAt, status, createdAt }. search матчится по clientEmail/label.

Блокировка/разблокировка — два отдельных эндпоинта без тела, не один переключатель 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 } владельцу
newsPublished { id, title, createdAt } все (broadcast)
ticketCreated { ticketId, userId, userName, type } admins
ticketUpdated { ticketId } владельцу

Client → Server

Клиент только слушает; группировка по пользователю происходит на сервере при подключении, по UserId из JWT (плюс admins, если роль админская).

Коды ошибок

Код Когда
400 Ошибка валидации (FluentValidation, не на все команды — см. backend-conventions.md)
401 Нет/просрочен/невалиден access-токен
403 Нет прав по роли, либо Auth.NotActivated
404 Ресурс не найден
409 Конфликт домена: Configs.QuotaExceeded, дубликат имени пользователя при регистрации, уже есть Pending-запрос активации, Support.RoleRequestAlreadyPending, Support.TicketClosed
422 Прочие управляемые ошибки, не подошедшие под коды выше
429 Rate limit (/api/auth/*, /api/auth/telegram/*, /sub/{token})
500 Необработанное исключение (перехватывается UseExceptionHandler(), тело без деталей)

502/недоступность 3x-ui наружу не пробрасывается — ошибка гейтвея становится Result.Failure и маппится в один из кодов выше (обычно 422), либо конфиг остаётся в старом статусе, если это фоновая синхронизация.