Files
vpn-telegram-bot/webapp_admin_routes.py

1684 lines
60 KiB
Python
Raw Permalink 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.
"""Маршруты веб-админки и кодов пополнения."""
from __future__ import annotations
import json
import logging
import secrets
from datetime import datetime, timedelta, timezone
from typing import Any
from aiohttp import web
from config import Settings
from services.db import Database
from services.openrouter import (
DEFAULT_MODELS,
DEFAULT_SYSTEM_PROMPT,
chat_completion,
config_public_dict,
list_models,
load_openrouter_config,
parse_ai_decision,
)
from services.payments_runtime import (
apply_payment_runtime,
apply_payments_body,
payments_public_dict,
)
from services.smtp_runtime import apply_smtp_body, smtp_public_dict
from services.rate_limit import allow
from services.tariffs import (
Tariff,
TariffError,
parse_tariff_payload,
replace_shared_tariffs,
save_tariffs,
)
from services.tickets import (
BODY_MAX,
clamp_body,
message_to_dict,
notify_user_ticket_reply,
ticket_to_dict,
)
from services.topup_codes import generate_code
from services.xui_client import XuiClient, XuiError, build_subscription_link
from services.xui_wg import load_wg_purchase_settings, save_wg_purchase_settings
from services.awg_client import DEFAULT_PROTOCOL, AwgClient, AwgError
from services.awg_configs import load_awg_purchase_settings, save_awg_purchase_settings
logger = logging.getLogger(__name__)
ADMIN_COOKIE = "vpn_admin_session"
ADMIN_SESSION_HOURS = 12
def register_topup_admin_routes(app: web.Application, *, webapp_dir) -> None:
if "admin_sessions" not in app:
app["admin_sessions"] = {}
async def handle_admin_index(_: web.Request) -> web.Response:
return web.FileResponse(webapp_dir / "admin.html")
app.router.add_get("/admin", handle_admin_index)
app.router.add_post("/api/admin/login", handle_admin_login)
app.router.add_post("/api/admin/logout", handle_admin_logout)
app.router.add_get("/api/admin/me", handle_admin_me)
app.router.add_get("/api/admin/stats", handle_admin_stats)
app.router.add_get("/api/admin/codes", handle_admin_list_codes)
app.router.add_post("/api/admin/codes", handle_admin_create_codes)
app.router.add_get("/api/admin/users", handle_admin_list_users)
app.router.add_post("/api/admin/users/balance", handle_admin_user_balance)
app.router.add_get("/api/admin/tickets", handle_admin_list_tickets)
app.router.add_get("/api/admin/tickets/{ticket_id}", handle_admin_get_ticket)
app.router.add_post("/api/admin/tickets/{ticket_id}/messages", handle_admin_reply_ticket)
app.router.add_post("/api/admin/tickets/{ticket_id}/close", handle_admin_close_ticket)
app.router.add_post("/api/admin/tickets/{ticket_id}/reopen", handle_admin_reopen_ticket)
app.router.add_get("/api/admin/ai", handle_admin_ai_get)
app.router.add_post("/api/admin/ai", handle_admin_ai_save)
app.router.add_get("/api/admin/ai/models", handle_admin_ai_models)
app.router.add_post("/api/admin/ai/test", handle_admin_ai_test)
app.router.add_get("/api/admin/xui/servers", handle_admin_xui_list)
app.router.add_post("/api/admin/xui/servers", handle_admin_xui_create)
app.router.add_post("/api/admin/xui/test", handle_admin_xui_test)
app.router.add_get("/api/admin/xui/servers/{server_id}", handle_admin_xui_get)
app.router.add_put("/api/admin/xui/servers/{server_id}", handle_admin_xui_update)
app.router.add_delete("/api/admin/xui/servers/{server_id}", handle_admin_xui_delete)
app.router.add_post(
"/api/admin/xui/servers/{server_id}/test",
handle_admin_xui_test_saved,
)
app.router.add_get(
"/api/admin/xui/servers/{server_id}/inbounds",
handle_admin_xui_inbounds,
)
app.router.add_get("/api/admin/xui/wg-settings", handle_admin_wg_settings_get)
app.router.add_post("/api/admin/xui/wg-settings", handle_admin_wg_settings_save)
app.router.add_get("/api/admin/awg/servers", handle_admin_awg_list)
app.router.add_post("/api/admin/awg/servers", handle_admin_awg_create)
app.router.add_post("/api/admin/awg/test", handle_admin_awg_test)
app.router.add_post("/api/admin/awg/discover", handle_admin_awg_discover)
app.router.add_get("/api/admin/awg/servers/{server_id}", handle_admin_awg_get)
app.router.add_put("/api/admin/awg/servers/{server_id}", handle_admin_awg_update)
app.router.add_delete("/api/admin/awg/servers/{server_id}", handle_admin_awg_delete)
app.router.add_post(
"/api/admin/awg/servers/{server_id}/test",
handle_admin_awg_test_saved,
)
app.router.add_get("/api/admin/awg/settings", handle_admin_awg_settings_get)
app.router.add_post("/api/admin/awg/settings", handle_admin_awg_settings_save)
app.router.add_get("/api/admin/tariffs", handle_admin_tariffs_list)
app.router.add_post("/api/admin/tariffs", handle_admin_tariffs_create)
app.router.add_put("/api/admin/tariffs/{tariff_id}", handle_admin_tariffs_update)
app.router.add_delete("/api/admin/tariffs/{tariff_id}", handle_admin_tariffs_delete)
app.router.add_get("/api/admin/payments", handle_admin_payments_get)
app.router.add_post("/api/admin/payments", handle_admin_payments_save)
app.router.add_get("/api/admin/smtp", handle_admin_smtp_get)
app.router.add_post("/api/admin/smtp", handle_admin_smtp_save)
app.router.add_post("/api/admin/smtp/test", handle_admin_smtp_test)
def _utc_now() -> datetime:
return datetime.now(timezone.utc)
def _admin_enabled(settings: Settings) -> bool:
return settings.web_admin_enabled
def _safe_secret_compare(got: str, expected: str) -> bool:
got_b = got.encode("utf-8")
exp_b = expected.encode("utf-8")
if len(got_b) != len(exp_b):
return False
return secrets.compare_digest(got_b, exp_b)
def _check_admin_password(settings: Settings, password: str) -> bool:
expected = settings.web_admin_password
if not expected:
return False
return _safe_secret_compare((password or "").strip(), expected)
def _check_admin_login(settings: Settings, login: str) -> bool:
expected = settings.web_admin_login.strip().lower()
if not expected:
return False
return _safe_secret_compare((login or "").strip().lower(), expected)
def _admin_token(request: web.Request) -> str | None:
auth = request.headers.get("Authorization") or ""
if auth.lower().startswith("bearer "):
return auth[7:].strip() or None
return request.cookies.get(ADMIN_COOKIE)
def _admin_authenticated(request: web.Request) -> bool:
settings: Settings = request.app["settings"]
if not _admin_enabled(settings):
return False
token = _admin_token(request)
if not token:
return False
sessions: dict[str, datetime] = request.app.get("admin_sessions") or {}
expires = sessions.get(token)
if not expires or expires <= _utc_now():
sessions.pop(token, None)
return False
return True
def _require_admin(request: web.Request) -> web.Response | None:
if not _admin_enabled(request.app["settings"]):
return web.json_response({"ok": False, "error": "admin disabled"}, status=503)
if not _admin_authenticated(request):
return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
return None
def _set_admin_cookie(response: web.Response, request: web.Request, token: str) -> None:
secure = request.headers.get("X-Forwarded-Proto", "").lower() == "https" or (
request.url.scheme == "https"
)
response.set_cookie(
ADMIN_COOKIE,
token,
max_age=ADMIN_SESSION_HOURS * 3600,
httponly=True,
samesite="Lax",
secure=secure,
path="/",
)
async def handle_admin_login(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
if not _admin_enabled(settings):
return web.json_response({"ok": False, "error": "admin disabled"}, status=503)
if not allow(request, "admin_login", limit=8, window_sec=300):
return web.json_response({"ok": False, "error": "Слишком часто"}, status=429)
try:
body = await request.json()
except json.JSONDecodeError:
return web.json_response({"ok": False, "error": "invalid json"}, status=400)
login = str(body.get("login") or "").strip()
password = str(body.get("password") or "").strip()
login_ok = _check_admin_login(settings, login)
pass_ok = _check_admin_password(settings, password)
if not login_ok or not pass_ok:
logger.warning(
"Web admin login failed (login_ok=%s pass_ok=%s, configured_login=%r)",
login_ok,
pass_ok,
settings.web_admin_login,
)
return web.json_response({"ok": False, "error": "Неверный логин или пароль"}, status=403)
token = secrets.token_urlsafe(32)
sessions: dict[str, datetime] = request.app["admin_sessions"]
sessions[token] = _utc_now() + timedelta(hours=ADMIN_SESSION_HOURS)
resp = web.json_response({"ok": True, "token": token})
_set_admin_cookie(resp, request, token)
return resp
async def handle_admin_logout(request: web.Request) -> web.Response:
token = _admin_token(request)
if token:
sessions: dict[str, datetime] = request.app.get("admin_sessions") or {}
sessions.pop(token, None)
resp = web.json_response({"ok": True})
resp.del_cookie(ADMIN_COOKIE, path="/")
return resp
async def handle_admin_me(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
return web.json_response(
{
"ok": True,
"enabled": _admin_enabled(settings),
"authenticated": _admin_authenticated(request),
}
)
async def handle_admin_stats(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
db: Database = request.app["db"]
return web.json_response(
{
"ok": True,
"stats": db.topup_code_stats(),
"tickets": db.ticket_stats(),
}
)
async def handle_admin_list_codes(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
status = request.rel_url.query.get("status") or "all"
try:
limit = int(request.rel_url.query.get("limit") or "50")
offset = int(request.rel_url.query.get("offset") or "0")
except ValueError:
limit, offset = 50, 0
db: Database = request.app["db"]
codes = db.list_topup_codes(status=status, limit=limit, offset=offset)
return web.json_response({"ok": True, "codes": _serialize_codes(codes)})
async def handle_admin_create_codes(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
try:
body = await request.json()
except json.JSONDecodeError:
return web.json_response({"ok": False, "error": "invalid json"}, status=400)
try:
amount = float(body.get("amount") or 0)
count = int(body.get("count") or 1)
except (TypeError, ValueError):
return web.json_response({"ok": False, "error": "bad amount/count"}, status=400)
if amount <= 0:
return web.json_response({"ok": False, "error": "amount must be > 0"}, status=400)
count = max(1, min(count, 100))
note = str(body.get("note") or "web_admin")[:120]
codes: list[str] = []
seen: set[str] = set()
while len(codes) < count:
code = generate_code()
if code in seen:
continue
seen.add(code)
codes.append(code)
db: Database = request.app["db"]
inserted = db.create_topup_codes(
amount_rub=amount,
count=count,
created_by=note,
codes=codes,
)
return web.json_response(
{
"ok": True,
"inserted": inserted,
"codes": codes[:inserted],
"amount": amount,
}
)
async def handle_admin_list_users(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
q = request.rel_url.query.get("q") or ""
try:
limit = int(request.rel_url.query.get("limit") or "50")
offset = int(request.rel_url.query.get("offset") or "0")
except ValueError:
limit, offset = 50, 0
db: Database = request.app["db"]
rows, total = db.list_users_admin(query=q, limit=limit, offset=offset)
users = []
for row in rows:
tid = int(row["telegram_id"])
name = row.get("full_name") or row.get("login") or row.get("username")
if row.get("username") and not str(name or "").startswith("@"):
uname = row["username"]
else:
uname = row.get("username")
users.append(
{
"telegram_id": tid,
"login": row.get("login"),
"username": uname,
"full_name": row.get("full_name"),
"display_name": name or f"ID {tid}",
"balance": float(row["balance"]),
"created_at": _iso(row.get("created_at")),
"is_telegram": tid > 0,
"banned": bool(row.get("banned")),
}
)
return web.json_response(
{"ok": True, "users": users, "total": total, "limit": limit, "offset": offset}
)
async def handle_admin_user_balance(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
try:
body = await request.json()
except json.JSONDecodeError:
return web.json_response({"ok": False, "error": "invalid json"}, status=400)
try:
telegram_id = int(body.get("telegram_id"))
amount = float(body.get("amount") or 0)
except (TypeError, ValueError):
return web.json_response({"ok": False, "error": "bad telegram_id/amount"}, status=400)
comment = str(body.get("comment") or "").strip()
if amount <= 0:
return web.json_response({"ok": False, "error": "amount must be > 0"}, status=400)
if not comment:
return web.json_response(
{"ok": False, "error": "Укажите комментарий для пользователя"},
status=400,
)
db: Database = request.app["db"]
user = db.get_user(telegram_id)
if user is None:
db.ensure_user(telegram_id)
user = db.get_user(telegram_id)
if user is None:
return web.json_response({"ok": False, "error": "user not found"}, status=404)
try:
balance = db.admin_credit_balance(
telegram_id,
amount,
comment=comment[:200],
)
except ValueError as exc:
return web.json_response({"ok": False, "error": str(exc)}, status=400)
bot = request.app.get("bot")
if bot is not None and telegram_id > 0:
try:
await bot.send_message(
telegram_id,
(
"<b>💰 Баланс пополнен</b>\n\n"
f"Зачислено: <b>+{amount:.0f} ₽</b>\n"
f"Комментарий: {comment[:200]}\n"
f"Текущий баланс: <b>{balance:.0f} ₽</b>"
),
parse_mode="HTML",
)
except Exception: # noqa: BLE001
logger.warning("admin credit notify failed tg=%s", telegram_id)
logger.info(
"Admin credit tg=%s amount=%s comment=%r",
telegram_id,
amount,
comment[:80],
)
return web.json_response(
{
"ok": True,
"telegram_id": telegram_id,
"balance": balance,
"amount": amount,
"comment": comment[:200],
}
)
async def handle_admin_list_tickets(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
status = request.rel_url.query.get("status") or "open"
try:
limit = int(request.rel_url.query.get("limit") or "50")
offset = int(request.rel_url.query.get("offset") or "0")
except ValueError:
limit, offset = 50, 0
db: Database = request.app["db"]
rows, total = db.list_tickets_admin(status=status, limit=limit, offset=offset)
return web.json_response(
{
"ok": True,
"tickets": [ticket_to_dict(r, include_user=True) for r in rows],
"total": total,
"stats": db.ticket_stats(),
"body_max": BODY_MAX,
}
)
async def handle_admin_get_ticket(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
try:
tid = int(request.match_info["ticket_id"])
except (KeyError, ValueError):
return web.json_response({"ok": False, "error": "bad id"}, status=400)
db: Database = request.app["db"]
ticket = db.get_ticket(tid)
if not ticket:
return web.json_response({"ok": False, "error": "not found"}, status=404)
messages = db.list_ticket_messages(tid)
return web.json_response(
{
"ok": True,
"ticket": ticket_to_dict(
{**ticket, "message_count": len(messages)},
include_user=True,
),
"messages": [message_to_dict(m) for m in messages],
}
)
async def handle_admin_reply_ticket(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
try:
tid = int(request.match_info["ticket_id"])
except (KeyError, ValueError):
return web.json_response({"ok": False, "error": "bad id"}, status=400)
try:
body = await request.json()
except json.JSONDecodeError:
return web.json_response({"ok": False, "error": "invalid json"}, status=400)
text = clamp_body(str(body.get("body") or body.get("message") or ""))
if len(text) < 1:
return web.json_response({"ok": False, "error": "Пустое сообщение"}, status=400)
db: Database = request.app["db"]
ticket = db.get_ticket(tid)
if not ticket:
return web.json_response({"ok": False, "error": "not found"}, status=404)
try:
ticket = db.add_ticket_message(
tid,
author_role="admin",
author_id=None,
body=text,
)
except ValueError as exc:
return web.json_response({"ok": False, "error": str(exc)}, status=400)
bot = request.app.get("bot")
if bot is not None:
await notify_user_ticket_reply(
bot,
telegram_id=int(ticket["telegram_id"]),
ticket_id=tid,
subject=str(ticket.get("subject") or ""),
body=text,
)
messages = db.list_ticket_messages(tid)
return web.json_response(
{
"ok": True,
"ticket": ticket_to_dict(
{**ticket, "message_count": len(messages)},
include_user=True,
),
"messages": [message_to_dict(m) for m in messages],
}
)
async def handle_admin_close_ticket(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
try:
tid = int(request.match_info["ticket_id"])
except (KeyError, ValueError):
return web.json_response({"ok": False, "error": "bad id"}, status=400)
db: Database = request.app["db"]
ticket = db.set_ticket_status(tid, "closed")
if not ticket:
return web.json_response({"ok": False, "error": "not found"}, status=404)
messages = db.list_ticket_messages(tid)
return web.json_response(
{
"ok": True,
"ticket": ticket_to_dict(
{**ticket, "message_count": len(messages)},
include_user=True,
),
"messages": [message_to_dict(m) for m in messages],
}
)
async def handle_admin_reopen_ticket(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
try:
tid = int(request.match_info["ticket_id"])
except (KeyError, ValueError):
return web.json_response({"ok": False, "error": "bad id"}, status=400)
db: Database = request.app["db"]
ticket = db.set_ticket_status(tid, "open")
if not ticket:
return web.json_response({"ok": False, "error": "not found"}, status=404)
messages = db.list_ticket_messages(tid)
return web.json_response(
{
"ok": True,
"ticket": ticket_to_dict(
{**ticket, "message_count": len(messages)},
include_user=True,
),
"messages": [message_to_dict(m) for m in messages],
}
)
async def handle_admin_ai_get(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
db: Database = request.app["db"]
cfg = load_openrouter_config(db)
return web.json_response({"ok": True, "ai": config_public_dict(cfg)})
async def handle_admin_ai_save(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
try:
body = await request.json()
except json.JSONDecodeError:
return web.json_response({"ok": False, "error": "invalid json"}, status=400)
db: Database = request.app["db"]
data: dict[str, str] = {}
if "enabled" in body:
data["openrouter_enabled"] = "true" if bool(body.get("enabled")) else "false"
if "auto_reply" in body:
data["openrouter_auto_reply"] = (
"true" if bool(body.get("auto_reply")) else "false"
)
if "api_key" in body:
key = str(body.get("api_key") or "").strip()
if key == "__clear__":
data["openrouter_api_key"] = ""
elif key and "…" not in key and "•" not in key and "..." not in key:
data["openrouter_api_key"] = key[:200]
if "model" in body:
model = str(body.get("model") or "").strip()
if model:
data["openrouter_model"] = model[:120]
if "models" in body:
models_raw = body.get("models")
models: list[str] = []
if isinstance(models_raw, list):
models = [str(x).strip() for x in models_raw if str(x).strip()]
elif isinstance(models_raw, str):
models = [x.strip() for x in models_raw.split(",") if x.strip()]
# unique keep order
seen: set[str] = set()
uniq: list[str] = []
for m in models:
if m not in seen:
seen.add(m)
uniq.append(m[:120])
data["openrouter_models"] = json.dumps(uniq[:20], ensure_ascii=False)
if "system_prompt" in body:
prompt = str(body.get("system_prompt") or "").strip()
data["openrouter_system_prompt"] = prompt[:8000] if prompt else DEFAULT_SYSTEM_PROMPT
if data:
db.set_settings_map(data)
cfg = load_openrouter_config(db)
# если активная модель не в списке — добавим
if cfg.model and cfg.model not in cfg.models:
models = [cfg.model, *cfg.models][:20]
db.set_setting("openrouter_models", json.dumps(models, ensure_ascii=False))
cfg = load_openrouter_config(db)
return web.json_response({"ok": True, "ai": config_public_dict(cfg)})
async def handle_admin_ai_models(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
db: Database = request.app["db"]
cfg = load_openrouter_config(db)
settings: Settings = request.app["settings"]
if not cfg.api_key:
return web.json_response(
{
"ok": True,
"models": [{"id": m, "name": m} for m in DEFAULT_MODELS],
"source": "defaults",
"error": "Сначала сохраните API-ключ OpenRouter",
}
)
try:
models = await list_models(
cfg.api_key,
referer=(settings.webapp_url or "").rstrip("/") or "https://bot.example.com",
)
return web.json_response(
{"ok": True, "models": models, "source": "openrouter", "total": len(models)}
)
except Exception as exc: # noqa: BLE001
logger.exception("OpenRouter models list failed")
return web.json_response(
{
"ok": False,
"error": str(exc),
"models": [{"id": m, "name": m} for m in DEFAULT_MODELS],
"source": "defaults",
},
status=400,
)
async def handle_admin_ai_test(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
db: Database = request.app["db"]
cfg = load_openrouter_config(db)
if not cfg.api_key:
return web.json_response(
{"ok": False, "error": "Нет API-ключа"},
status=400,
)
try:
body = await request.json()
except json.JSONDecodeError:
body = {}
prompt = str(body.get("prompt") or "Как подключить VPN? Ответь коротко.").strip()
settings: Settings = request.app["settings"]
try:
raw, used = await chat_completion(
api_key=cfg.api_key,
model=cfg.model,
models=cfg.models_for_request(),
messages=[
{"role": "system", "content": cfg.system_prompt},
{"role": "user", "content": prompt},
],
referer=(settings.webapp_url or "").rstrip("/") or "https://bot.example.com",
max_tokens=400,
)
decision = parse_ai_decision(raw, used_model=used)
return web.json_response(
{
"ok": True,
"model": used,
"raw": raw[:2000],
"action": decision.action,
"reply": decision.reply or decision.reason,
}
)
except Exception as exc: # noqa: BLE001
logger.exception("OpenRouter test failed")
return web.json_response({"ok": False, "error": str(exc)}, status=400)
# ─── 3x-ui servers ───────────────────────────────────────────────────────────
def _serialize_xui_server(row: dict[str, Any], *, hide_password: bool = True) -> dict[str, Any]:
sub = str(row.get("subscription_url") or "").strip()
return {
"id": int(row["id"]),
"name": row.get("name") or "",
"api_url": row.get("api_url") or "",
"username": row.get("username") or "",
"password_set": bool(row.get("password")),
"password": "" if hide_password else (row.get("password") or ""),
"api_key_set": bool(str(row.get("api_key") or "").strip()),
"api_key": "" if hide_password else (row.get("api_key") or ""),
"inbound_id": row.get("inbound_id"),
"inbound_remark": row.get("inbound_remark") or "",
"subscription_url": sub,
"subscription_preview": build_subscription_link(sub, "exampleSubId") if sub else "",
"enabled": bool(row.get("enabled", True)),
"verify_ssl": bool(row.get("verify_ssl", True)),
"last_check_at": _iso(row.get("last_check_at")),
"last_check_ok": row.get("last_check_ok"),
"last_check_msg": row.get("last_check_msg") or "",
"created_at": _iso(row.get("created_at")),
"updated_at": _iso(row.get("updated_at")),
"panel": "3x-ui",
"panel_version": "3.5.0",
"protocol": "wireguard",
}
def _parse_inbound_id(value: Any) -> int | None:
if value is None or value == "":
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _xui_client_from_fields(
*,
api_url: str,
username: str = "",
password: str = "",
api_key: str = "",
verify_ssl: bool = True,
) -> XuiClient:
return XuiClient(
api_url,
username=username,
password=password,
api_key=api_key,
verify_ssl=verify_ssl,
)
async def _xui_probe(
*,
api_url: str,
username: str = "",
password: str = "",
api_key: str = "",
verify_ssl: bool = True,
) -> dict[str, Any]:
async with _xui_client_from_fields(
api_url=api_url,
username=username,
password=password,
api_key=api_key,
verify_ssl=verify_ssl,
) as client:
info = await client.test_connection()
return {
"ok": info.ok,
"message": info.message,
"version": info.version,
"inbounds_total": info.inbounds_total,
"wireguard": [
{
"id": i.id,
"remark": i.remark,
"protocol": i.protocol,
"port": i.port,
"enable": i.enable,
"clients_count": i.clients_count,
}
for i in info.wireguard
],
}
async def handle_admin_xui_list(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
db: Database = request.app["db"]
rows = db.list_xui_servers()
return web.json_response(
{
"ok": True,
"servers": [_serialize_xui_server(r) for r in rows],
"panel_version": "3.5.0",
}
)
async def handle_admin_xui_get(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
try:
server_id = int(request.match_info["server_id"])
except (KeyError, ValueError):
return web.json_response({"ok": False, "error": "bad id"}, status=400)
db: Database = request.app["db"]
row = db.get_xui_server(server_id)
if not row:
return web.json_response({"ok": False, "error": "not found"}, status=404)
return web.json_response({"ok": True, "server": _serialize_xui_server(row)})
async def handle_admin_xui_create(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
try:
body = await request.json()
except json.JSONDecodeError:
return web.json_response({"ok": False, "error": "invalid json"}, status=400)
name = str(body.get("name") or "").strip()
api_url = str(body.get("api_url") or "").strip()
username = str(body.get("username") or "").strip()
password = str(body.get("password") or "")
api_key = str(body.get("api_key") or "").strip()
subscription_url = str(body.get("subscription_url") or "").strip()
if not name or not api_url:
return web.json_response(
{"ok": False, "error": "Нужны name и api_url"},
status=400,
)
if not api_key and (not username or not password):
return web.json_response(
{"ok": False, "error": "Нужен API Token или логин+пароль"},
status=400,
)
inbound_id = _parse_inbound_id(body.get("inbound_id"))
inbound_remark = str(body.get("inbound_remark") or "").strip() or None
enabled = bool(body.get("enabled", True))
verify_ssl = bool(body.get("verify_ssl", True))
db: Database = request.app["db"]
row = db.create_xui_server(
name=name,
api_url=api_url,
username=username,
password=password,
api_key=api_key,
subscription_url=subscription_url,
inbound_id=inbound_id,
inbound_remark=inbound_remark,
enabled=enabled,
verify_ssl=verify_ssl,
)
return web.json_response({"ok": True, "server": _serialize_xui_server(row)})
async def handle_admin_xui_update(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
try:
server_id = int(request.match_info["server_id"])
except (KeyError, ValueError):
return web.json_response({"ok": False, "error": "bad id"}, status=400)
try:
body = await request.json()
except json.JSONDecodeError:
return web.json_response({"ok": False, "error": "invalid json"}, status=400)
db: Database = request.app["db"]
existing = db.get_xui_server(server_id)
if not existing:
return web.json_response({"ok": False, "error": "not found"}, status=404)
fields: dict[str, Any] = {}
if "name" in body:
name = str(body.get("name") or "").strip()
if not name:
return web.json_response({"ok": False, "error": "name пуст"}, status=400)
fields["name"] = name
if "api_url" in body:
api_url = str(body.get("api_url") or "").strip()
if not api_url:
return web.json_response({"ok": False, "error": "api_url пуст"}, status=400)
fields["api_url"] = api_url
if "username" in body:
fields["username"] = str(body.get("username") or "").strip()
if "password" in body:
password = str(body.get("password") or "")
if password:
fields["password"] = password
if "api_key" in body:
api_key = str(body.get("api_key") or "").strip()
if api_key:
fields["api_key"] = api_key
if "subscription_url" in body:
fields["subscription_url"] = str(body.get("subscription_url") or "").strip()
if "inbound_id" in body:
fields["inbound_id"] = _parse_inbound_id(body.get("inbound_id"))
if "inbound_remark" in body:
fields["inbound_remark"] = str(body.get("inbound_remark") or "").strip() or None
if "enabled" in body:
fields["enabled"] = bool(body.get("enabled"))
if "verify_ssl" in body:
fields["verify_ssl"] = bool(body.get("verify_ssl"))
row = db.update_xui_server(server_id, **fields)
if not row:
return web.json_response({"ok": False, "error": "not found"}, status=404)
return web.json_response({"ok": True, "server": _serialize_xui_server(row)})
async def handle_admin_xui_delete(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
try:
server_id = int(request.match_info["server_id"])
except (KeyError, ValueError):
return web.json_response({"ok": False, "error": "bad id"}, status=400)
db: Database = request.app["db"]
deleted = db.delete_xui_server(server_id)
if not deleted:
return web.json_response({"ok": False, "error": "not found"}, status=404)
return web.json_response({"ok": True})
async def handle_admin_xui_test(request: web.Request) -> web.Response:
"""Проверка панели по полям формы (ещё не сохранено)."""
denied = _require_admin(request)
if denied:
return denied
try:
body = await request.json()
except json.JSONDecodeError:
return web.json_response({"ok": False, "error": "invalid json"}, status=400)
api_url = str(body.get("api_url") or "").strip()
username = str(body.get("username") or "").strip()
password = str(body.get("password") or "")
api_key = str(body.get("api_key") or "").strip()
verify_ssl = bool(body.get("verify_ssl", True))
if not api_url:
return web.json_response(
{"ok": False, "error": "Нужен api_url"},
status=400,
)
if not api_key and (not username or not password):
return web.json_response(
{"ok": False, "error": "Нужен API Token или логин+пароль"},
status=400,
)
try:
result = await _xui_probe(
api_url=api_url,
username=username,
password=password,
api_key=api_key,
verify_ssl=verify_ssl,
)
return web.json_response({"ok": True, **result})
except XuiError as exc:
return web.json_response({"ok": False, "error": str(exc)}, status=400)
except Exception as exc: # noqa: BLE001
logger.exception("3x-ui test failed")
return web.json_response({"ok": False, "error": str(exc)}, status=400)
async def handle_admin_xui_test_saved(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
try:
server_id = int(request.match_info["server_id"])
except (KeyError, ValueError):
return web.json_response({"ok": False, "error": "bad id"}, status=400)
db: Database = request.app["db"]
row = db.get_xui_server(server_id)
if not row:
return web.json_response({"ok": False, "error": "not found"}, status=404)
try:
result = await _xui_probe(
api_url=str(row["api_url"]),
username=str(row.get("username") or ""),
password=str(row.get("password") or ""),
api_key=str(row.get("api_key") or ""),
verify_ssl=bool(row.get("verify_ssl", True)),
)
db.set_xui_server_check(
server_id,
ok=bool(result.get("ok")),
message=str(result.get("message") or ""),
)
refreshed = db.get_xui_server(server_id)
return web.json_response(
{
"ok": True,
**result,
"server": _serialize_xui_server(refreshed) if refreshed else None,
}
)
except Exception as exc: # noqa: BLE001
logger.exception("3x-ui saved test failed")
db.set_xui_server_check(server_id, ok=False, message=str(exc))
return web.json_response({"ok": False, "error": str(exc)}, status=400)
async def handle_admin_xui_inbounds(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
try:
server_id = int(request.match_info["server_id"])
except (KeyError, ValueError):
return web.json_response({"ok": False, "error": "bad id"}, status=400)
db: Database = request.app["db"]
row = db.get_xui_server(server_id)
if not row:
return web.json_response({"ok": False, "error": "not found"}, status=404)
protocol = (request.rel_url.query.get("protocol") or "wireguard").strip().lower()
try:
async with _xui_client_from_fields(
api_url=str(row["api_url"]),
username=str(row.get("username") or ""),
password=str(row.get("password") or ""),
api_key=str(row.get("api_key") or ""),
verify_ssl=bool(row.get("verify_ssl", True)),
) as client:
await client.ensure_auth()
if protocol in {"wireguard", "wg"}:
items = await client.list_wireguard_inbounds()
else:
items = await client.list_inbounds()
return web.json_response(
{
"ok": True,
"inbounds": [
{
"id": i.id,
"remark": i.remark,
"protocol": i.protocol,
"port": i.port,
"enable": i.enable,
"clients_count": i.clients_count,
"is_wireguard": i.is_wireguard,
}
for i in items
],
}
)
except XuiError as exc:
return web.json_response({"ok": False, "error": str(exc)}, status=400)
except Exception as exc: # noqa: BLE001
logger.exception("3x-ui inbounds failed")
return web.json_response({"ok": False, "error": str(exc)}, status=400)
async def handle_admin_wg_settings_get(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
db: Database = request.app["db"]
settings = load_wg_purchase_settings(db)
return web.json_response({"ok": True, **settings.as_dict()})
async def handle_admin_wg_settings_save(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
try:
body = await request.json()
except json.JSONDecodeError:
return web.json_response({"ok": False, "error": "invalid json"}, status=400)
enabled = body.get("enabled")
price = body.get("price")
kwargs: dict[str, Any] = {}
if enabled is not None:
kwargs["enabled"] = bool(enabled)
if price is not None:
try:
kwargs["price"] = float(price)
except (TypeError, ValueError):
return web.json_response({"ok": False, "error": "bad price"}, status=400)
db: Database = request.app["db"]
settings = save_wg_purchase_settings(db, **kwargs)
return web.json_response({"ok": True, **settings.as_dict()})
def _serialize_awg_server(row: dict[str, Any], *, hide_token: bool = True) -> dict[str, Any]:
token = str(row.get("api_token") or "")
return {
"id": int(row["id"]),
"name": row.get("name") or "",
"api_url": row.get("api_url") or "",
"api_token_set": bool(token.strip()),
"api_token_masked": (token[:8] + "…") if hide_token and len(token) > 8 else ("" if hide_token else token),
"panel_server_id": int(row.get("panel_server_id") or 0),
"protocol": row.get("protocol") or DEFAULT_PROTOCOL,
"enabled": bool(row.get("enabled", True)),
"verify_ssl": bool(row.get("verify_ssl", True)),
"last_check_at": _iso(row.get("last_check_at")),
"last_check_ok": row.get("last_check_ok"),
"last_check_msg": row.get("last_check_msg") or "",
"created_at": _iso(row.get("created_at")),
"updated_at": _iso(row.get("updated_at")),
}
async def handle_admin_awg_list(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
db: Database = request.app["db"]
rows = db.list_awg_servers()
return web.json_response(
{"ok": True, "servers": [_serialize_awg_server(r) for r in rows]}
)
async def handle_admin_awg_get(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
try:
server_id = int(request.match_info["server_id"])
except (KeyError, ValueError):
return web.json_response({"ok": False, "error": "bad id"}, status=400)
db: Database = request.app["db"]
row = db.get_awg_server(server_id)
if not row:
return web.json_response({"ok": False, "error": "not found"}, status=404)
return web.json_response({"ok": True, "server": _serialize_awg_server(row)})
async def handle_admin_awg_create(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
try:
body = await request.json()
except json.JSONDecodeError:
return web.json_response({"ok": False, "error": "invalid json"}, status=400)
name = str(body.get("name") or "").strip()
api_url = str(body.get("api_url") or "").strip()
api_token = str(body.get("api_token") or "").strip()
if not name or not api_url or not api_token:
return web.json_response(
{"ok": False, "error": "Нужны название, API URL и API Token"},
status=400,
)
try:
panel_server_id = int(body.get("panel_server_id") or 0)
except (TypeError, ValueError):
return web.json_response({"ok": False, "error": "bad panel_server_id"}, status=400)
protocol = str(body.get("protocol") or DEFAULT_PROTOCOL).strip() or DEFAULT_PROTOCOL
db: Database = request.app["db"]
row = db.create_awg_server(
name=name,
api_url=api_url,
api_token=api_token,
panel_server_id=panel_server_id,
protocol=protocol,
enabled=bool(body.get("enabled", True)),
verify_ssl=bool(body.get("verify_ssl", True)),
)
return web.json_response({"ok": True, "server": _serialize_awg_server(row)})
async def handle_admin_awg_update(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
try:
server_id = int(request.match_info["server_id"])
except (KeyError, ValueError):
return web.json_response({"ok": False, "error": "bad id"}, status=400)
try:
body = await request.json()
except json.JSONDecodeError:
return web.json_response({"ok": False, "error": "invalid json"}, status=400)
db: Database = request.app["db"]
existing = db.get_awg_server(server_id)
if not existing:
return web.json_response({"ok": False, "error": "not found"}, status=404)
fields: dict[str, Any] = {}
if "name" in body:
fields["name"] = str(body.get("name") or "").strip()
if "api_url" in body:
fields["api_url"] = str(body.get("api_url") or "").strip()
if "api_token" in body:
token = str(body.get("api_token") or "").strip()
if token:
fields["api_token"] = token
if "panel_server_id" in body:
try:
fields["panel_server_id"] = int(body.get("panel_server_id") or 0)
except (TypeError, ValueError):
return web.json_response({"ok": False, "error": "bad panel_server_id"}, status=400)
if "protocol" in body:
fields["protocol"] = str(body.get("protocol") or DEFAULT_PROTOCOL).strip() or DEFAULT_PROTOCOL
if "enabled" in body:
fields["enabled"] = bool(body.get("enabled"))
if "verify_ssl" in body:
fields["verify_ssl"] = bool(body.get("verify_ssl"))
row = db.update_awg_server(server_id, **fields)
return web.json_response({"ok": True, "server": _serialize_awg_server(row) if row else None})
async def handle_admin_awg_delete(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
try:
server_id = int(request.match_info["server_id"])
except (KeyError, ValueError):
return web.json_response({"ok": False, "error": "bad id"}, status=400)
db: Database = request.app["db"]
deleted = db.delete_awg_server(server_id)
if not deleted:
return web.json_response({"ok": False, "error": "not found"}, status=404)
return web.json_response({"ok": True})
async def handle_admin_awg_discover(request: web.Request) -> web.Response:
"""Список нод с панели (backup inventory)."""
denied = _require_admin(request)
if denied:
return denied
try:
body = await request.json()
except json.JSONDecodeError:
return web.json_response({"ok": False, "error": "invalid json"}, status=400)
api_url = str(body.get("api_url") or "").strip()
api_token = str(body.get("api_token") or "").strip()
verify_ssl = bool(body.get("verify_ssl", True))
if not api_url or not api_token:
return web.json_response(
{"ok": False, "error": "Укажите API URL и Token"}, status=400
)
try:
async with AwgClient(api_url, api_token=api_token, verify_ssl=verify_ssl) as client:
servers = await client.list_panel_servers()
return web.json_response(
{
"ok": True,
"servers": [
{
"id": s.id,
"name": s.name,
"host": s.host,
"protocols": s.protocols,
"has_awg2": s.has_awg2,
}
for s in servers
],
}
)
except AwgError as exc:
return web.json_response({"ok": False, "error": str(exc)}, status=400)
except Exception as exc: # noqa: BLE001
logger.exception("AWG discover failed")
return web.json_response({"ok": False, "error": str(exc)}, status=400)
async def handle_admin_awg_test(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
try:
body = await request.json()
except json.JSONDecodeError:
return web.json_response({"ok": False, "error": "invalid json"}, status=400)
api_url = str(body.get("api_url") or "").strip()
api_token = str(body.get("api_token") or "").strip()
try:
panel_server_id = int(body.get("panel_server_id") or 0)
except (TypeError, ValueError):
return web.json_response({"ok": False, "error": "bad panel_server_id"}, status=400)
protocol = str(body.get("protocol") or DEFAULT_PROTOCOL).strip() or DEFAULT_PROTOCOL
verify_ssl = bool(body.get("verify_ssl", True))
try:
async with AwgClient(api_url, api_token=api_token, verify_ssl=verify_ssl) as client:
result = await client.probe(panel_server_id, protocol=protocol)
return web.json_response(
{
"ok": result.ok,
"alive": result.alive,
"ping_ms": result.ping_ms,
"protocols": result.protocols,
"message": result.message,
}
)
except AwgError as exc:
return web.json_response({"ok": False, "error": str(exc)}, status=400)
except Exception as exc: # noqa: BLE001
logger.exception("AWG test failed")
return web.json_response({"ok": False, "error": str(exc)}, status=400)
async def handle_admin_awg_test_saved(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
try:
server_id = int(request.match_info["server_id"])
except (KeyError, ValueError):
return web.json_response({"ok": False, "error": "bad id"}, status=400)
db: Database = request.app["db"]
row = db.get_awg_server(server_id)
if not row:
return web.json_response({"ok": False, "error": "not found"}, status=404)
try:
async with AwgClient(
str(row["api_url"]),
api_token=str(row.get("api_token") or ""),
verify_ssl=bool(row.get("verify_ssl", True)),
) as client:
result = await client.probe(
int(row.get("panel_server_id") or 0),
protocol=str(row.get("protocol") or DEFAULT_PROTOCOL),
)
db.set_awg_server_check(server_id, ok=result.ok, message=result.message)
refreshed = db.get_awg_server(server_id)
return web.json_response(
{
"ok": result.ok,
"alive": result.alive,
"ping_ms": result.ping_ms,
"protocols": result.protocols,
"message": result.message,
"server": _serialize_awg_server(refreshed) if refreshed else None,
}
)
except Exception as exc: # noqa: BLE001
db.set_awg_server_check(server_id, ok=False, message=str(exc))
return web.json_response({"ok": False, "error": str(exc)}, status=400)
async def handle_admin_awg_settings_get(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
db: Database = request.app["db"]
settings = load_awg_purchase_settings(db)
return web.json_response({"ok": True, **settings.as_dict()})
async def handle_admin_awg_settings_save(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
try:
body = await request.json()
except json.JSONDecodeError:
return web.json_response({"ok": False, "error": "invalid json"}, status=400)
enabled = body.get("enabled")
price = body.get("price")
kwargs: dict[str, Any] = {}
if enabled is not None:
kwargs["enabled"] = bool(enabled)
if price is not None:
try:
kwargs["price"] = float(price)
except (TypeError, ValueError):
return web.json_response({"ok": False, "error": "bad price"}, status=400)
db: Database = request.app["db"]
settings = save_awg_purchase_settings(db, **kwargs)
return web.json_response({"ok": True, **settings.as_dict()})
def _serialize_codes(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for row in rows:
out.append(
{
"code": row["code"],
"amount": float(row["amount_rub"]),
"created_at": _iso(row.get("created_at")),
"created_by": row.get("created_by"),
"used_at": _iso(row.get("used_at")),
"used_by": row.get("used_by"),
}
)
return out
def _iso(value: Any) -> str | None:
if value is None:
return None
if isinstance(value, datetime):
return value.isoformat()
return str(value)
def _tariffs_path(request: web.Request) -> str:
settings: Settings = request.app["settings"]
return settings.tariffs_path
def _persist_tariffs(request: web.Request, tariffs: list[Tariff]) -> None:
path = _tariffs_path(request)
save_tariffs(path, tariffs)
shared: list[Tariff] = request.app["tariffs"]
replace_shared_tariffs(shared, tariffs)
async def handle_admin_tariffs_list(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
tariffs: list[Tariff] = request.app["tariffs"]
return web.json_response(
{"ok": True, "tariffs": [t.to_admin_dict() for t in tariffs]}
)
async def handle_admin_tariffs_create(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
try:
body = await request.json()
except json.JSONDecodeError:
return web.json_response({"ok": False, "error": "invalid json"}, status=400)
shared: list[Tariff] = list(request.app["tariffs"])
default_squads = shared[0].squad_uuids if shared else ()
try:
tariff = parse_tariff_payload(body, default_squads=default_squads)
except TariffError as exc:
return web.json_response({"ok": False, "error": str(exc)}, status=400)
if any(t.id == tariff.id for t in shared):
return web.json_response(
{"ok": False, "error": f"Тариф «{tariff.id}» уже есть"},
status=400,
)
shared.append(tariff)
try:
_persist_tariffs(request, shared)
except OSError as exc:
logger.exception("tariffs save failed")
return web.json_response(
{"ok": False, "error": f"Не удалось записать tariffs.json: {exc}"},
status=500,
)
return web.json_response({"ok": True, "tariff": tariff.to_admin_dict()})
async def handle_admin_tariffs_update(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
tariff_id = str(request.match_info.get("tariff_id") or "").strip()
try:
body = await request.json()
except json.JSONDecodeError:
return web.json_response({"ok": False, "error": "invalid json"}, status=400)
shared: list[Tariff] = list(request.app["tariffs"])
existing = next((t for t in shared if t.id == tariff_id), None)
if existing is None:
return web.json_response({"ok": False, "error": "Тариф не найден"}, status=404)
payload = dict(body)
payload["id"] = tariff_id
try:
updated = parse_tariff_payload(payload, existing=existing)
except TariffError as exc:
return web.json_response({"ok": False, "error": str(exc)}, status=400)
shared = [updated if t.id == tariff_id else t for t in shared]
try:
_persist_tariffs(request, shared)
except OSError as exc:
logger.exception("tariffs save failed")
return web.json_response(
{"ok": False, "error": f"Не удалось записать tariffs.json: {exc}"},
status=500,
)
return web.json_response({"ok": True, "tariff": updated.to_admin_dict()})
async def handle_admin_tariffs_delete(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
tariff_id = str(request.match_info.get("tariff_id") or "").strip()
shared: list[Tariff] = list(request.app["tariffs"])
if not any(t.id == tariff_id for t in shared):
return web.json_response({"ok": False, "error": "Тариф не найден"}, status=404)
if len(shared) <= 1:
return web.json_response(
{"ok": False, "error": "Нельзя удалить последний тариф"},
status=400,
)
shared = [t for t in shared if t.id != tariff_id]
try:
_persist_tariffs(request, shared)
except OSError as exc:
logger.exception("tariffs save failed")
return web.json_response(
{"ok": False, "error": f"Не удалось записать tariffs.json: {exc}"},
status=500,
)
return web.json_response({"ok": True})
async def handle_admin_payments_get(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
settings: Settings = request.app["settings"]
return web.json_response({"ok": True, "payments": payments_public_dict(settings)})
async def handle_admin_payments_save(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
try:
body = await request.json()
except json.JSONDecodeError:
return web.json_response({"ok": False, "error": "invalid json"}, status=400)
try:
settings = apply_payments_body(body if isinstance(body, dict) else {})
apply_payment_runtime(request.app, settings)
except OSError as exc:
logger.exception("payments .env write failed")
return web.json_response(
{"ok": False, "error": f"Не удалось записать .env: {exc}"},
status=500,
)
except SystemExit as exc:
return web.json_response(
{"ok": False, "error": str(exc) or "Ошибка настроек"},
status=400,
)
except Exception as exc: # noqa: BLE001
logger.exception("payments save failed")
return web.json_response({"ok": False, "error": str(exc)}, status=500)
return web.json_response(
{"ok": True, "payments": payments_public_dict(settings)}
)
async def handle_admin_smtp_get(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
settings: Settings = request.app["settings"]
return web.json_response({"ok": True, "smtp": smtp_public_dict(settings)})
async def handle_admin_smtp_save(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
try:
body = await request.json()
except json.JSONDecodeError:
return web.json_response({"ok": False, "error": "invalid json"}, status=400)
try:
settings = apply_smtp_body(body if isinstance(body, dict) else {})
request.app["settings"] = settings
inject = request.app.get("inject")
if inject is not None:
inject.settings = settings
except OSError as exc:
logger.exception("smtp .env write failed")
return web.json_response(
{"ok": False, "error": f"Не удалось записать .env: {exc}"},
status=500,
)
except SystemExit as exc:
return web.json_response(
{"ok": False, "error": str(exc) or "Ошибка настроек"},
status=400,
)
except Exception as exc: # noqa: BLE001
logger.exception("smtp save failed")
return web.json_response({"ok": False, "error": str(exc)}, status=500)
return web.json_response({"ok": True, "smtp": smtp_public_dict(settings)})
async def handle_admin_smtp_test(request: web.Request) -> web.Response:
denied = _require_admin(request)
if denied:
return denied
settings: Settings = request.app["settings"]
try:
body = await request.json()
except json.JSONDecodeError:
body = {}
to = str((body or {}).get("to") or settings.smtp_from or "").strip()
if not to:
return web.json_response(
{"ok": False, "error": "Укажите email получателя"}, status=400
)
from services.mailer import render_smtp_test_email, send_email, smtp_configured
if not smtp_configured(settings):
return web.json_response(
{"ok": False, "error": "SMTP не настроен"}, status=400
)
try:
await send_email(
settings,
to=to,
subject="Тест SMTP — VPN Service",
html=render_smtp_test_email(webapp_url=settings.webapp_url or ""),
)
except Exception as exc: # noqa: BLE001
logger.exception("smtp test failed")
return web.json_response({"ok": False, "error": str(exc)}, status=500)
return web.json_response({"ok": True})