387 lines
16 KiB
Python
387 lines
16 KiB
Python
"""
|
|
Cosmo Pay — in-app subscription renewal for Cosmo VPN.
|
|
|
|
Flow: app sends its subscription URL -> we find the SoloBot key (keys.remnawave_link) via SoloBot's
|
|
read-only API -> offer tariffs of the same class -> create a YooKassa payment (owner's shop, receipt as
|
|
SoloBot does) -> poll YooKassa -> on success set the new absolute expiry in Remnawave (PATCH /users,
|
|
expireAt only — limits untouched) and in SoloBot's DB (keys.expiry_time + reset expiry-notification flags).
|
|
|
|
Secrets are read on the server only: SoloBot config.py (YooKassa keys, receipt settings, Remnawave API
|
|
token) and /etc/cosmo-pay.env (SoloBot admin API token). Nothing secret is logged or returned.
|
|
"""
|
|
import asyncio
|
|
import hashlib
|
|
import importlib.util
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import sqlite3
|
|
import subprocess
|
|
import time
|
|
import uuid
|
|
from contextlib import asynccontextmanager
|
|
from datetime import datetime, timezone
|
|
|
|
import httpx
|
|
from fastapi import FastAPI, HTTPException, Request
|
|
from fastapi.responses import HTMLResponse
|
|
from pydantic import BaseModel
|
|
|
|
BOT_DIR = "/root/cosmonetvpn_bot"
|
|
ENV_FILE = "/etc/cosmo-pay.env"
|
|
DB_FILE = "/var/lib/cosmo-pay/pay.db"
|
|
SOLOBOT_API = "http://127.0.0.1:3003/api"
|
|
REMNAWAVE_API = "https://rw.cosmonet.online/api"
|
|
YOOKASSA_API = "https://api.yookassa.ru/v3"
|
|
PUBLIC_BASE = "https://bot.cosmonet.online/cosmo-pay"
|
|
BOT_LINK = "https://t.me/cosmonetvpn_bot"
|
|
SUB_URL_RE = re.compile(r"^https://sub\.cosmonet\.online/([A-Za-z0-9_-]{8,64})/?$")
|
|
RENEWABLE_GROUPS = {"cosmo", "vip"}
|
|
OFFER_GROUP = "cosmo"
|
|
PENDING_TTL_S = 3 * 3600
|
|
DAY_MS = 86_400_000
|
|
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
|
log = logging.getLogger("cosmo-pay")
|
|
|
|
|
|
# ------------------------------------------------------------------ configuration (server-side only)
|
|
def _load_bot_config():
|
|
spec = importlib.util.spec_from_file_location("solobot_config", f"{BOT_DIR}/config.py")
|
|
mod = importlib.util.module_from_spec(spec)
|
|
cwd = os.getcwd()
|
|
try:
|
|
os.chdir(BOT_DIR)
|
|
spec.loader.exec_module(mod)
|
|
finally:
|
|
os.chdir(cwd)
|
|
return mod
|
|
|
|
|
|
def _load_env():
|
|
env = {}
|
|
with open(ENV_FILE) as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if line and not line.startswith("#") and "=" in line:
|
|
k, v = line.split("=", 1)
|
|
env[k.strip()] = v.strip()
|
|
return env
|
|
|
|
|
|
CFG = _load_bot_config()
|
|
ENV = _load_env()
|
|
YK_AUTH = (str(CFG.YOOKASSA_SHOP_ID), str(CFG.YOOKASSA_SECRET_KEY))
|
|
RECEIPT_EMAIL = getattr(CFG, "EMAIL", "") or ""
|
|
VAT_CODE = int(getattr(CFG, "VAT_CODE", 1) or 1)
|
|
SBP_ONLY = bool(getattr(CFG, "YOOKASSA_SBP_ONLY", False))
|
|
RW_TOKEN = CFG.REMNAWAVE_ACCESS_TOKEN
|
|
SB_PARAMS = {"tg_id": ENV["SOLOBOT_ADMIN_TG_ID"]}
|
|
SB_HEADERS = {"X-Token": ENV["SOLOBOT_API_TOKEN"]}
|
|
|
|
|
|
# ------------------------------------------------------------------ storage
|
|
def db():
|
|
os.makedirs(os.path.dirname(DB_FILE), exist_ok=True)
|
|
con = sqlite3.connect(DB_FILE, timeout=10)
|
|
con.row_factory = sqlite3.Row
|
|
con.execute("""create table if not exists payments(
|
|
yk_id text primary key, token_hash text, client_id text, email text, tg_id integer,
|
|
tariff_id integer, tariff_name text, days integer, amount real,
|
|
status text, target_expiry integer, error text, created integer, updated integer)""")
|
|
return con
|
|
|
|
|
|
def token_hash(token: str) -> str:
|
|
return hashlib.sha256(("cosmo-pay:" + token).encode()).hexdigest()[:24]
|
|
|
|
|
|
# ------------------------------------------------------------------ SoloBot (read-only API) + DB write
|
|
_cache = {"keys": (0.0, []), "tariffs": (0.0, [])}
|
|
|
|
|
|
async def sb_get(client: httpx.AsyncClient, path: str, ttl: float):
|
|
ts, data = _cache[path]
|
|
if time.time() - ts < ttl:
|
|
return data
|
|
r = await client.get(f"{SOLOBOT_API}/{path}/", params=SB_PARAMS, headers=SB_HEADERS, timeout=15)
|
|
r.raise_for_status()
|
|
data = r.json()
|
|
_cache[path] = (time.time(), data)
|
|
return data
|
|
|
|
|
|
async def find_key(client, token: str):
|
|
keys = await sb_get(client, "keys", ttl=20)
|
|
for k in keys:
|
|
link = (k.get("remnawave_link") or "").rstrip("/")
|
|
if link.endswith("/" + token):
|
|
return k
|
|
return None
|
|
|
|
|
|
def tariff_title(name: str) -> str:
|
|
return (name or "").split("•")[0].strip() or name
|
|
|
|
|
|
async def offer_for_key(client, key):
|
|
tariffs = await sb_get(client, "tariffs", ttl=300)
|
|
by_id = {t["id"]: t for t in tariffs}
|
|
cur = by_id.get(key.get("tariff_id"))
|
|
if not cur or cur.get("group_code") not in RENEWABLE_GROUPS:
|
|
return cur, []
|
|
cls = cur.get("device_limit")
|
|
offer = [t for t in tariffs
|
|
if t.get("is_active") and t.get("group_code") == OFFER_GROUP
|
|
and t.get("device_limit") == cls and (t.get("price_rub") or 0) > 0 and (t.get("duration_days") or 0) > 0]
|
|
offer.sort(key=lambda t: (t.get("sort_order") is None, t.get("sort_order") or 0, t["duration_days"]))
|
|
return cur, offer
|
|
|
|
|
|
def db_set_expiry(client_id: str, expiry_ms: int):
|
|
if not re.fullmatch(r"[0-9a-fA-F-]{32,40}", client_id):
|
|
raise ValueError("bad client_id")
|
|
sql = ("update keys set expiry_time = :'exp', notified = false, notified_24h = false "
|
|
"where client_id = :'cid' returning client_id;\n")
|
|
p = subprocess.run(["sudo", "-u", "postgres", "psql", "-d", "cosmonetdb", "-Atq", "-v", "ON_ERROR_STOP=1",
|
|
"-v", f"exp={int(expiry_ms)}", "-v", f"cid={client_id}"],
|
|
input=sql, capture_output=True, text=True, timeout=20)
|
|
if p.returncode != 0 or p.stdout.strip() != client_id:
|
|
raise RuntimeError(f"db update failed: rc={p.returncode} {p.stderr.strip()}"[:200])
|
|
|
|
|
|
# ------------------------------------------------------------------ Remnawave
|
|
async def rw_get_user(client, client_id: str):
|
|
r = await client.get(f"{REMNAWAVE_API}/users/{client_id}", headers={"Authorization": f"Bearer {RW_TOKEN}"}, timeout=15)
|
|
r.raise_for_status()
|
|
return r.json().get("response", {})
|
|
|
|
|
|
async def rw_set_expiry(client, client_id: str, expiry_ms: int):
|
|
iso = datetime.fromtimestamp(expiry_ms / 1000, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
|
r = await client.patch(f"{REMNAWAVE_API}/users", headers={"Authorization": f"Bearer {RW_TOKEN}"},
|
|
json={"uuid": client_id, "expireAt": iso}, timeout=20)
|
|
if r.status_code >= 400:
|
|
raise RuntimeError(f"remnawave PATCH {r.status_code}: {r.text[:150]}")
|
|
got = (r.json().get("response") or {}).get("expireAt", "")
|
|
if not got.startswith(iso[:16]):
|
|
raise RuntimeError(f"remnawave expireAt mismatch: {got}")
|
|
|
|
|
|
# ------------------------------------------------------------------ YooKassa
|
|
async def yk_create(client, amount: float, description: str, metadata: dict):
|
|
value = f"{amount:.2f}"
|
|
body = {
|
|
"amount": {"value": value, "currency": "RUB"},
|
|
"capture": True,
|
|
"confirmation": {"type": "redirect", "return_url": f"{PUBLIC_BASE}/return"},
|
|
"description": description[:128],
|
|
"metadata": metadata,
|
|
}
|
|
if RECEIPT_EMAIL:
|
|
body["receipt"] = {
|
|
"customer": {"email": RECEIPT_EMAIL},
|
|
"items": [{"description": description[:128], "quantity": "1.00",
|
|
"amount": {"value": value, "currency": "RUB"}, "vat_code": VAT_CODE,
|
|
"payment_mode": "full_payment", "payment_subject": "service"}],
|
|
}
|
|
if SBP_ONLY:
|
|
body["payment_method_data"] = {"type": "sbp"}
|
|
r = await client.post(f"{YOOKASSA_API}/payments", auth=YK_AUTH, json=body,
|
|
headers={"Idempotence-Key": str(uuid.uuid4())}, timeout=20)
|
|
if r.status_code >= 400:
|
|
log.error("yookassa create failed %s: %s", r.status_code, r.text[:300])
|
|
raise HTTPException(502, "payment provider error")
|
|
return r.json()
|
|
|
|
|
|
async def yk_get(client, yk_id: str):
|
|
r = await client.get(f"{YOOKASSA_API}/payments/{yk_id}", auth=YK_AUTH, timeout=15)
|
|
r.raise_for_status()
|
|
return r.json()
|
|
|
|
|
|
# ------------------------------------------------------------------ renewal (serialized, idempotent)
|
|
renew_lock = asyncio.Lock()
|
|
|
|
|
|
async def process_payment(client, yk_id: str):
|
|
"""Advance one payment: pending -> succeeded(target fixed) -> renewed. Safe to call repeatedly."""
|
|
async with renew_lock:
|
|
con = db()
|
|
row = con.execute("select * from payments where yk_id=?", (yk_id,)).fetchone()
|
|
if not row or row["status"] in ("renewed", "canceled"):
|
|
return row
|
|
now = int(time.time())
|
|
if row["status"] == "pending":
|
|
p = await yk_get(client, yk_id)
|
|
st = p.get("status")
|
|
if st == "canceled":
|
|
con.execute("update payments set status='canceled', updated=? where yk_id=?", (now, yk_id)); con.commit()
|
|
return con.execute("select * from payments where yk_id=?", (yk_id,)).fetchone()
|
|
if st != "succeeded" or not p.get("paid"):
|
|
return row
|
|
if f"{float(p['amount']['value']):.2f}" != f"{row['amount']:.2f}":
|
|
con.execute("update payments set status='error', error='amount mismatch', updated=? where yk_id=?", (now, yk_id)); con.commit()
|
|
log.error("payment %s amount mismatch", yk_id)
|
|
return con.execute("select * from payments where yk_id=?", (yk_id,)).fetchone()
|
|
# Fix the absolute target once: max(now, current expiry in Remnawave, other renewals of this key) + days.
|
|
user = await rw_get_user(client, row["client_id"])
|
|
cur_ms = int(datetime.fromisoformat(user["expireAt"].replace("Z", "+00:00")).timestamp() * 1000)
|
|
other = con.execute("select max(target_expiry) m from payments where client_id=? and status in ('succeeded','renewed')",
|
|
(row["client_id"],)).fetchone()["m"] or 0
|
|
base = max(int(time.time() * 1000), cur_ms, other)
|
|
target = base + int(row["days"]) * DAY_MS
|
|
con.execute("update payments set status='succeeded', target_expiry=?, updated=? where yk_id=?", (target, now, yk_id)); con.commit()
|
|
row = con.execute("select * from payments where yk_id=?", (yk_id,)).fetchone()
|
|
if row["status"] in ("succeeded", "error") and row["target_expiry"]:
|
|
try:
|
|
await rw_set_expiry(client, row["client_id"], row["target_expiry"])
|
|
db_set_expiry(row["client_id"], row["target_expiry"])
|
|
con.execute("update payments set status='renewed', error=null, updated=? where yk_id=?", (now, yk_id)); con.commit()
|
|
_cache["keys"] = (0.0, [])
|
|
log.info("payment %s renewed key %s.. until %s", yk_id, row["client_id"][:8],
|
|
datetime.fromtimestamp(row["target_expiry"] / 1000, tz=timezone.utc).isoformat())
|
|
except Exception as e: # stays retryable; target is absolute so retries are idempotent
|
|
con.execute("update payments set status='error', error=?, updated=? where yk_id=?", (str(e)[:200], now, yk_id)); con.commit()
|
|
log.error("payment %s renewal failed: %s", yk_id, e)
|
|
return con.execute("select * from payments where yk_id=?", (yk_id,)).fetchone()
|
|
|
|
|
|
async def poller(client):
|
|
while True:
|
|
try:
|
|
con = db()
|
|
ids = [r["yk_id"] for r in con.execute(
|
|
"select yk_id from payments where status in ('pending','succeeded','error') and created > ?",
|
|
(int(time.time()) - PENDING_TTL_S,))]
|
|
for yk_id in ids:
|
|
await process_payment(client, yk_id)
|
|
except Exception as e:
|
|
log.error("poller: %s", e)
|
|
await asyncio.sleep(15)
|
|
|
|
|
|
# ------------------------------------------------------------------ HTTP API
|
|
@asynccontextmanager
|
|
async def lifespan(app):
|
|
app.state.client = httpx.AsyncClient()
|
|
task = asyncio.create_task(poller(app.state.client))
|
|
yield
|
|
task.cancel()
|
|
await app.state.client.aclose()
|
|
|
|
|
|
app = FastAPI(lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None)
|
|
|
|
_hits: dict[str, list[float]] = {}
|
|
|
|
|
|
def rate_limit(request: Request, per_min: int = 30):
|
|
ip = request.headers.get("x-real-ip") or (request.client.host if request.client else "?")
|
|
now = time.time()
|
|
lst = [t for t in _hits.get(ip, []) if now - t < 60]
|
|
if len(lst) >= per_min:
|
|
raise HTTPException(429, "too many requests")
|
|
lst.append(now)
|
|
_hits[ip] = lst
|
|
|
|
|
|
def parse_token(sub_url: str) -> str:
|
|
m = SUB_URL_RE.match((sub_url or "").strip())
|
|
if not m:
|
|
raise HTTPException(400, "unknown subscription")
|
|
return m.group(1)
|
|
|
|
|
|
class SubReq(BaseModel):
|
|
subscription_url: str
|
|
|
|
|
|
class PayReq(BaseModel):
|
|
subscription_url: str
|
|
tariff_id: int
|
|
|
|
|
|
class StatusReq(BaseModel):
|
|
subscription_url: str
|
|
payment_id: str
|
|
|
|
|
|
def iso_ms(ms):
|
|
return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") if ms else None
|
|
|
|
|
|
@app.get("/health")
|
|
async def health():
|
|
return {"ok": True}
|
|
|
|
|
|
@app.post("/api/v1/tariffs")
|
|
async def tariffs(req: SubReq, request: Request):
|
|
rate_limit(request)
|
|
token = parse_token(req.subscription_url)
|
|
client = request.app.state.client
|
|
key = await find_key(client, token)
|
|
if not key:
|
|
raise HTTPException(404, "subscription not found")
|
|
cur, offer = await offer_for_key(client, key)
|
|
return {
|
|
"renewable": bool(offer),
|
|
"expire_at": iso_ms(key.get("expiry_time")),
|
|
"current_tariff": tariff_title(cur.get("name")) if cur else None,
|
|
"bot_url": BOT_LINK,
|
|
"tariffs": [{"id": t["id"], "title": tariff_title(t["name"]), "days": t["duration_days"],
|
|
"price_rub": t["price_rub"]} for t in offer],
|
|
}
|
|
|
|
|
|
@app.post("/api/v1/payments")
|
|
async def create_payment(req: PayReq, request: Request):
|
|
rate_limit(request, per_min=10)
|
|
token = parse_token(req.subscription_url)
|
|
client = request.app.state.client
|
|
key = await find_key(client, token)
|
|
if not key:
|
|
raise HTTPException(404, "subscription not found")
|
|
_, offer = await offer_for_key(client, key)
|
|
t = next((x for x in offer if x["id"] == req.tariff_id), None)
|
|
if not t:
|
|
raise HTTPException(400, "tariff not available")
|
|
title = tariff_title(t["name"])
|
|
p = await yk_create(client, float(t["price_rub"]), f"Cosmo VPN: продление подписки — {title}",
|
|
{"source": "cosmo_app", "tariff_id": str(t["id"])})
|
|
con = db()
|
|
now = int(time.time())
|
|
con.execute("insert into payments values(?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
(p["id"], token_hash(token), key["client_id"], key.get("email"), key.get("tg_id"), t["id"], title,
|
|
t["duration_days"], float(t["price_rub"]), "pending", None, None, now, now))
|
|
con.commit()
|
|
log.info("payment %s created: tariff %s, %s RUB, key %s..", p["id"], t["id"], t["price_rub"], key["client_id"][:8])
|
|
return {"payment_id": p["id"], "confirmation_url": p["confirmation"]["confirmation_url"]}
|
|
|
|
|
|
@app.post("/api/v1/payments/status")
|
|
async def payment_status(req: StatusReq, request: Request):
|
|
rate_limit(request, per_min=60)
|
|
token = parse_token(req.subscription_url)
|
|
con = db()
|
|
row = con.execute("select * from payments where yk_id=? and token_hash=?", (req.payment_id, token_hash(token))).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "payment not found")
|
|
row = await process_payment(request.app.state.client, req.payment_id)
|
|
status = {"pending": "pending", "succeeded": "processing", "error": "processing",
|
|
"renewed": "renewed", "canceled": "canceled"}.get(row["status"], "pending")
|
|
return {"status": status, "expire_at": iso_ms(row["target_expiry"]) if status == "renewed" else None}
|
|
|
|
|
|
@app.get("/return", response_class=HTMLResponse)
|
|
async def return_page():
|
|
return """<!doctype html><html lang="ru"><head><meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1"><title>Cosmo VPN</title>
|
|
<style>body{font-family:system-ui,sans-serif;background:#17121c;color:#f2ddf9;display:flex;min-height:100vh;
|
|
align-items:center;justify-content:center;margin:0;text-align:center;padding:24px}
|
|
a{display:inline-block;margin-top:20px;padding:14px 22px;border-radius:14px;background:#d8468e;color:#fff;text-decoration:none}</style>
|
|
</head><body><div><h2>Оплата обрабатывается</h2><p>Вернитесь в приложение Cosmo VPN — подписка продлится автоматически.</p>
|
|
<a href="cosmovpn://payment-return">Открыть Cosmo VPN</a></div></body></html>"""
|