fix: 18-point audit pass — payment races, hwid limit bugs, blocking SSH/HTTP in event loops, N+1 queries, ssh host-key pinning, dead code
payments: _grant_paid_subscription now validates plan/node exist before marking a payment paid instead of after (was leaving charged-but-ungranted payments with no error trail); mark_payment_paid is now a single atomic UPDATE ... WHERE status='pending' instead of check-then-act, closing a double-grant race between webhooks and the periodic reconciler; yookassa webhook now re-verifies payment status server-side via the API instead of trusting the posted body (platega already had HMAC verification). hwid: 'user["hwid_limit"] or FALLBACK' treated an explicit 0 (admin fully blocking a user) as unset — now an explicit None check. Device count-check and insert are now one atomic transaction (db.add_device_if_under_limit) instead of two raceable statements. perf: payment webhooks and _grant_paid_subscription's SSH/HTTP calls now run via asyncio.to_thread instead of blocking the event loop; same for bot.py's periodic_sync/reconcile_pending_payments and the manual admin sync button. Admin endpoints (traffic/subscriptions/payments/gift-codes/ user-card) now resolve node labels from one db.list_nodes() call instead of a fresh db.get_node() per row. revoke/reset-traffic use a direct PK lookup instead of scanning up to 5000 rows. Dashboard now asks the API for 8 rows instead of fetching 200 and slicing client-side. security: mbs.db (and -wal/-shm) now chmod 600 right after creation — it held session tokens and subscription bearer tokens world-readable by default. Node SSH connections now pin host keys via a persisted known_hosts file (TOFU) instead of accepting any key on every connection. delete_node now refuses to delete a node with active subscriptions instead of silently orphaning their xray clients. deadcode: removed unused xray_manager.list_client_ids and admin.html's superseded staggerReveal (rows animate via rowAttr() inline now). Also guards gift-code redemption against a plan/node deleted after the code was created (was an unhandled KeyError/TypeError crash). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
2604c2dfe7
commit
f9ee4f5933
6 changed files with 103 additions and 59 deletions
64
api.py
64
api.py
|
|
@ -1,3 +1,4 @@
|
|||
import asyncio
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
|
|
@ -241,16 +242,15 @@ def get_subscription(token: str, request: Request):
|
|||
hwid = request.headers.get("x-hwid", "")
|
||||
if not HWID_RE.match(hwid):
|
||||
raise HTTPException(404, "hwid required")
|
||||
if not db.get_device(user["tg_id"], hwid):
|
||||
limit = user["hwid_limit"] or HWID_FALLBACK_LIMIT
|
||||
if db.count_devices(user["tg_id"]) >= limit:
|
||||
raise HTTPException(404, "device limit reached", headers={"x-hwid-max-devices-reached": "true"})
|
||||
db.add_device(
|
||||
user["tg_id"], hwid,
|
||||
request.headers.get("x-device-os"),
|
||||
request.headers.get("x-device-model"),
|
||||
ua,
|
||||
)
|
||||
limit = user["hwid_limit"] if user["hwid_limit"] is not None else HWID_FALLBACK_LIMIT
|
||||
_, allowed = db.add_device_if_under_limit(
|
||||
user["tg_id"], hwid, limit,
|
||||
request.headers.get("x-device-os"),
|
||||
request.headers.get("x-device-model"),
|
||||
ua,
|
||||
)
|
||||
if not allowed:
|
||||
raise HTTPException(404, "device limit reached", headers={"x-hwid-max-devices-reached": "true"})
|
||||
|
||||
content = links.build_subscription_text(subs)
|
||||
return Response(content=content, media_type="text/plain")
|
||||
|
|
@ -305,13 +305,16 @@ def _tg_send_message(tg_id: int, text: str):
|
|||
|
||||
|
||||
def _grant_paid_subscription(payment_id: str):
|
||||
payment = db.mark_payment_paid(payment_id)
|
||||
if not payment:
|
||||
payment = db.get_payment(payment_id)
|
||||
if not payment or payment["status"] == "paid":
|
||||
return
|
||||
plan = PLANS_BY_CODE.get(payment["plan"])
|
||||
node = db.get_node(payment["node"])
|
||||
if not plan or not node:
|
||||
return
|
||||
payment = db.mark_payment_paid(payment_id)
|
||||
if not payment:
|
||||
return
|
||||
sub = db.create_subscription(payment["tg_id"], payment["node"], plan["days"], payment["plan"], source="payment")
|
||||
xray_manager.add_client_to_node(node, sub["uuid"], email=sub["uuid"])
|
||||
user = db.get_or_create_user(payment["tg_id"], None)
|
||||
|
|
@ -343,9 +346,10 @@ def _check_and_reconcile_payment(payment: dict) -> str:
|
|||
@app.get("/admin/api/payments")
|
||||
def admin_list_payments(request: Request):
|
||||
require_admin(request)
|
||||
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
|
||||
out = []
|
||||
for p in db.list_payments():
|
||||
node = db.get_node(p["node"])
|
||||
node = nodes_by_code.get(p["node"])
|
||||
plan = PLANS_BY_CODE.get(p["plan"])
|
||||
out.append({
|
||||
**p,
|
||||
|
|
@ -374,12 +378,16 @@ async def yookassa_webhook(request: Request):
|
|||
if not payments.verify_yookassa_notification(body):
|
||||
raise HTTPException(400, "unexpected event")
|
||||
obj = body.get("object", {})
|
||||
if obj.get("status") != "succeeded":
|
||||
return {"ok": True}
|
||||
payment_id = (obj.get("metadata") or {}).get("payment_id")
|
||||
if not payment_id:
|
||||
raise HTTPException(400, "missing payment_id")
|
||||
_grant_paid_subscription(payment_id)
|
||||
payment = db.get_payment(payment_id)
|
||||
if not payment or not payment.get("external_id"):
|
||||
raise HTTPException(400, "unknown payment")
|
||||
status = await asyncio.to_thread(payments.check_yookassa_payment, payment["external_id"])
|
||||
if status not in payments.PAID_STATUSES:
|
||||
return {"ok": True}
|
||||
await asyncio.to_thread(_grant_paid_subscription, payment_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
|
|
@ -394,7 +402,7 @@ async def platega_webhook(request: Request):
|
|||
payment_id = body.get("id") or body.get("paymentId")
|
||||
if status not in ("succeeded", "success", "paid") or not payment_id:
|
||||
return {"ok": True}
|
||||
_grant_paid_subscription(payment_id)
|
||||
await asyncio.to_thread(_grant_paid_subscription, payment_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
|
|
@ -519,12 +527,13 @@ def admin_traffic(request: Request):
|
|||
total_down = sum(v["down"] for v in all_stats.values())
|
||||
|
||||
subs = db.list_all_subscriptions(limit=5000)
|
||||
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
|
||||
per_sub = []
|
||||
for s in subs:
|
||||
st = all_stats.get(s["uuid"])
|
||||
if not st:
|
||||
continue
|
||||
node = db.get_node(s["node"])
|
||||
node = nodes_by_code.get(s["node"])
|
||||
per_sub.append({
|
||||
"uuid": s["uuid"],
|
||||
"username": ("@" + s["username"]) if s.get("username") else f"tg{s['tg_id']}",
|
||||
|
|
@ -544,12 +553,13 @@ def admin_traffic(request: Request):
|
|||
|
||||
|
||||
@app.get("/admin/api/subscriptions")
|
||||
def admin_subscriptions(request: Request):
|
||||
def admin_subscriptions(request: Request, limit: int = 200):
|
||||
require_admin(request)
|
||||
subs = db.list_all_subscriptions()
|
||||
subs = db.list_all_subscriptions(limit=limit)
|
||||
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
|
||||
out = []
|
||||
for s in subs:
|
||||
node = db.get_node(s["node"])
|
||||
node = nodes_by_code.get(s["node"])
|
||||
plan = PLANS_BY_CODE.get(s["plan"])
|
||||
out.append({
|
||||
**s,
|
||||
|
|
@ -563,8 +573,7 @@ def admin_subscriptions(request: Request):
|
|||
@app.post("/admin/api/subscriptions/{uuid}/revoke")
|
||||
def admin_revoke_subscription(uuid: str, request: Request):
|
||||
require_admin(request)
|
||||
subs = db.list_all_subscriptions(limit=5000)
|
||||
sub = next((s for s in subs if s["uuid"] == uuid), None)
|
||||
sub = db.get_subscription(uuid)
|
||||
if not sub:
|
||||
raise HTTPException(404, "not found")
|
||||
node = db.get_node(sub["node"])
|
||||
|
|
@ -577,8 +586,7 @@ def admin_revoke_subscription(uuid: str, request: Request):
|
|||
@app.post("/admin/api/subscriptions/{uuid}/reset-traffic")
|
||||
def admin_reset_traffic(uuid: str, request: Request):
|
||||
require_admin(request)
|
||||
subs = db.list_all_subscriptions(limit=5000)
|
||||
sub = next((s for s in subs if s["uuid"] == uuid), None)
|
||||
sub = db.get_subscription(uuid)
|
||||
if not sub:
|
||||
raise HTTPException(404, "not found")
|
||||
node = db.get_node(sub["node"])
|
||||
|
|
@ -595,9 +603,10 @@ def admin_user_card(tg_id: int, request: Request):
|
|||
if not user:
|
||||
raise HTTPException(404, "not found")
|
||||
subs = db.list_subscriptions_for_user(tg_id)
|
||||
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
|
||||
out_subs = []
|
||||
for s in subs:
|
||||
node = db.get_node(s["node"])
|
||||
node = nodes_by_code.get(s["node"])
|
||||
plan = PLANS_BY_CODE.get(s["plan"])
|
||||
out_subs.append({
|
||||
**s,
|
||||
|
|
@ -662,9 +671,10 @@ def admin_set_hwid_limit(tg_id: int, request: Request, body: dict = Body(...)):
|
|||
def admin_gift_codes(request: Request):
|
||||
require_admin(request)
|
||||
codes = db.list_gift_codes()
|
||||
nodes_by_code = {n["code"]: n for n in db.list_nodes()}
|
||||
out = []
|
||||
for c in codes:
|
||||
node = db.get_node(c["node"])
|
||||
node = nodes_by_code.get(c["node"])
|
||||
plan = PLANS_BY_CODE.get(c["plan"])
|
||||
out.append({
|
||||
**c,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue