834 lines
32 KiB
Python
834 lines
32 KiB
Python
"""Доп. API: рефералка, trial, промо, пароль, сессии, HWID, legal, бэкапы."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
from uuid import UUID
|
|
|
|
from aiohttp import web
|
|
from remnawave.models import HWIDDeleteRequest
|
|
|
|
from config import Settings, ROOT_DIR
|
|
from services.db import AuthError, BotUser, Database
|
|
from services.legal_pages import (
|
|
html_aup,
|
|
html_contacts,
|
|
html_cookies,
|
|
html_delivery,
|
|
html_help,
|
|
html_offer,
|
|
html_privacy,
|
|
html_refund,
|
|
html_site_rules,
|
|
html_status,
|
|
html_support,
|
|
html_terms,
|
|
html_vpn_usage,
|
|
)
|
|
from services.mailer import render_password_reset_email, send_email, smtp_configured
|
|
from services.pg_backup import (
|
|
delete_backup,
|
|
list_backups,
|
|
resolve_backup_path,
|
|
restore_from_backup_name,
|
|
restore_from_bytes,
|
|
run_pg_dump_backup,
|
|
save_uploaded_backup,
|
|
)
|
|
from services.rate_limit import allow, client_ip
|
|
from services.remnawave_users import find_panel_user
|
|
from services.trial import grant_trial, trial_days_setting, trial_enabled
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _require_user(request: web.Request) -> BotUser:
|
|
from webapp_server import _require_user as req
|
|
|
|
return req(request)
|
|
|
|
|
|
def _user_payload(user: BotUser) -> dict:
|
|
from webapp_server import _user_payload as payload
|
|
|
|
return payload(user)
|
|
|
|
|
|
def _session_token(request: web.Request) -> str | None:
|
|
from webapp_server import _session_token as tok
|
|
|
|
return tok(request)
|
|
|
|
|
|
def _set_session_cookie(resp, request, token: str) -> None:
|
|
from webapp_server import _set_session_cookie as setc
|
|
|
|
setc(resp, request, token)
|
|
|
|
|
|
def register_growth_routes(app: web.Application) -> None:
|
|
app.router.add_get("/api/referral", handle_referral_get)
|
|
app.router.add_post("/api/trial", handle_trial)
|
|
app.router.add_get("/api/account/notifications", handle_notify_get)
|
|
app.router.add_post("/api/account/notifications", handle_notify_set)
|
|
app.router.add_get("/api/account/sessions", handle_sessions_list)
|
|
app.router.add_post("/api/account/sessions/logout-all", handle_sessions_logout_all)
|
|
app.router.add_post("/api/auth/password/forgot", handle_password_forgot)
|
|
app.router.add_post("/api/auth/password/reset", handle_password_reset)
|
|
app.router.add_get("/api/devices/hwid", handle_hwid_list)
|
|
app.router.add_post("/api/devices/hwid/delete", handle_hwid_delete)
|
|
app.router.add_get("/terms", handle_terms_page)
|
|
app.router.add_get("/offer", handle_offer_page)
|
|
app.router.add_get("/privacy", handle_privacy_page)
|
|
app.router.add_get("/aup", handle_aup_page)
|
|
app.router.add_get("/vpn-usage", handle_vpn_usage_page)
|
|
app.router.add_get("/vpn_usage", handle_vpn_usage_page)
|
|
app.router.add_get("/rules", handle_rules_page)
|
|
app.router.add_get("/refund", handle_refund_page)
|
|
app.router.add_get("/delivery", handle_delivery_page)
|
|
app.router.add_get("/contacts", handle_contacts_page)
|
|
app.router.add_get("/help", handle_help_page)
|
|
app.router.add_get("/support", handle_support_page)
|
|
app.router.add_get("/cookies", handle_cookies_page)
|
|
app.router.add_get("/status", handle_status_page)
|
|
app.router.add_get("/api/admin/promos", handle_admin_promos_list)
|
|
app.router.add_post("/api/admin/promos", handle_admin_promos_create)
|
|
app.router.add_post("/api/admin/promos/{promo_id}/toggle", handle_admin_promos_toggle)
|
|
app.router.add_post("/api/admin/users/ban", handle_admin_user_ban)
|
|
app.router.add_post("/api/admin/users/delete", handle_admin_user_delete)
|
|
app.router.add_get("/api/admin/payments/journal", handle_admin_payment_journal)
|
|
app.router.add_get("/api/admin/backups", handle_admin_backups_list)
|
|
app.router.add_post("/api/admin/backups", handle_admin_backups_run)
|
|
app.router.add_get("/api/admin/backups/{name}/download", handle_admin_backups_download)
|
|
app.router.add_delete("/api/admin/backups/{name}", handle_admin_backups_delete)
|
|
app.router.add_post("/api/admin/backups/delete", handle_admin_backups_delete_post)
|
|
app.router.add_post("/api/admin/backups/restore", handle_admin_backups_restore)
|
|
app.router.add_post("/api/admin/backups/upload", handle_admin_backups_upload)
|
|
app.router.add_get("/api/admin/growth-settings", handle_admin_growth_get)
|
|
app.router.add_post("/api/admin/growth-settings", handle_admin_growth_set)
|
|
|
|
|
|
async def handle_referral_get(request: web.Request) -> web.Response:
|
|
settings: Settings = request.app["settings"]
|
|
db: Database = request.app["db"]
|
|
try:
|
|
user = _require_user(request)
|
|
except web.HTTPUnauthorized:
|
|
return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
|
|
code = db.ensure_referral_code(user.telegram_id)
|
|
bot = str(request.app.get("bot_username") or "").lstrip("@")
|
|
link = f"https://t.me/{bot}?start=ref_{code}" if bot else ""
|
|
web_link = f"{settings.webapp_url.rstrip('/')}/?ref={code}" if settings.webapp_url else ""
|
|
return web.json_response(
|
|
{
|
|
"ok": True,
|
|
"code": code,
|
|
"telegram_link": link,
|
|
"web_link": web_link,
|
|
"bonus_referrer": float(db.get_setting("referral_bonus_referrer") or "50"),
|
|
"bonus_referee": float(db.get_setting("referral_bonus_referee") or "50"),
|
|
}
|
|
)
|
|
|
|
|
|
async def handle_trial(request: web.Request) -> web.Response:
|
|
db: Database = request.app["db"]
|
|
sdk = request.app["sdk"]
|
|
try:
|
|
user = _require_user(request)
|
|
except web.HTTPUnauthorized:
|
|
return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
|
|
if not allow(request, "trial", limit=5, window_sec=3600):
|
|
return web.json_response({"ok": False, "error": "Слишком часто"}, status=429)
|
|
result = await grant_trial(
|
|
db=db, sdk=sdk, telegram_id=user.telegram_id, tariffs=request.app["tariffs"]
|
|
)
|
|
status = 200 if result.ok else 400
|
|
return web.json_response(
|
|
{
|
|
"ok": result.ok,
|
|
"error": None if result.ok else result.message,
|
|
"message": result.message,
|
|
"expire_at": result.expire_at.isoformat() if result.expire_at else None,
|
|
"trial_enabled": trial_enabled(db),
|
|
"trial_days": trial_days_setting(db),
|
|
},
|
|
status=status,
|
|
)
|
|
|
|
|
|
async def handle_notify_get(request: web.Request) -> web.Response:
|
|
try:
|
|
user = _require_user(request)
|
|
except web.HTTPUnauthorized:
|
|
return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
|
|
return web.json_response(
|
|
{
|
|
"ok": True,
|
|
"notify_telegram": bool(user.notify_telegram),
|
|
"notify_email": bool(user.notify_email),
|
|
}
|
|
)
|
|
|
|
|
|
async def handle_notify_set(request: web.Request) -> web.Response:
|
|
db: Database = request.app["db"]
|
|
try:
|
|
user = _require_user(request)
|
|
except web.HTTPUnauthorized:
|
|
return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
|
|
try:
|
|
body = await request.json()
|
|
except json.JSONDecodeError:
|
|
return web.json_response({"ok": False, "error": "invalid json"}, status=400)
|
|
updated = db.set_notify_prefs(
|
|
user.telegram_id,
|
|
notify_telegram=body.get("notify_telegram"),
|
|
notify_email=body.get("notify_email"),
|
|
)
|
|
return web.json_response(
|
|
{
|
|
"ok": True,
|
|
"notify_telegram": updated.notify_telegram,
|
|
"notify_email": updated.notify_email,
|
|
"user": _user_payload(updated),
|
|
}
|
|
)
|
|
|
|
|
|
async def handle_sessions_list(request: web.Request) -> web.Response:
|
|
db: Database = request.app["db"]
|
|
try:
|
|
user = _require_user(request)
|
|
except web.HTTPUnauthorized:
|
|
return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
|
|
current = _session_token(request)
|
|
items = []
|
|
for s in db.list_sessions(user.telegram_id):
|
|
items.append(
|
|
{
|
|
"token_hint": (s["token"][:6] + "…") if s.get("token") else "",
|
|
"current": s.get("token") == current,
|
|
"created_at": s["created_at"].isoformat()
|
|
if hasattr(s["created_at"], "isoformat")
|
|
else str(s["created_at"]),
|
|
"expires_at": s["expires_at"].isoformat()
|
|
if hasattr(s["expires_at"], "isoformat")
|
|
else str(s["expires_at"]),
|
|
"user_agent": s.get("user_agent"),
|
|
"ip": s.get("ip"),
|
|
}
|
|
)
|
|
return web.json_response({"ok": True, "sessions": items})
|
|
|
|
|
|
async def handle_sessions_logout_all(request: web.Request) -> web.Response:
|
|
db: Database = request.app["db"]
|
|
try:
|
|
user = _require_user(request)
|
|
except web.HTTPUnauthorized:
|
|
return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
|
|
keep = _session_token(request)
|
|
deleted = db.clear_all_sessions(user.telegram_id, keep_token=keep)
|
|
return web.json_response({"ok": True, "deleted": deleted})
|
|
|
|
|
|
async def handle_password_forgot(request: web.Request) -> web.Response:
|
|
import os
|
|
|
|
from dotenv import load_dotenv
|
|
|
|
from config import get_settings
|
|
|
|
# Подтянуть .env (SMTP из админки) и обновить runtime settings
|
|
env_file = (os.getenv("ENV_FILE") or "").strip() or None
|
|
load_dotenv(env_file, override=True)
|
|
settings = get_settings(require_bot_token=False)
|
|
request.app["settings"] = settings
|
|
db: Database = request.app["db"]
|
|
if not allow(request, "pwd_forgot", limit=5, window_sec=600):
|
|
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)
|
|
email = str(body.get("email") or "").strip()
|
|
if not email or "@" not in email:
|
|
return web.json_response({"ok": False, "error": "Укажите корректный email"}, status=400)
|
|
|
|
if not smtp_configured(settings):
|
|
return web.json_response(
|
|
{
|
|
"ok": False,
|
|
"error": "Сброс пароля недоступен: SMTP не настроен (Админка → SMTP)",
|
|
},
|
|
status=503,
|
|
)
|
|
if not (settings.webapp_url or "").strip():
|
|
return web.json_response(
|
|
{
|
|
"ok": False,
|
|
"error": "Сброс пароля недоступен: не задан WEBAPP_URL",
|
|
},
|
|
status=503,
|
|
)
|
|
|
|
# Не раскрываем, есть ли email в базе
|
|
user = db.get_user_by_email(email)
|
|
if user and user.email:
|
|
token = db.create_password_reset_token(user.telegram_id)
|
|
link = f"{settings.webapp_url.rstrip('/')}/?reset={token}"
|
|
try:
|
|
await send_email(
|
|
settings,
|
|
to=user.email,
|
|
subject="Сброс пароля — VPN Service",
|
|
html=render_password_reset_email(
|
|
reset_url=link,
|
|
webapp_url=settings.webapp_url or "",
|
|
),
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.exception("password reset mail failed")
|
|
return web.json_response(
|
|
{"ok": False, "error": f"Не удалось отправить письмо: {exc}"},
|
|
status=502,
|
|
)
|
|
return web.json_response(
|
|
{
|
|
"ok": True,
|
|
"message": "Если email найден — отправили ссылку для сброса. Проверьте почту и «Спам».",
|
|
}
|
|
)
|
|
|
|
|
|
async def handle_password_reset(request: web.Request) -> web.Response:
|
|
db: Database = request.app["db"]
|
|
if not allow(request, "pwd_reset", limit=10, window_sec=600):
|
|
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)
|
|
token = str(body.get("token") or "")
|
|
password = str(body.get("password") or "")
|
|
try:
|
|
tid = db.consume_password_reset_token(token)
|
|
db.set_password_hash(tid, password)
|
|
db.clear_all_sessions(tid)
|
|
session = db.create_session(
|
|
tid, user_agent=request.headers.get("User-Agent"), ip=client_ip(request)
|
|
)
|
|
except AuthError as exc:
|
|
return web.json_response({"ok": False, "error": str(exc)}, status=400)
|
|
user = db.get_user(tid)
|
|
assert user is not None
|
|
resp = web.json_response(
|
|
{"ok": True, "token": session, "user": _user_payload(user), "balance": user.balance}
|
|
)
|
|
_set_session_cookie(resp, request, session)
|
|
return resp
|
|
|
|
|
|
async def handle_hwid_list(request: web.Request) -> web.Response:
|
|
db: Database = request.app["db"]
|
|
sdk = request.app["sdk"]
|
|
try:
|
|
user = _require_user(request)
|
|
except web.HTTPUnauthorized:
|
|
return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
|
|
panel = await find_panel_user(sdk, user.telegram_id, login=user.login)
|
|
if panel is None:
|
|
return web.json_response({"ok": True, "devices": [], "total": 0})
|
|
try:
|
|
resp = await sdk.hwid.get_hwid_user(str(panel.uuid))
|
|
devices = []
|
|
for d in getattr(resp, "devices", None) or []:
|
|
devices.append(
|
|
{
|
|
"hwid": getattr(d, "hwid", ""),
|
|
"platform": getattr(d, "platform", None),
|
|
"os_version": getattr(d, "os_version", None),
|
|
"device_model": getattr(d, "device_model", None),
|
|
"user_agent": getattr(d, "user_agent", None),
|
|
"created_at": getattr(d, "created_at", None).isoformat()
|
|
if getattr(d, "created_at", None)
|
|
else None,
|
|
}
|
|
)
|
|
return web.json_response(
|
|
{
|
|
"ok": True,
|
|
"total": int(getattr(resp, "total", None) or len(devices)),
|
|
"devices": devices,
|
|
}
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.exception("hwid list failed")
|
|
return web.json_response({"ok": False, "error": str(exc)}, status=500)
|
|
|
|
|
|
async def handle_hwid_delete(request: web.Request) -> web.Response:
|
|
db: Database = request.app["db"]
|
|
sdk = request.app["sdk"]
|
|
try:
|
|
user = _require_user(request)
|
|
except web.HTTPUnauthorized:
|
|
return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
|
|
try:
|
|
body = await request.json()
|
|
except json.JSONDecodeError:
|
|
return web.json_response({"ok": False, "error": "invalid json"}, status=400)
|
|
hwid = str(body.get("hwid") or "").strip()
|
|
if not hwid:
|
|
return web.json_response({"ok": False, "error": "hwid required"}, status=400)
|
|
panel = await find_panel_user(sdk, user.telegram_id, login=user.login)
|
|
if panel is None:
|
|
return web.json_response({"ok": False, "error": "Нет подписки"}, status=400)
|
|
try:
|
|
await sdk.hwid.delete_hwid_to_user(
|
|
HWIDDeleteRequest(user_uuid=UUID(str(panel.uuid)), hwid=hwid)
|
|
)
|
|
from services.remnawave_users import sync_device_policy
|
|
from services.extras_billing import effective_extra_devices
|
|
|
|
fresh = await find_panel_user(sdk, user.telegram_id, login=user.login)
|
|
if fresh is not None:
|
|
await sync_device_policy(
|
|
sdk,
|
|
fresh,
|
|
db=db,
|
|
telegram_id=user.telegram_id,
|
|
extra_paid=effective_extra_devices(user),
|
|
device_blocked=user.device_blocked,
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.exception("hwid delete failed")
|
|
return web.json_response({"ok": False, "error": str(exc)}, status=500)
|
|
return web.json_response({"ok": True})
|
|
|
|
|
|
async def handle_terms_page(_: web.Request) -> web.Response:
|
|
return web.Response(text=html_terms(), content_type="text/html", charset="utf-8")
|
|
|
|
|
|
async def handle_offer_page(_: web.Request) -> web.Response:
|
|
return web.Response(text=html_offer(), content_type="text/html", charset="utf-8")
|
|
|
|
|
|
async def handle_privacy_page(_: web.Request) -> web.Response:
|
|
return web.Response(text=html_privacy(), content_type="text/html", charset="utf-8")
|
|
|
|
|
|
async def handle_aup_page(_: web.Request) -> web.Response:
|
|
return web.Response(text=html_aup(), content_type="text/html", charset="utf-8")
|
|
|
|
|
|
async def handle_vpn_usage_page(_: web.Request) -> web.Response:
|
|
return web.Response(text=html_vpn_usage(), content_type="text/html", charset="utf-8")
|
|
|
|
|
|
async def handle_rules_page(_: web.Request) -> web.Response:
|
|
return web.Response(text=html_site_rules(), content_type="text/html", charset="utf-8")
|
|
|
|
|
|
async def handle_refund_page(_: web.Request) -> web.Response:
|
|
return web.Response(text=html_refund(), content_type="text/html", charset="utf-8")
|
|
|
|
|
|
async def handle_delivery_page(_: web.Request) -> web.Response:
|
|
return web.Response(text=html_delivery(), content_type="text/html", charset="utf-8")
|
|
|
|
|
|
async def handle_contacts_page(_: web.Request) -> web.Response:
|
|
return web.Response(text=html_contacts(), content_type="text/html", charset="utf-8")
|
|
|
|
|
|
async def handle_help_page(_: web.Request) -> web.Response:
|
|
return web.Response(text=html_help(), content_type="text/html", charset="utf-8")
|
|
|
|
|
|
async def handle_support_page(_: web.Request) -> web.Response:
|
|
return web.Response(text=html_support(), content_type="text/html", charset="utf-8")
|
|
|
|
|
|
async def handle_cookies_page(_: web.Request) -> web.Response:
|
|
return web.Response(text=html_cookies(), content_type="text/html", charset="utf-8")
|
|
|
|
|
|
async def handle_status_page(_: web.Request) -> web.Response:
|
|
return web.Response(text=html_status(), content_type="text/html", charset="utf-8")
|
|
|
|
|
|
def _require_admin(request: web.Request):
|
|
from webapp_admin_routes import _require_admin as ra
|
|
|
|
return ra(request)
|
|
|
|
|
|
async def handle_admin_promos_list(request: web.Request) -> web.Response:
|
|
denied = _require_admin(request)
|
|
if denied:
|
|
return denied
|
|
db: Database = request.app["db"]
|
|
rows = db.list_promo_codes()
|
|
items = []
|
|
for r in rows:
|
|
items.append(
|
|
{
|
|
"id": r["id"],
|
|
"code": r["code"],
|
|
"kind": r["kind"],
|
|
"value": float(r["value"]),
|
|
"max_uses": r.get("max_uses"),
|
|
"used_count": int(r.get("used_count") or 0),
|
|
"tariff_id": r.get("tariff_id"),
|
|
"active": bool(r.get("active")),
|
|
"expires_at": r["expires_at"].isoformat()
|
|
if r.get("expires_at") and hasattr(r["expires_at"], "isoformat")
|
|
else None,
|
|
"note": r.get("note"),
|
|
}
|
|
)
|
|
return web.json_response({"ok": True, "promos": items})
|
|
|
|
|
|
async def handle_admin_promos_create(request: web.Request) -> web.Response:
|
|
denied = _require_admin(request)
|
|
if denied:
|
|
return denied
|
|
db: Database = request.app["db"]
|
|
try:
|
|
body = await request.json()
|
|
except json.JSONDecodeError:
|
|
return web.json_response({"ok": False, "error": "invalid json"}, status=400)
|
|
try:
|
|
row = db.create_promo_code(
|
|
code=str(body.get("code") or ""),
|
|
kind=str(body.get("kind") or "percent"),
|
|
value=float(body.get("value") or 0),
|
|
max_uses=int(body["max_uses"]) if body.get("max_uses") not in (None, "") else None,
|
|
tariff_id=str(body.get("tariff_id") or "").strip() or None,
|
|
note=str(body.get("note") or "").strip() or None,
|
|
)
|
|
except (AuthError, ValueError) as exc:
|
|
return web.json_response({"ok": False, "error": str(exc)}, status=400)
|
|
return web.json_response({"ok": True, "promo": {"id": row["id"], "code": row["code"]}})
|
|
|
|
|
|
async def handle_admin_promos_toggle(request: web.Request) -> web.Response:
|
|
denied = _require_admin(request)
|
|
if denied:
|
|
return denied
|
|
db: Database = request.app["db"]
|
|
promo_id = int(request.match_info["promo_id"])
|
|
try:
|
|
body = await request.json()
|
|
except json.JSONDecodeError:
|
|
body = {}
|
|
db.set_promo_active(promo_id, bool(body.get("active", True)))
|
|
return web.json_response({"ok": True})
|
|
|
|
|
|
async def handle_admin_user_ban(request: web.Request) -> web.Response:
|
|
denied = _require_admin(request)
|
|
if denied:
|
|
return denied
|
|
db: Database = request.app["db"]
|
|
try:
|
|
body = await request.json()
|
|
except json.JSONDecodeError:
|
|
return web.json_response({"ok": False, "error": "invalid json"}, status=400)
|
|
tid = int(body.get("telegram_id") or 0)
|
|
banned = bool(body.get("banned"))
|
|
user = db.set_banned(tid, banned)
|
|
return web.json_response({"ok": True, "user": _user_payload(user), "banned": user.banned})
|
|
|
|
|
|
async def handle_admin_user_delete(request: web.Request) -> web.Response:
|
|
denied = _require_admin(request)
|
|
if denied:
|
|
return denied
|
|
db: Database = request.app["db"]
|
|
settings: Settings = request.app["settings"]
|
|
try:
|
|
body = await request.json()
|
|
except json.JSONDecodeError:
|
|
return web.json_response({"ok": False, "error": "invalid json"}, status=400)
|
|
try:
|
|
tid = int(body.get("telegram_id") or 0)
|
|
except (TypeError, ValueError):
|
|
return web.json_response({"ok": False, "error": "bad telegram_id"}, status=400)
|
|
if not tid:
|
|
return web.json_response({"ok": False, "error": "Укажите telegram_id"}, status=400)
|
|
if not body.get("confirm"):
|
|
return web.json_response(
|
|
{"ok": False, "error": "Нужно подтверждение (confirm=true)"},
|
|
status=400,
|
|
)
|
|
if tid in (settings.admin_ids or ()):
|
|
return web.json_response(
|
|
{"ok": False, "error": "Нельзя удалить пользователя из ADMIN_IDS"},
|
|
status=400,
|
|
)
|
|
user = db.get_user(tid)
|
|
if user is None:
|
|
return web.json_response({"ok": False, "error": "Пользователь не найден"}, status=404)
|
|
label = user.display_name
|
|
try:
|
|
ok = db.delete_user(tid)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.exception("admin delete user failed tid=%s", tid)
|
|
return web.json_response({"ok": False, "error": str(exc)}, status=500)
|
|
if not ok:
|
|
return web.json_response({"ok": False, "error": "Пользователь не найден"}, status=404)
|
|
return web.json_response(
|
|
{
|
|
"ok": True,
|
|
"telegram_id": tid,
|
|
"message": f"Удалён: {label} (ID {tid})",
|
|
}
|
|
)
|
|
|
|
|
|
async def handle_admin_payment_journal(request: web.Request) -> web.Response:
|
|
denied = _require_admin(request)
|
|
if denied:
|
|
return denied
|
|
db: Database = request.app["db"]
|
|
rows = db.list_payment_events(limit=100)
|
|
items = []
|
|
for r in rows:
|
|
items.append(
|
|
{
|
|
"id": r["id"],
|
|
"provider": r["provider"],
|
|
"event_type": r["event_type"],
|
|
"telegram_id": r.get("telegram_id"),
|
|
"amount": float(r["amount"]) if r.get("amount") is not None else None,
|
|
"invoice_ref": r.get("invoice_ref"),
|
|
"status": r.get("status"),
|
|
"meta": r.get("meta"),
|
|
"created_at": r["created_at"].isoformat()
|
|
if hasattr(r["created_at"], "isoformat")
|
|
else str(r["created_at"]),
|
|
}
|
|
)
|
|
return web.json_response({"ok": True, "events": items})
|
|
|
|
|
|
async def handle_admin_backups_list(request: web.Request) -> web.Response:
|
|
denied = _require_admin(request)
|
|
if denied:
|
|
return denied
|
|
return web.json_response({"ok": True, "backups": list_backups()})
|
|
|
|
|
|
async def handle_admin_backups_run(request: web.Request) -> web.Response:
|
|
denied = _require_admin(request)
|
|
if denied:
|
|
return denied
|
|
try:
|
|
path = await run_pg_dump_backup(compose_dir=ROOT_DIR)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.exception("backup failed")
|
|
return web.json_response({"ok": False, "error": str(exc)}, status=500)
|
|
return web.json_response({"ok": True, "file": path.name, "backups": list_backups()})
|
|
|
|
|
|
async def handle_admin_backups_download(request: web.Request) -> web.Response:
|
|
denied = _require_admin(request)
|
|
if denied:
|
|
return denied
|
|
name = request.match_info.get("name") or ""
|
|
try:
|
|
path = resolve_backup_path(name)
|
|
except (ValueError, FileNotFoundError) as exc:
|
|
return web.json_response({"ok": False, "error": str(exc)}, status=404)
|
|
return web.FileResponse(
|
|
path,
|
|
headers={
|
|
"Content-Type": "application/gzip",
|
|
"Content-Disposition": f'attachment; filename="{path.name}"',
|
|
},
|
|
)
|
|
|
|
|
|
async def handle_admin_backups_delete(request: web.Request) -> web.Response:
|
|
denied = _require_admin(request)
|
|
if denied:
|
|
return denied
|
|
name = request.match_info.get("name") or ""
|
|
try:
|
|
delete_backup(name)
|
|
except (ValueError, FileNotFoundError) as exc:
|
|
return web.json_response({"ok": False, "error": str(exc)}, status=404)
|
|
return web.json_response({"ok": True, "backups": list_backups()})
|
|
|
|
|
|
async def handle_admin_backups_delete_post(request: web.Request) -> web.Response:
|
|
"""Удаление через POST — надёжнее, чем DELETE за прокси."""
|
|
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()
|
|
if not name:
|
|
return web.json_response({"ok": False, "error": "Укажите name"}, status=400)
|
|
try:
|
|
delete_backup(name)
|
|
except (ValueError, FileNotFoundError) as exc:
|
|
return web.json_response({"ok": False, "error": str(exc)}, status=404)
|
|
return web.json_response({"ok": True, "backups": list_backups(), "message": f"Удалён {name}"})
|
|
|
|
|
|
async def handle_admin_backups_restore(request: web.Request) -> web.Response:
|
|
"""Восстановить БД из сохранённого бэкапа или загруженного файла (multipart)."""
|
|
denied = _require_admin(request)
|
|
if denied:
|
|
return denied
|
|
|
|
content_type = (request.content_type or "").lower()
|
|
safety_name = None
|
|
|
|
try:
|
|
# Сначала страховочный бэкап текущего состояния
|
|
try:
|
|
safety = await run_pg_dump_backup(compose_dir=ROOT_DIR)
|
|
safety_name = safety.name
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.warning("pre-restore safety backup failed: %s", exc)
|
|
|
|
if "multipart" in content_type:
|
|
reader = await request.multipart()
|
|
confirm = False
|
|
file_bytes: bytes | None = None
|
|
filename = "upload.sql.gz"
|
|
while True:
|
|
part = await reader.next()
|
|
if part is None:
|
|
break
|
|
if part.name == "confirm":
|
|
confirm = (await part.text()).strip().lower() in {"1", "true", "yes", "on"}
|
|
elif part.name == "file":
|
|
filename = Path(part.filename or filename).name
|
|
file_bytes = await part.read(decode=False)
|
|
if not confirm:
|
|
return web.json_response(
|
|
{"ok": False, "error": "Нужно подтверждение restore (confirm=true)"},
|
|
status=400,
|
|
)
|
|
if not file_bytes:
|
|
return web.json_response({"ok": False, "error": "Файл не передан"}, status=400)
|
|
await restore_from_bytes(file_bytes, filename=filename)
|
|
try:
|
|
await save_uploaded_backup(file_bytes, original_name=filename)
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
else:
|
|
try:
|
|
body = await request.json()
|
|
except json.JSONDecodeError:
|
|
return web.json_response({"ok": False, "error": "invalid json"}, status=400)
|
|
if not body.get("confirm"):
|
|
return web.json_response(
|
|
{"ok": False, "error": "Нужно подтверждение restore (confirm=true)"},
|
|
status=400,
|
|
)
|
|
name = str(body.get("name") or "").strip()
|
|
if not name:
|
|
return web.json_response({"ok": False, "error": "Укажите name бэкапа"}, status=400)
|
|
await restore_from_backup_name(name)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.exception("restore failed")
|
|
return web.json_response(
|
|
{
|
|
"ok": False,
|
|
"error": str(exc),
|
|
"safety_backup": safety_name,
|
|
},
|
|
status=500,
|
|
)
|
|
|
|
return web.json_response(
|
|
{
|
|
"ok": True,
|
|
"message": "База восстановлена. Перезапустите bot при необходимости.",
|
|
"safety_backup": safety_name,
|
|
"backups": list_backups(),
|
|
}
|
|
)
|
|
|
|
|
|
async def handle_admin_backups_upload(request: web.Request) -> web.Response:
|
|
"""Только сохранить файл в список бэкапов (без restore)."""
|
|
denied = _require_admin(request)
|
|
if denied:
|
|
return denied
|
|
if "multipart" not in (request.content_type or "").lower():
|
|
return web.json_response({"ok": False, "error": "ожидается multipart"}, status=400)
|
|
reader = await request.multipart()
|
|
file_bytes: bytes | None = None
|
|
filename = "upload.sql.gz"
|
|
while True:
|
|
part = await reader.next()
|
|
if part is None:
|
|
break
|
|
if part.name == "file":
|
|
filename = Path(part.filename or filename).name
|
|
file_bytes = await part.read(decode=False)
|
|
if not file_bytes:
|
|
return web.json_response({"ok": False, "error": "Файл не передан"}, status=400)
|
|
try:
|
|
path = await save_uploaded_backup(file_bytes, original_name=filename)
|
|
except Exception as exc: # noqa: BLE001
|
|
return web.json_response({"ok": False, "error": str(exc)}, status=400)
|
|
return web.json_response({"ok": True, "file": path.name, "backups": list_backups()})
|
|
|
|
|
|
async def handle_admin_growth_get(request: web.Request) -> web.Response:
|
|
denied = _require_admin(request)
|
|
if denied:
|
|
return denied
|
|
db: Database = request.app["db"]
|
|
return web.json_response(
|
|
{
|
|
"ok": True,
|
|
"settings": {
|
|
"referral_bonus_referrer": db.get_setting("referral_bonus_referrer", "50"),
|
|
"referral_bonus_referee": db.get_setting("referral_bonus_referee", "50"),
|
|
"trial_days": db.get_setting("trial_days", "3"),
|
|
"trial_tariff_id": db.get_setting("trial_tariff_id", ""),
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
async def handle_admin_growth_set(request: web.Request) -> web.Response:
|
|
denied = _require_admin(request)
|
|
if denied:
|
|
return denied
|
|
db: Database = request.app["db"]
|
|
try:
|
|
body = await request.json()
|
|
except json.JSONDecodeError:
|
|
return web.json_response({"ok": False, "error": "invalid json"}, status=400)
|
|
mapping = {
|
|
"referral_bonus_referrer": "referral_bonus_referrer",
|
|
"referral_bonus_referee": "referral_bonus_referee",
|
|
"trial_days": "trial_days",
|
|
"trial_tariff_id": "trial_tariff_id",
|
|
}
|
|
data = {}
|
|
for k, sk in mapping.items():
|
|
if k in body and body[k] is not None:
|
|
data[sk] = str(body[k]).strip()
|
|
if data:
|
|
db.set_settings_map(data)
|
|
return await handle_admin_growth_get(request)
|