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
""" + + +@app.get("/app") +async def web_app_redirect(): + return RedirectResponse(f"{PUBLIC_BASE}/app/", status_code=301) + + +# PWA (iPhone and any browser): static shell served from /opt/cosmo-pay/web, same API as the Android app. +if os.path.isdir(WEB_DIR): + app.mount("/app", StaticFiles(directory=WEB_DIR, html=True), name="web") diff --git a/install.sh b/install.sh index 0544588..b422d96 100644 --- a/install.sh +++ b/install.sh @@ -8,8 +8,19 @@ S=/tmp/cosmo-pay-src mkdir -p /opt/cosmo-pay /var/lib/cosmo-pay; chmod 700 /var/lib/cosmo-pay [ -d /opt/cosmo-pay/venv ] || python3 -m venv /opt/cosmo-pay/venv /opt/cosmo-pay/venv/bin/pip install -q --upgrade pip >/dev/null -/opt/cosmo-pay/venv/bin/pip install -q "fastapi>=0.110" "uvicorn>=0.29" "httpx>=0.27" >/dev/null -/opt/cosmo-pay/venv/bin/python -c "import fastapi, uvicorn, httpx; print('deps: fastapi', fastapi.__version__, '| uvicorn', uvicorn.__version__, '| httpx', httpx.__version__)" +/opt/cosmo-pay/venv/bin/pip install -q "fastapi>=0.110" "uvicorn>=0.29" "httpx>=0.27" "pywebpush>=2.0" >/dev/null +/opt/cosmo-pay/venv/bin/python -c "import fastapi, uvicorn, httpx, pywebpush; print('deps: fastapi', fastapi.__version__, '| uvicorn', uvicorn.__version__, '| httpx', httpx.__version__, '| pywebpush ok')" +# Web Push (PWA) VAPID key: generated once on the server, never printed. +if [ ! -s /etc/cosmo-pay-vapid.pem ]; then + (umask 077; /opt/cosmo-pay/venv/bin/python -c " +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives import serialization +k = ec.generate_private_key(ec.SECP256R1()) +open('/etc/cosmo-pay-vapid.pem', 'wb').write(k.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()))") + echo "vapid key created" +fi +# PWA static files +if [ -d $S/web ]; then rm -rf /opt/cosmo-pay/web.new; cp -r $S/web /opt/cosmo-pay/web.new; rm -rf /opt/cosmo-pay/web; mv /opt/cosmo-pay/web.new /opt/cosmo-pay/web; echo "web: $(find /opt/cosmo-pay/web -type f | wc -l) files"; fi [ -f /opt/cosmo-pay/app.py ] && cp -a /opt/cosmo-pay/app.py /opt/cosmo-pay/app.py.bak.$(date +%F-%H%M) install -m 600 $S/app.py /opt/cosmo-pay/app.py install -m 644 $S/cosmo-pay.service /etc/systemd/system/cosmo-pay.service diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..ae2981e --- /dev/null +++ b/web/app.js @@ -0,0 +1,409 @@ +// Cosmo VPN PWA: trial, purchase and renewal without Telegram (same Cosmo Pay API as the Android app). +// The PWA can't be a VPN itself on iOS: it manages the subscription and hands it to a VPN client (Happ). +"use strict"; + +const API = "/cosmo-pay/api/v1"; +const HAPP_IOS = "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973"; +const HAPP_ANDROID = "https://play.google.com/store/apps/details?id=com.happproxy"; +const SUB_RE = /^https:\/\/sub\.cosmonet\.online\/[A-Za-z0-9_-]{8,64}\/?$/; +const PENDING_TTL = 3 * 3600 * 1000; + +const ua = navigator.userAgent; +const isIOS = /iPhone|iPad|iPod/.test(ua) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); +const isAndroid = /Android/.test(ua); +const standalone = window.matchMedia("(display-mode: standalone)").matches || navigator.standalone === true; + +// ---------------------------------------------------------------- storage (safe in private mode) +const store = { + get(k) { try { return localStorage.getItem(k); } catch (_) { return null; } }, + set(k, v) { try { v == null ? localStorage.removeItem(k) : localStorage.setItem(k, v); } catch (_) {} }, +}; + +// Device id for the server (same role as the Android HWID): random, 32 hex, kept in this browser/PWA. +function webId() { + let id = store.get("cosmo_web_id"); + if (!/^[0-9a-f]{32}$/.test(id || "")) { + const b = new Uint8Array(16); + crypto.getRandomValues(b); + id = Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); + store.set("cosmo_web_id", id); + } + return id; +} + +let subUrl = store.get("cosmo_sub_url"); +const who = () => (subUrl ? { subscription_url: subUrl } : { hwid: webId() }); + +async function api(path, body) { + const r = await fetch(`${API}/${path}`, { + method: body ? "POST" : "GET", + headers: body ? { "Content-Type": "application/json" } : {}, + body: body ? JSON.stringify(body) : undefined, + }); + if (!r.ok) { const e = new Error(`HTTP ${r.status}`); e.status = r.status; throw e; } + return r.json(); +} + +// ---------------------------------------------------------------- helpers +const $ = (id) => document.getElementById(id); +const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); +const fmtDate = (iso) => { const d = new Date(iso); return isNaN(d) ? "" : d.toLocaleDateString("ru-RU"); }; +function plural(n, one, few, many) { + const m10 = n % 10, m100 = n % 100; + if (m10 === 1 && m100 !== 11) return one; + if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return few; + return many; +} +function toast(text) { + const t = $("toast"); + t.textContent = text; t.style.display = "block"; + clearTimeout(toast.timer); toast.timer = setTimeout(() => (t.style.display = "none"), 3500); +} +function setSubUrl(url) { + subUrl = url; store.set("cosmo_sub_url", url); + refreshPushIdentity(); +} +const spinner = '
'; +const logo = ''; + +function installBanner() { + if (standalone) return ""; + if (isIOS) { + return ``; + } + if (deferredInstall) { + return ``; + } + return ""; +} +let deferredInstall = null; +window.addEventListener("beforeinstallprompt", (e) => { e.preventDefault(); deferredInstall = e; }); + +// ---------------------------------------------------------------- views +async function start() { + const params = new URLSearchParams(location.search); + if (params.has("paid") || params.has("renew")) history.replaceState(null, "", location.pathname); + if (!subUrl) { + try { + const st = await api("device", { hwid: webId() }); + if (st.subscription_url) setSubUrl(st.subscription_url); + else return welcome(st); + } catch (e) { + return welcome(null); + } + } + await home(); + if (params.has("renew")) openSheet(); +} + +function welcome(st) { + const days = st && st.trial_available ? st.trial_days : null; + $("view").innerHTML = ` + ${logo} +

Добро пожаловать в Cosmo VPN

+

Попробуйте бесплатно или купите подписку прямо здесь — Telegram не нужен.

+ ${installBanner()} + ${days ? `` : ""} + +
+
+

Уже есть подписка?

+

Вставьте ссылку подписки из бота (https://sub.cosmonet.online/…).

+ + +
`; + bindInstall(); + $("buyBtn").onclick = () => openSheet(); + $("linkBtn").onclick = addLink; + if (days) $("trialBtn").onclick = activateTrial; +} + +async function activateTrial() { + const btn = $("trialBtn"); + btn.disabled = true; btn.innerHTML = spinner; + try { + const r = await api("trial", { hwid: webId() }); + setSubUrl(r.subscription_url); + toast("Пробный период активирован"); + await home(true); + } catch (e) { + const msg = e.status === 409 ? "Пробный период уже использован." : + e.status === 429 ? "Слишком много попыток. Попробуйте позже." : + "Не удалось связаться с сервером. Проверьте интернет и попробуйте ещё раз."; + $("welcomeErr").innerHTML = `

${msg}

`; + btn.remove(); + } +} + +async function addLink() { + const v = $("linkInput").value.trim(); + if (!SUB_RE.test(v)) { $("welcomeErr").innerHTML = '

Это не похоже на ссылку подписки Cosmo VPN.

'; return; } + try { + await api("tariffs", { subscription_url: v }); + } catch (e) { + $("welcomeErr").innerHTML = `

${e.status === 404 ? "Подписка не найдена." : "Не удалось проверить ссылку, попробуйте ещё раз."}

`; + return; + } + setSubUrl(v); + await home(); +} + +let offerCache = null; + +async function home(justCreated = false) { + $("view").innerHTML = `
${spinner}
`; + let offer = null; + try { offer = await api("tariffs", who()); } catch (e) { + if (e.status === 404) { // the bot removes subscriptions some time after they expire + setSubUrl(null); + return start(); + } + } + offerCache = offer; + const exp = offer && offer.expire_at ? new Date(offer.expire_at) : null; + const left = exp ? Math.ceil((exp - Date.now()) / 86400000) : null; + const expired = left !== null && left <= 0; + const farFuture = left !== null && left > 3650; + const statusLine = !offer ? "Не удалось загрузить данные подписки" + : expired ? "Подписка закончилась" + : farFuture ? "Подписка активна" + : `Осталось ${left} ${plural(left, "день", "дня", "дней")}`; + const happAdd = "happ://add/" + subUrl; + $("view").innerHTML = ` +

Cosmo VPN

+
+

${offer && offer.current_tariff ? esc(offer.current_tariff) : "Подписка"}

+
${statusLine}
+ ${exp && !farFuture ? `

${expired ? "Закончилась" : "Действует до"} ${fmtDate(offer.expire_at)}

` : ""} + Безлимитный трафик + ${offer && offer.current_devices ? `Устройств: ${offer.current_devices}` : ""} +
+ + ${installBanner()} +
+

Подключение

+

${justCreated ? "Подписка готова! " : ""}Добавьте подписку в приложение Happ и включите VPN в нём.

+ Добавить в Happ и подключиться + ${isAndroid ? `Открыть в приложении Cosmo VPN` : ""} + Нет Happ? Установить из ${isAndroid ? "Google Play" : "App Store"} + + +

Сохраните ссылку: по ней подписку можно добавить в любой VPN-клиент или восстановить на другом устройстве.

+
+
`; + bindInstall(); + $("renewBtn").onclick = () => openSheet(); + $("copyBtn").onclick = async () => { + try { await navigator.clipboard.writeText(subUrl); toast("Ссылка скопирована"); } + catch (_) { toast("Не удалось скопировать — выделите ссылку вручную"); } + }; + renderPushBox(); +} + +function bindInstall() { + const b = $("installBtn"); + if (b && deferredInstall) b.onclick = async () => { deferredInstall.prompt(); deferredInstall = null; b.parentElement.remove(); }; +} + +// ---------------------------------------------------------------- buy / renew sheet +const sheetState = { offer: null, selected: -1, devices: null, paying: false, error: null, loading: false }; + +function priceFor(t, devices) { + const opts = t.device_options || []; + if (!opts.length) return t.base_price_rub; + const base = Math.min(...opts); + const n = opts.includes(devices) ? devices : base; + if (n <= base) return t.base_price_rub; + const ov = (t.device_overrides || {})[String(n)]; + return t.base_price_rub + (ov != null ? Number(ov) : (n - base) * (t.device_step_rub || 0)); +} +const clampDevices = (t, d) => { + const opts = t.device_options || []; + if (!opts.length) return null; + return opts.includes(d) ? d : (t.default_devices ?? Math.min(...opts)); +}; + +async function openSheet() { + $("scrim").classList.add("open"); $("sheet").classList.add("open"); + $("scrim").onclick = closeSheet; + Object.assign(sheetState, { loading: true, error: null, paying: false }); + renderSheet(); + try { + const o = await api("tariffs", who()); + sheetState.offer = o; + const ids = o.tariffs.map((t) => t.id); + if (!ids.includes(sheetState.selected)) sheetState.selected = (o.tariffs.find((t) => t.days === 30) || o.tariffs[0] || {}).id ?? -1; + if (sheetState.devices == null) sheetState.devices = (o.tariffs.find((t) => (t.device_options || []).length) || {}).default_devices ?? null; + } catch (e) { + sheetState.error = "load"; + } + sheetState.loading = false; + renderSheet(); +} + +function closeSheet() { $("scrim").classList.remove("open"); $("sheet").classList.remove("open"); } + +function tariffCard(t) { + const [name, period] = t.title.includes(":") ? [t.title.split(":")[0].trim(), t.title.split(":").slice(1).join(":").trim()] : ["", t.title]; + const sel = t.id === sheetState.selected; + return ``; +} + +function renderSheet() { + const s = sheetState, o = s.offer; + const isNew = o ? o.is_new : !subUrl; + let body = ""; + if (s.loading) body = `
${spinner}
`; + else if (s.error === "load") body = `

Не удалось связаться с сервисом оплаты. Проверьте интернет и попробуйте ещё раз.

+ `; + else if (!o.renewable) body = `

Эту подписку нельзя продлить здесь. Откройте бота @cosmonetvpn_bot, чтобы выбрать тариф.

`; + else { + const standard = o.tariffs.filter((t) => !t.unlimited_devices), vip = o.tariffs.filter((t) => t.unlimited_devices); + const chosen = o.tariffs.find((t) => t.id === s.selected); + const ref = chosen && (chosen.device_options || []).length ? chosen : standard.find((t) => (t.device_options || []).length); + let stepper = ""; + if (ref) { + const opts = ref.device_options, n = clampDevices(ref, s.devices); + stepper = `
Устройств + + ${n} +
`; + } + const price = chosen ? priceFor(chosen, clampDevices(chosen, s.devices)) : null; + body = `${stepper} + ${standard.length ? `
${standard.map(tariffCard).join("")}
` : ""} + ${vip.length ? `
VIP · без лимита устройств
${vip.map(tariffCard).join("")}
` : ""} + ${s.error === "pay" ? '

Не удалось создать платёж. Попробуйте ещё раз через минуту.

' : ""} + +

Оплата картой или через СБП в ЮKassa. После оплаты вернитесь в Cosmo VPN — подписка ${isNew ? "появится" : "продлится"} автоматически.

`; + } + $("sheet").innerHTML = `
+

${isNew ? "Купить подписку" : "Продлить подписку"}

+ ${o && o.expire_at ? `

Действует до ${fmtDate(o.expire_at)}

` : ""} +
${body}`; + const retry = $("retryBtn"); if (retry) retry.onclick = openSheet; + document.querySelectorAll(".tariff").forEach((b) => (b.onclick = () => { + if (s.paying) return; + s.selected = Number(b.dataset.id); s.error = null; renderSheet(); + })); + const minus = $("devMinus"), plus = $("devPlus"); + const step = (dir) => { + const t = o.tariffs.find((x) => x.id === s.selected && (x.device_options || []).length) || + o.tariffs.find((x) => !x.unlimited_devices && (x.device_options || []).length); + const opts = t.device_options, n = clampDevices(t, s.devices); + s.devices = dir < 0 ? [...opts].reverse().find((x) => x < n) ?? n : opts.find((x) => x > n) ?? n; + renderSheet(); + }; + if (minus) minus.onclick = () => step(-1); + if (plus) plus.onclick = () => step(1); + const pay = $("payBtn"); if (pay) pay.onclick = startPayment; +} + +async function startPayment() { + const s = sheetState, t = s.offer.tariffs.find((x) => x.id === s.selected); + if (!t) return; + s.paying = true; s.error = null; renderSheet(); + try { + const body = { ...who(), tariff_id: t.id, return_to: "web" }; + const d = clampDevices(t, s.devices); + if (d != null) body.devices = d; + const r = await api("payments", body); + // Status must be asked with the same identity the payment was created with. + store.set("cosmo_pending", JSON.stringify({ id: r.payment_id, at: Date.now(), sub: subUrl || "" })); + location.href = r.confirmation_url; + } catch (e) { + s.paying = false; s.error = "pay"; renderSheet(); + } +} + +// ---------------------------------------------------------------- pending payment +let polling = false; +async function pollPending() { + if (polling) return; + let p = null; + try { p = JSON.parse(store.get("cosmo_pending") || "null"); } catch (_) {} + if (!p || !p.id) return; + if (Date.now() - p.at > PENDING_TTL) { store.set("cosmo_pending", null); return; } + polling = true; + const ident = p.sub ? { subscription_url: p.sub } : { hwid: webId() }; + try { + for (let i = 0; i < 40 && document.visibilityState === "visible"; i++) { + let st = null; + try { st = await api("payments/status", { ...ident, payment_id: p.id }); } catch (_) {} + if (st && st.status === "renewed") { + store.set("cosmo_pending", null); + if (st.subscription_url && !subUrl) setSubUrl(st.subscription_url); + closeSheet(); + toast(st.expire_at ? `Подписка оплачена до ${fmtDate(st.expire_at)}` : "Подписка оплачена"); + await home(!!st.subscription_url); + return; + } + if (st && st.status === "canceled") { store.set("cosmo_pending", null); toast("Оплата отменена"); return; } + await new Promise((r) => setTimeout(r, 3000)); + } + } finally { polling = false; } +} +document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") pollPending(); }); + +// ---------------------------------------------------------------- push notifications +const pushSupported = "serviceWorker" in navigator && "PushManager" in window && "Notification" in window; + +function renderPushBox() { + const box = $("pushBox"); + if (!box) return; + if (!pushSupported) { + box.innerHTML = isIOS && !standalone + ? '

Уведомления о подписке появятся, когда вы добавите Cosmo VPN на экран «Домой».

' : ""; + return; + } + if (Notification.permission === "granted") { box.innerHTML = ""; refreshPushIdentity(); return; } + if (Notification.permission === "denied") { + box.innerHTML = '

Уведомления выключены в настройках устройства.

'; return; + } + box.innerHTML = `

Уведомления

+

Напомним, когда подписка будет заканчиваться, и пришлём важные новости.

+
`; + $("pushBtn").onclick = enablePush; +} + +async function enablePush() { + try { + const perm = await Notification.requestPermission(); + if (perm !== "granted") { renderPushBox(); return; } + await refreshPushIdentity(true); + toast("Уведомления включены"); + } catch (e) { + toast("Не удалось включить уведомления"); + } + renderPushBox(); +} + +function b64ToBytes(b64) { + const pad = "=".repeat((4 - (b64.length % 4)) % 4); + const raw = atob((b64 + pad).replace(/-/g, "+").replace(/_/g, "/")); + return Uint8Array.from(raw, (c) => c.charCodeAt(0)); +} + +// (Re)sends this browser's push subscription with the current identity (device id or subscription link). +async function refreshPushIdentity(create = false) { + if (!pushSupported || Notification.permission !== "granted") return; + try { + const reg = await navigator.serviceWorker.ready; + let sub = await reg.pushManager.getSubscription(); + if (!sub && create) { + const { key } = await api("push/key"); + sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64ToBytes(key) }); + } + if (sub) await api("push/subscribe", { ...who(), subscription: sub.toJSON() }); + } catch (e) { + console.warn("push", e); + } +} + +// ---------------------------------------------------------------- boot +if ("serviceWorker" in navigator) navigator.serviceWorker.register("sw.js").catch(() => {}); +start().then(pollPending); diff --git a/web/icons/icon-180.png b/web/icons/icon-180.png new file mode 100644 index 0000000..64ab0bb Binary files /dev/null and b/web/icons/icon-180.png differ diff --git a/web/icons/icon-192.png b/web/icons/icon-192.png new file mode 100644 index 0000000..20e674d Binary files /dev/null and b/web/icons/icon-192.png differ diff --git a/web/icons/icon-512.png b/web/icons/icon-512.png new file mode 100644 index 0000000..d57ed72 Binary files /dev/null and b/web/icons/icon-512.png differ diff --git a/web/icons/maskable-512.png b/web/icons/maskable-512.png new file mode 100644 index 0000000..c818b77 Binary files /dev/null and b/web/icons/maskable-512.png differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..4defe11 --- /dev/null +++ b/web/index.html @@ -0,0 +1,107 @@ + + + + + +Cosmo VPN + + + + + + + + + + + + +
+
+
+
+ + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..f36457a --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,17 @@ +{ + "name": "Cosmo VPN", + "short_name": "Cosmo VPN", + "description": "Подписка Cosmo VPN: пробный период, покупка и продление без Telegram", + "lang": "ru", + "start_url": "/cosmo-pay/app/", + "scope": "/cosmo-pay/app/", + "display": "standalone", + "orientation": "portrait", + "background_color": "#17121c", + "theme_color": "#17121c", + "icons": [ + {"src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png"}, + {"src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png"}, + {"src": "icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable"} + ] +} diff --git a/web/sw.js b/web/sw.js new file mode 100644 index 0000000..40f8ce9 --- /dev/null +++ b/web/sw.js @@ -0,0 +1,56 @@ +// Cosmo VPN PWA service worker: offline shell + Web Push (mailings and expiry reminders from the server). +const CACHE = "cosmo-shell-v1"; +const SHELL = ["./", "app.js", "manifest.json", "icons/icon-192.png", "icons/icon-180.png"]; + +self.addEventListener("install", (e) => { + e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting())); +}); + +self.addEventListener("activate", (e) => { + e.waitUntil( + caches.keys() + .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))) + .then(() => self.clients.claim()) + ); +}); + +// Shell: network first (always fresh), cache as offline fallback. API calls are never cached. +self.addEventListener("fetch", (e) => { + const url = new URL(e.request.url); + if (e.request.method !== "GET" || url.origin !== location.origin || url.pathname.includes("/api/")) return; + e.respondWith( + fetch(e.request) + .then((resp) => { + const copy = resp.clone(); + caches.open(CACHE).then((c) => c.put(e.request, copy)); + return resp; + }) + .catch(() => caches.match(e.request, { ignoreSearch: true }).then((r) => r || caches.match("./"))) + ); +}); + +self.addEventListener("push", (e) => { + let data = {}; + try { data = e.data ? e.data.json() : {}; } catch (_) { data = { body: e.data && e.data.text() }; } + e.waitUntil( + self.registration.showNotification(data.title || "Cosmo VPN", { + body: data.body || "", + icon: "icons/icon-192.png", + badge: "icons/icon-192.png", + data: { url: data.url || "./" }, + }) + ); +}); + +self.addEventListener("notificationclick", (e) => { + e.notification.close(); + const target = new URL(e.notification.data && e.notification.data.url || "./", self.registration.scope).href; + e.waitUntil( + self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((list) => { + for (const c of list) { + if (c.url.startsWith(self.registration.scope)) { c.navigate(target); return c.focus(); } + } + return self.clients.openWindow(target); + }) + ); +});