- Introduced `MediaImage` entity to manage images for markdown in instructions and news. - Updated `IAppDbContext` and `AppDbContext` to include `MediaImages` DbSet. - Implemented `DeleteMediaImageFilesAsync` method in `FactoryResetCommandHandler` to remove media images during factory reset. - Added new API endpoints for uploading and retrieving media images, enhancing markdown support. - Updated frontend components to utilize the new `MarkdownEditor` for image uploads in instructions and news. - Enhanced documentation to reflect the new media handling features and API specifications.
48 KiB
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), иначе
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[], configQuota, planId } — без пагинации, весь список сразу |
| 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); создание сверх квоты тарифа (AppUser.ConfigQuota) → 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, isRecommended } |
AdminAppDto |
| PUT | /api/admin/apps/{id} |
admin | { name, downloadUrl, operatingSystem, description?, iconUrl?, sortOrder, isEnabled, isRecommended } |
AdminAppDto |
| DELETE | /api/admin/apps/{id} |
admin | — | 204 No Content |
IsRecommended — рекомендованные приложения идут первыми внутри своей группы ОС на /api/apps
(сортировка IsRecommended desc, SortOrder asc, применяется после фильтра IsEnabled), помечаются
значком-звездой на странице инструкций и в списке приложений в админке.
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).
Instructions — страница инструкций
Вводный markdown-текст (singleton) над вкладками + дополнительные вкладки, обе части редактируются
из админки. Встроенная вкладка «Приложения» (каталог ClientApp) в этот API не входит — фронт
всегда рисует её первой, сама её достаёт через GET /api/apps.
| Метод | Путь | Роль | Тело запроса | Тело ответа |
|---|---|---|---|---|
| GET | /api/instructions/intro |
user | — | InstructionIntroDto |
| GET | /api/instructions/tabs |
user | — | InstructionTabDto[] (сортировка SortOrder asc) |
| PUT | /api/admin/instructions/intro |
admin | { body } |
InstructionIntroDto |
| POST | /api/admin/instructions/tabs |
admin | { title, body, sortOrder } |
InstructionTabDto |
| PUT | /api/admin/instructions/tabs/{id} |
admin | { title, body, sortOrder } |
InstructionTabDto |
| DELETE | /api/admin/instructions/tabs/{id} |
admin | — | 204 No Content |
PUT /api/admin/instructions/intro — get-or-create (строка одна на всю систему; если её ещё нет,
создаётся, иначе обновляется на месте). GET /api/instructions/intro никогда не 404-ит — если строка
ещё не создана, отдаёт { id: "00000000-0000-0000-0000-000000000000", body: "", updatedAt: <MinValue> },
чтобы публичная страница не падала. Вкладки — обычный CRUD без статуса черновик/опубликовано, как
у NewsPostDto.
Media — картинки для markdown
| Метод | Путь | Роль | Тело запроса | Тело ответа |
|---|---|---|---|---|
| POST | /api/admin/media/images |
admin | multipart/form-data: file |
MediaImageDto |
| GET | /api/media/images/{id} |
аноним | — | тело картинки (Content-Type как при загрузке) |
Картинки вставляются админом в markdown инструкций и новостей. MediaImageDto:
{ id, fileName, contentType, sizeBytes } — ссылку клиент строит сам: /api/media/images/{id}.
Отдача анонимная: markdown рендерится обычным <img>, который не шлёт Authorization (в отличие
от вложений тикетов, которые клиент качает как blob). Защита — непрозрачный Guid в ссылке; в
картинках инструкций/новостей персональных данных нет. Ответ помечен
Cache-Control: public, max-age=31536000, immutable — содержимое по Id неизменно (перезалив даёт новый Id).
Ограничения загрузки — как у вложений тикетов: ≤5 МБ, image/jpeg|png|webp|gif. SVG не поддерживается
осознанно: картинка открывается по прямой ссылке, а SVG — документ со скриптами. Отдельного экрана
управления медиа нет; файлы лежат в том же IFileStorage (volume) и стираются при factory reset.
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 } (обязателен, ≤500) |
{ id, comment, createdAt } |
Billing (пользователь)
Группа /api/billing, RequireAuthorization() + IRequiresActivation. Доступна независимо от роли —
GET /status сам сообщает billingEnabled=false, если биллинг для роли пользователя не включён.
| Метод | Путь | Тело запроса | Тело ответа |
|---|---|---|---|
| GET | /api/billing/status |
— | BillingStatusDto { billingEnabled, paidUntil, suspended, requisitesText, activeRequest: PaymentRequestDto | null }. PaymentRequestDto теперь несёт kind (Subscription/PlanChangeTopUp) и period: PaymentPeriod | null (null для PlanChangeTopUp — см. domain-model.md#planchangetopup). Если у пользователя одновременно активны заявки обоих Kind, видна только одна (см. известное ограничение там же) |
| POST | /api/billing/requests |
{ period } (Quarter/HalfYear/Year) |
PaymentRequestDto (Kind.Subscription; 409 Billing.ActiveRequestExists, если уже есть активная заявка того же Kind — активный PlanChangeTopUp не блокирует; 409 Billing.UnlimitedRoleNotSupported при ConfigQuota=-1 (безлимит — только у admin); 500 Billing.PricingNotConfigured, если ставка для периода не задана) |
| POST | /api/billing/requests/{id}/cancel |
— | 204 No Content (только из AwaitingPayment) |
| POST | /api/billing/requests/{id}/mark-paid |
— | 204 No Content (AwaitingPayment → AwaitingConfirmation, уведомляет админов в Telegram) |
| POST | /api/billing/requests/{id}/send-requisites |
— | 204 No Content (дублирует реквизиты в свой Telegram; 409 Telegram.NotLinked, если Telegram не привязан) |
Support (пользователь)
Группа /api/support, RequireAuthorization() + IRequiresActivation (кроме GET /attachments/{id},
который тоже требует активации, но не привязан к типу тикета). Создание баг-репорта и добавление
комментария — multipart/form-data (вложения), остальное — JSON.
| Метод | Путь | Тело запроса | Тело ответа |
|---|---|---|---|
| GET | /api/support/pricing |
— | PricingSettingsDto — та же цена (включая скидочную лесенку discountTiers), что и /api/admin/pricing, для справки на странице смены тарифа (/plan, см. Plans ниже) |
| 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/extension-requests |
{ requestedDays, justification } |
TicketDetailDto (403 Billing.NotEnabled, если роль не billing; 409 Support.ExtensionRequestAlreadyPending) |
| 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 добавляет requestedDays, comments: TicketCommentDto[]. TicketCommentDto: { id, authorId, authorName, body, createdAt, attachments: TicketAttachmentDto[] }.
Заявка при существующем открытом запросе на продление → 409 Support.ExtensionRequestAlreadyPending.
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 (только BugReport, только из Open) |
| POST | /api/admin/support/tickets/{id}/close |
— | 204 No Content (только BugReport, финал) |
| POST | /api/admin/support/tickets/{id}/approve-extension |
— | 204 No Content (только ExtensionRequest/Open; продлевает BillingPaidUntil на RequestedDays) |
| POST | /api/admin/support/tickets/{id}/reject-extension |
{ reason? } |
204 No Content (только ExtensionRequest; reason уходит комментарием) |
Обработать собственный тикет админу можно — resolve/close/approve-extension/reject-extension владением тикета не ограничены (единственный админ иначе не смог бы закрыть свой же тикет).
approve-extension/reject-extension — единственный способ решить заявку на продление (нельзя
одобрить через resolve). То же самое администратор может сделать из 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) |
| DELETE | /api/admin/maintenance/audit-logs |
query: olderThanDays (1–3650) |
{ deletedCount } — удаляет записи AuditLog старше olderThanDays дней |
| DELETE | /api/admin/maintenance/apps/disabled |
— | { deletedCount } — удаляет все ClientApp с IsEnabled = false |
| DELETE | /api/admin/maintenance/factory-reset |
— | 204 No Content — полный сброс панели к состоянию свежего деплоя |
Тикет/комментарий/вложение — плоские сущности без FK-каскада (см. SupportTicket), поэтому хендлер
удаляет вручную в порядке вложения → комментарии → тикеты.
Очистка аудита пишет собственную запись AuditLogsCleanedUp уже после выборки старых записей —
её CreatedAt позже порога, поэтому она не удаляет сама себя. Полного удаления всего журнала нет
осознанно — AuditLog в проекте append-only, доступна только очистка по возрасту.
factory-reset — самая деструктивная операция панели, на фронте спрятана под спойлер
(«Опасная зона») и требует ввести фразу-подтверждение в диалоге (не просто confirm()). Удаляет:
всех пользователей кроме текущего админа, все VpnConfig/TrafficSample (конфиги сначала best-effort
отзываются на нодах через IXuiPanelGateway.RemoveClientAsync — недоступная нода не блокирует сброс),
все Node/Inbound, тикеты с перепиской/вложениями (+файлы), NewsPost, InstructionIntro/
InstructionTab, весь AuditLog, все кастомные роли (AppRole.IsSystem == false) и ClientApp —
каталог приложений и вводный текст инструкций затем пересеиваются дефолтными значениями через
IClientAppCatalogSeeder/IInstructionIntroSeeder (те же сервисы, что использует DbInitializer
при первом старте); вкладки инструкций дефолтами не пересеиваются, как и новости.
Не атомарно целиком (несколько SaveChangesAsync внутри хендлера, как и в DeleteUserCommandHandler) —
при сбое посередине возможно частичное состояние, компенсации нет, это осознанный компромисс для
редкой ручной админской операции. Финальная запись FactoryReset в аудит добавляется уже после
очистки самого журнала.
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, maxIpLimit, billingEnabled } |
RoleDto (400, если billingEnabled=true для name="admin") |
| PUT | /api/admin/roles/{id} |
admin | { maxIpLimit, billingEnabled } |
RoleDto (то же ограничение на admin; включение billingEnabled ретроактивно выдаёт грейс-период уже назначенным пользователям без PaidUntil) |
| DELETE | /api/admin/roles/{id} |
admin | — | 204 No Content (системные admin/user удалить нельзя) |
| PATCH | /api/admin/users/{id}/role |
admin | { roleId } |
204 No Content (409 Roles.CannotRemoveLastAdmin, если у цели сейчас admin, новая роль другая, и это единственный админ; см. domain-model.md#approle — переход на/с admin автоматически выдаёт/сбрасывает безлимитную квоту) |
| PATCH | /api/admin/users/{id}/plan |
admin | { planId? | customConfigCount? } |
204 No Content — прямой оверрайд квоты конфигов пользователя (ровно одно из полей); в отличие от POST /api/plans/change — без пикера конфигов на понижение (грандфазеринг) и без доплаты; customConfigCount = -1 (безлимит) — 409 Plans.UnlimitedOnlyForAdmin, если текущая роль цели не admin |
| GET | /api/admin/pricing |
admin | — | PricingSettingsDto (глобальная справочная цена за конфиг в месяц + скидочная лесенка discountTiers: { minConfigs, discountPercent }[], одна на весь сервис — не per-роль/тариф) |
| PUT | /api/admin/pricing |
admin | { pricePerConfigPerQuarter?, pricePerConfigPerHalfYear?, pricePerConfigPerYear?, discountTiers: { minConfigs, discountPercent }[] } |
PricingSettingsDto (400, если итог более длинного тарифа дешевле итога более короткого, либо discountTiers не уникальны/не прогрессивны — см. domain-model.md#pricingdiscounttier). discountTiers при сохранении полностью заменяет прежний набор |
Нет отдельного эндпоинта «активировать напрямую без запроса» — активация только через
approve/reject над ActivationRequest.
Plans (пользователь) — самостоятельная смена тарифа
Группа /api/plans, RequireAuthorization() + IRequiresActivation. Полная модель — см.
domain-model.md.
| Метод | Путь | Тело запроса | Тело ответа |
|---|---|---|---|
| GET | /api/plans |
— | PlanDto[] ({ id, name, configCount }, только isEnabled == true, сортировка sortOrder asc) |
| GET | /api/plans/status |
— | MyPlanStatusDto { configQuota, planId, activeConfigCount, billingEnabled, billingPaidUntil } |
| POST | /api/plans/change |
{ planId? | customConfigCount?, configIdsToRevoke: Guid[] } |
ChangePlanResultDto { configQuota, planId, topUpAmount: int? } |
POST /api/plans/change — ровно одно из planId/customConfigCount (иначе 400); квота меняется
сразу, без подтверждения админом. Если новое количество меньше текущего числа конфигов, всё ещё
занимающих квоту (Active и Expired — приостановленные за неуплату тоже считаются, иначе их
можно было бы молча вернуть сверх новой квоты следующей оплатой) — configIdsToRevoke обязателен и
должен содержать ровно (это число − новое количество) id, иначе 400 Plans.MustSelectConfigsToRevoke
(фронт по этой ошибке показывает пикер конфигов, включая приостановленные). Если у роли пользователя
включён биллинг, увеличение тарифа при активном
BillingPaidUntil создаёт PaymentRequest(Kind.PlanChangeTopUp) на разницу в цене —
topUpAmount в ответе ненулевой, см. domain-model.md.
customConfigCount ограничен [Plans__MinCustomConfigCount, Plans__MaxCustomConfigCount]
(по умолчанию [3, 50]) — иначе 400; -1 (безлимит) недоступен через самообслуживание в
принципе (вне допустимого диапазона). Тариф выключен/не найден → 404 Plans.NotFound /
400 Plans.Disabled.
Admin — Plans
Группа /api/admin/plans, RequireAuthorization(RoleNames.Admin). Точное зеркало /api/admin/apps.
| Метод | Путь | Тело запроса | Тело ответа |
|---|---|---|---|
| GET | /api/admin/plans |
— | AdminPlanDto[] (вкл. выключенные, { id, name, configCount, sortOrder, isEnabled }) |
| POST | /api/admin/plans |
{ name, configCount, sortOrder } |
AdminPlanDto |
| PUT | /api/admin/plans/{id} |
{ name, configCount, sortOrder, isEnabled } |
AdminPlanDto |
| DELETE | /api/admin/plans/{id} |
— | 204 No Content |
ConfigCount — только положительное число (каталожные тарифы не поддерживают безлимит; безлимит
доступен исключительно роли admin через AppUser.ConfigQuota=-1, вне каталога).
Admin — Billing
| Метод | Путь | Роль | Тело запроса | Тело ответа |
|---|---|---|---|---|
| GET | /api/admin/billing/settings |
admin | — | BillingSettingsDto { requisitesText, graceDays, defaultBillingEnabledForNewRoles } |
| PUT | /api/admin/billing/settings |
admin | { requisitesText, graceDays, defaultBillingEnabledForNewRoles } |
BillingSettingsDto |
| GET | /api/admin/billing/requests |
admin | query: status?, kind?, search?, page=1, pageSize=20 (search — по имени пользователя, резолвится до пагинации) |
PagedList<AdminPaymentRequestDto> (включает userName, kind, period: PaymentPeriod | null) |
| POST | /api/admin/billing/requests/{id}/confirm |
admin | — | 204 No Content (для Kind.Subscription продлевает BillingPaidUntil и возвращает приостановленные конфиги в Active; для Kind.PlanChangeTopUp — только помечает Confirmed, BillingPaidUntil не трогает, см. domain-model.md#planchangetopup) |
| POST | /api/admin/billing/requests/{id}/reject |
admin | { reason? } |
204 No Content |
| POST | /api/admin/billing/gift |
admin | { userId, days } |
204 No Content (продлевает BillingPaidUntil на days от max(текущий, сейчас), возвращает приостановленные конфиги, шлёт Telegram-уведомление пользователю; 403 Billing.NotEnabled, если роль пользователя не billing) |
То же подтверждение/отклонение доступно из Telegram, не заходя на сайт — инлайн-кнопки на
уведомлении о заявке (pay:approve:{id}/pay:reject:{id}, см. telegram-bot.md).
Гифт — только на сайте, в боте не решается.
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, baseAddress, location?, isEnabled, notifyOnStatusChange, 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 — оба опциональны; креденшлы меняются, только если заданы оба.
baseAddress в PUT обязателен (в отличие от username/password) — форма всегда шлёт текущее
значение; при реальном изменении бэкенд валидирует новый адрес через IXuiPanelGateway.ValidateBaseAddress
и инвалидирует закэшированный per-node клиент гейтвея (как и при смене креденшлов).
Admin — Inbounds
| Метод | Путь | Роль | Тело запроса | Тело ответа |
|---|---|---|---|---|
| GET | /api/admin/inbounds |
admin | query: nodeId? |
InboundDto[] |
| PUT | /api/admin/inbounds/{id}/publish |
admin | { isPublished, displayName?, allowedRoleIds? } |
InboundDto |
| DELETE | /api/admin/inbounds/{id} |
admin | только для IsAvailable=false, иначе Inbounds.StillAvailable |
204 |
Admin — Users & Stats
| Метод | Путь | Роль | Тело запроса | Тело ответа |
|---|---|---|---|---|
| GET | /api/admin/users |
admin | query: page, pageSize, search?, roleId?, isActivated?, isBlocked?, billingExpired? |
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?, protocol?, nodeId? |
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, source?, targetType?, action? (action — подстрока) |
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 } |
владельцу |
billingStatusChanged |
{ userId } |
владельцу |
billingStatusChanged — безадресный пинг без данных (см. BillingConfigResumer в
domain-model.md):
клиент в ответ инвалидирует my-billing-status, а не читает payload.
Client → Server
Клиент только слушает; группировка по пользователю происходит на сервере при подключении, по
UserId из JWT (плюс admins, если роль админская).
Коды ошибок
| Код | Когда |
|---|---|
| 400 | Ошибка валидации (FluentValidation, не на все команды — см. backend-conventions.md) |
| 401 | Нет/просрочен/невалиден access-токен |
| 403 | Нет прав по роли, либо Auth.NotActivated, либо Configs.BillingRequired (просрочена оплата) |
| 404 | Ресурс не найден |
| 409 | Конфликт домена: Configs.QuotaExceeded, дубликат имени пользователя при регистрации, уже есть Pending-запрос активации, Support.ExtensionRequestAlreadyPending, Support.TicketClosed, Billing.ActiveRequestExists, Billing.UnlimitedRoleNotSupported, Plans.MustSelectConfigsToRevoke, Plans.UnlimitedOnlyForAdmin, Telegram.NotLinked |
| 422 | Прочие управляемые ошибки, не подошедшие под коды выше |
| 429 | Rate limit (/api/auth/*, /api/auth/telegram/*, /sub/{token}) |
| 500 | Необработанное исключение (перехватывается UseExceptionHandler(), тело без деталей) |
502/недоступность 3x-ui наружу не пробрасывается — ошибка гейтвея становится Result.Failure и
маппится в один из кодов выше (обычно 422), либо конфиг остаётся в старом статусе, если это фоновая синхронизация.