diff --git a/app.py b/app.py index be5c28a..10ff5f8 100644 --- a/app.py +++ b/app.py @@ -15,11 +15,13 @@ Secrets are read on the server only: SoloBot config.py (YooKassa keys, receipt s token) and /etc/cosmo-pay.env (SoloBot admin API token). Nothing secret is logged or returned. """ import asyncio +import base64 import hashlib import hmac import html import importlib.util import json +from urllib.parse import urlparse import logging import os import re @@ -34,7 +36,8 @@ from datetime import datetime, timezone import httpx from fastapi import FastAPI, HTTPException, Request -from fastapi.responses import HTMLResponse +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles from pydantic import BaseModel BOT_DIR = "/root/cosmonetvpn_bot" @@ -126,6 +129,9 @@ def db(): # Bot messages (admin mailings) for app users without Telegram; the app polls them. con.execute("create table if not exists inbox(id integer primary key autoincrement, tg_id integer, text text, created integer)") con.execute("create index if not exists inbox_tg on inbox(tg_id, id)") + # Web Push subscriptions of the PWA (iPhone and browsers): mailings + expiry reminders are pushed by the server. + con.execute("create table if not exists push_subs(endpoint text primary key, sub_json text, hwid_hash text, " + "sub_token text, reminded text, created integer, updated integer)") return con @@ -391,12 +397,12 @@ async def rw_clear_hwid(client, client_id: str): # ------------------------------------------------------------------ YooKassa -async def yk_create(client, amount: float, description: str, metadata: dict): +async def yk_create(client, amount: float, description: str, metadata: dict, return_url: str | None = None): value = f"{amount:.2f}" body = { "amount": {"value": value, "currency": "RUB"}, "capture": True, - "confirmation": {"type": "redirect", "return_url": f"{PUBLIC_BASE}/return"}, + "confirmation": {"type": "redirect", "return_url": return_url or f"{PUBLIC_BASE}/return"}, "description": description[:128], "metadata": metadata, } @@ -494,6 +500,101 @@ def new_user_offer(tariffs): return pool +# ------------------------------------------------------------------ Web Push (PWA) +VAPID_KEY_FILE = "/etc/cosmo-pay-vapid.pem" +VAPID_SUB = "https://bot.cosmonet.online" +PUSH_HOSTS = ("push.apple.com", "fcm.googleapis.com", "push.services.mozilla.com", "notify.windows.com") +REMIND_TEXT = { + "3": "Подписка заканчивается — осталось меньше 3 дней. Продлите её в Cosmo VPN.", + "1": "Подписка закончится меньше чем через сутки. Продлите её, чтобы VPN не отключился.", + "0": "Подписка закончилась. Продлите её в Cosmo VPN, чтобы снова подключиться.", +} +_vapid_pub = None + + +def vapid_public_key() -> str: + global _vapid_pub + if _vapid_pub is None: + from cryptography.hazmat.primitives import serialization + with open(VAPID_KEY_FILE, "rb") as f: + key = serialization.load_pem_private_key(f.read(), None) + raw = key.public_key().public_bytes(serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint) + _vapid_pub = base64.urlsafe_b64encode(raw).rstrip(b"=").decode() + return _vapid_pub + + +def _webpush_sync(sub_json: str, payload: dict) -> int: + from pywebpush import WebPushException, webpush + try: + r = webpush(subscription_info=json.loads(sub_json), data=json.dumps(payload, ensure_ascii=False), + vapid_private_key=VAPID_KEY_FILE, vapid_claims={"sub": VAPID_SUB}, ttl=86400, timeout=10) + return r.status_code + except WebPushException as e: + return e.response.status_code if e.response is not None else 0 + except Exception as e: + log.warning("webpush failed: %s", type(e).__name__) + return 0 + + +async def push_send(con, row, text: str, url: str) -> bool: + st = await asyncio.to_thread(_webpush_sync, row["sub_json"], {"title": "Cosmo VPN", "body": text, "url": url}) + if st in (404, 410): # subscription gone (app removed / permission revoked) + con.execute("delete from push_subs where endpoint=?", (row["endpoint"],)); con.commit() + return 200 <= st < 300 + + +async def push_key(client, con, row): + if row["sub_token"]: + return await find_key(client, row["sub_token"]) + _, key = await device_key(client, con, row["hwid_hash"]) + return key + + +async def push_to_tg(client, tg_id: int, text: str): + try: + con = db() + for row in con.execute("select * from push_subs").fetchall(): + key = await push_key(client, con, row) + if key and key.get("tg_id") == tg_id: + await push_send(con, row, text, f"{PUBLIC_BASE}/app/") + except Exception as e: + log.error("push mailing: %s", e) + + +def remind_stage(expiry_ms: int | None) -> str | None: + if not expiry_ms: + return None + left = expiry_ms - int(time.time() * 1000) + if left > 3650 * DAY_MS: + return None + if left <= 0: + return "0" + if left <= DAY_MS: + return "1" + if left <= 3 * DAY_MS: + return "3" + return None + + +async def push_reminders(client): + while True: + await asyncio.sleep(1800) + try: + if not os.path.exists(VAPID_KEY_FILE): + continue + con = db() + for row in con.execute("select * from push_subs").fetchall(): + key = await push_key(client, con, row) + exp = key.get("expiry_time") if key else None + stage = remind_stage(exp) + mark = f"{exp}|{stage}" + if stage and row["reminded"] != mark and await push_send(con, row, REMIND_TEXT[stage], f"{PUBLIC_BASE}/app/?renew=1"): + con.execute("update push_subs set reminded=?, updated=? where endpoint=?", + (mark, int(time.time()), row["endpoint"])); con.commit() + except Exception as e: + log.error("push reminders: %s", e) + + # ------------------------------------------------------------------ renewal (serialized, idempotent) renew_lock = asyncio.Lock() @@ -587,13 +688,26 @@ async def poller(client): async def lifespan(app): app.state.client = httpx.AsyncClient() task = asyncio.create_task(poller(app.state.client)) + reminders = asyncio.create_task(push_reminders(app.state.client)) yield task.cancel() + reminders.cancel() await app.state.client.aclose() app = FastAPI(lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None) _hits: dict[str, list[float]] = {} +WEB_DIR = "/opt/cosmo-pay/web" + + +@app.middleware("http") +async def web_cache_headers(request: Request, call_next): + resp = await call_next(request) + path = request.url.path + if path.startswith("/app"): + # The PWA shell and service worker must update promptly; icons may be cached. + resp.headers["Cache-Control"] = "public, max-age=86400" if "/icons/" in path else "no-cache" + return resp def rate_limit(request: Request, per_min: int = 30): @@ -625,6 +739,7 @@ class SubReq(BaseModel): class PayReq(SubReq): tariff_id: int devices: int | None = None + return_to: str | None = None # "web": come back to the PWA instead of the Android deep-link page class StatusReq(SubReq): @@ -736,8 +851,10 @@ async def create_payment(req: PayReq, request: Request): price, devices = price_for(t, devices) title = tariff_title(t["name"]) dev_txt = f", {devices} устр." if opts else "" + web = req.return_to == "web" p = await yk_create(client, float(price), f"Cosmo VPN: {title}{dev_txt}", - {"source": "cosmo_app", "tariff_id": str(t["id"])}) + {"source": "cosmo_web" if web else "cosmo_app", "tariff_id": str(t["id"])}, + return_url=f"{PUBLIC_BASE}/app/?paid=1" if web else None) new_key = not key client_id = key.get("client_id") or str(uuid.uuid4()) # new key: ids fixed now so creation retries are idempotent email = key.get("email") or gen_key_email() @@ -793,9 +910,46 @@ async def internal_inbox(msg: InternalMsg, request: Request): con.execute("insert into inbox(tg_id, text, created) values(?,?,?)", (msg.tg_id, text, now)) con.execute("delete from inbox where created < ?", (now - INBOX_KEEP_S,)) con.commit() + asyncio.create_task(push_to_tg(request.app.state.client, msg.tg_id, text)) # PWA users get it pushed right away return {"ok": True, "stored": True} +class PushSubReq(SubReq): + subscription: dict + + +@app.get("/api/v1/push/key") +async def push_key_endpoint(): + try: + return {"key": vapid_public_key()} + except OSError: + raise HTTPException(503, "push not configured") + + +@app.post("/api/v1/push/subscribe") +async def push_subscribe(req: PushSubReq, request: Request): + rate_limit(request, per_min=10) + ep = str(req.subscription.get("endpoint") or "") + keys = req.subscription.get("keys") or {} + host = urlparse(ep).hostname or "" + # Only real push services (the server posts to this URL). + if not ep.startswith("https://") or len(ep) > 1000 or not any(host == h or host.endswith("." + h) for h in PUSH_HOSTS) \ + or not isinstance(keys, dict) or not keys.get("p256dh") or not keys.get("auth"): + raise HTTPException(400, "bad push subscription") + if req.subscription_url: + token, hh = parse_token(req.subscription_url), None + else: + token, hh = None, hwid_hash(parse_hwid(req.hwid)) + sub_json = json.dumps({"endpoint": ep, "keys": {"p256dh": str(keys["p256dh"]), "auth": str(keys["auth"])}}) + now = int(time.time()) + con = db() + con.execute("insert into push_subs(endpoint, sub_json, hwid_hash, sub_token, created, updated) values(?,?,?,?,?,?) " + "on conflict(endpoint) do update set sub_json=excluded.sub_json, hwid_hash=excluded.hwid_hash, " + "sub_token=excluded.sub_token, updated=excluded.updated", (ep, sub_json, hh, token, now, now)) + con.commit() + return {"ok": True} + + @app.post("/api/v1/inbox") async def inbox(req: InboxReq, request: Request): """Polled by the app: current expiry (for local reminders) and new messages for this subscription. @@ -878,3 +1032,13 @@ align-items:center;justify-content:center;margin:0;text-align:center;padding:24p a{display:inline-block;margin-top:20px;padding:14px 22px;border-radius:14px;background:#d8468e;color:#fff;text-decoration:none}
Вернитесь в приложение Cosmo VPN — подписка продлится автоматически.
Открыть Cosmo VPN