PWA for iPhone/browsers: trial, purchase, renewal, Happ hand-off, Web Push mailings and expiry reminders

This commit is contained in:
Cosmo VPN 2026-10-01 01:20:45 +03:00
parent 43160e1a39
commit 44e6d1ba88
10 changed files with 770 additions and 6 deletions

172
app.py
View file

@ -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}</style>
</head><body><div><h2>Оплата обрабатывается</h2><p>Вернитесь в приложение Cosmo VPN — подписка продлится автоматически.</p>
<a href="cosmovpn://payment-return">Открыть Cosmo VPN</a></div></body></html>"""
@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")

View file

@ -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

409
web/app.js Normal file
View file

@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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 = '<div class="spinner"></div>';
const logo = '<img class="logo" src="icons/icon-192.png" alt="">';
function installBanner() {
if (standalone) return "";
if (isIOS) {
return `<div class="banner">📲 <b>Установите Cosmo VPN на экран «Домой»</b>: нажмите «Поделиться» <span aria-hidden="true">⬆️</span>
внизу Safari → «На экран „Домой“». Так приложение запомнит подписку и сможет присылать уведомления.</div>`;
}
if (deferredInstall) {
return `<div class="banner">📲 <b>Установите Cosmo VPN</b> на главный экран.
<button class="btn outline" id="installBtn">Установить</button></div>`;
}
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}
<h1 class="center">Добро пожаловать в Cosmo VPN</h1>
<p class="center muted">Попробуйте бесплатно или купите подписку прямо здесь — Telegram не нужен.</p>
${installBanner()}
${days ? `<button class="btn" id="trialBtn">Попробовать бесплатно — ${days} ${plural(days, "день", "дня", "дней")}</button>` : ""}
<button class="btn ${days ? "outline" : ""}" id="buyBtn">Купить подписку</button>
<div id="welcomeErr"></div>
<div class="card">
<p><b>Уже есть подписка?</b></p>
<p class="muted" style="font-size:14px;margin-top:4px">Вставьте ссылку подписки из бота (https://sub.cosmonet.online/…).</p>
<input class="link" id="linkInput" type="url" inputmode="url" autocomplete="off" placeholder="https://sub.cosmonet.online/…">
<button class="btn outline" id="linkBtn">Добавить подписку</button>
</div>`;
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 = `<p class="error">${msg}</p>`;
btn.remove();
}
}
async function addLink() {
const v = $("linkInput").value.trim();
if (!SUB_RE.test(v)) { $("welcomeErr").innerHTML = '<p class="error">Это не похоже на ссылку подписки Cosmo VPN.</p>'; return; }
try {
await api("tariffs", { subscription_url: v });
} catch (e) {
$("welcomeErr").innerHTML = `<p class="error">${e.status === 404 ? "Подписка не найдена." : "Не удалось проверить ссылку, попробуйте ещё раз."}</p>`;
return;
}
setSubUrl(v);
await home();
}
let offerCache = null;
async function home(justCreated = false) {
$("view").innerHTML = `<div class="loading">${spinner}</div>`;
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 = `
<h1>Cosmo VPN</h1>
<div class="card status ${expired ? "expired" : ""}">
<p class="muted" style="color:#e8d3f3">${offer && offer.current_tariff ? esc(offer.current_tariff) : "Подписка"}</p>
<div class="big">${statusLine}</div>
${exp && !farFuture ? `<p style="color:#e8d3f3">${expired ? "Закончилась" : "Действует до"} ${fmtDate(offer.expire_at)}</p>` : ""}
<span class="pill">Безлимитный трафик</span>
${offer && offer.current_devices ? `<span class="pill">Устройств: ${offer.current_devices}</span>` : ""}
</div>
<button class="btn" id="renewBtn">Купить / продлить подписку</button>
${installBanner()}
<div class="card">
<h2>Подключение</h2>
<p class="muted" style="font-size:14px">${justCreated ? "Подписка готова! " : ""}Добавьте подписку в приложение Happ и включите VPN в нём.</p>
<a class="btn" href="${esc(happAdd)}">Добавить в Happ и подключиться</a>
${isAndroid ? `<a class="btn outline" href="cosmovpn://install-sub?url=${encodeURIComponent(subUrl)}">Открыть в приложении Cosmo VPN</a>` : ""}
<a class="btn text" href="${isAndroid ? HAPP_ANDROID : HAPP_IOS}" target="_blank" rel="noopener">Нет Happ? Установить из ${isAndroid ? "Google Play" : "App Store"}</a>
<button class="btn text" id="copyBtn">Скопировать ссылку подписки</button>
<p class="linkbox">${esc(subUrl)}</p>
<p class="note">Сохраните ссылку: по ней подписку можно добавить в любой VPN-клиент или восстановить на другом устройстве.</p>
</div>
<div id="pushBox"></div>`;
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 `<button class="tariff ${sel ? "sel" : ""}" data-id="${t.id}">
<div class="period">${esc(period)}</div>${name ? `<div class="name">${esc(name)}</div>` : ""}
<div class="price">${priceFor(t, clampDevices(t, sheetState.devices))} ₽</div></button>`;
}
function renderSheet() {
const s = sheetState, o = s.offer;
const isNew = o ? o.is_new : !subUrl;
let body = "";
if (s.loading) body = `<div class="loading">${spinner}</div>`;
else if (s.error === "load") body = `<p>Не удалось связаться с сервисом оплаты. Проверьте интернет и попробуйте ещё раз.</p>
<button class="btn outline" id="retryBtn">Повторить</button>`;
else if (!o.renewable) body = `<p>Эту подписку нельзя продлить здесь. Откройте бота @cosmonetvpn_bot, чтобы выбрать тариф.</p>`;
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 = `<div class="stepper"><span class="lbl">Устройств</span>
<button id="devMinus" ${s.paying || n <= Math.min(...opts) ? "disabled" : ""}>−</button>
<span class="n">${n}</span>
<button id="devPlus" ${s.paying || n >= Math.max(...opts) ? "disabled" : ""}>+</button></div>`;
}
const price = chosen ? priceFor(chosen, clampDevices(chosen, s.devices)) : null;
body = `${stepper}
${standard.length ? `<div class="grid">${standard.map(tariffCard).join("")}</div>` : ""}
${vip.length ? `<div class="section">VIP <span>· без лимита устройств</span></div><div class="grid">${vip.map(tariffCard).join("")}</div>` : ""}
${s.error === "pay" ? '<p class="error">Не удалось создать платёж. Попробуйте ещё раз через минуту.</p>' : ""}
<button class="btn" id="payBtn" ${!chosen || s.paying ? "disabled" : ""}>${s.paying ? spinner : price != null ? `Оплатить ${price} ₽` : "Выберите тариф"}</button>
<p class="note">Оплата картой или через СБП в ЮKassa. После оплаты вернитесь в Cosmo VPN — подписка ${isNew ? "появится" : "продлится"} автоматически.</p>`;
}
$("sheet").innerHTML = `<div class="grip"></div>
<h2>${isNew ? "Купить подписку" : "Продлить подписку"}</h2>
${o && o.expire_at ? `<p class="muted">Действует до ${fmtDate(o.expire_at)}</p>` : ""}
<div style="height:10px"></div>${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
? '<p class="note center">Уведомления о подписке появятся, когда вы добавите Cosmo VPN на экран «Домой».</p>' : "";
return;
}
if (Notification.permission === "granted") { box.innerHTML = ""; refreshPushIdentity(); return; }
if (Notification.permission === "denied") {
box.innerHTML = '<p class="note center">Уведомления выключены в настройках устройства.</p>'; return;
}
box.innerHTML = `<div class="card"><h2>Уведомления</h2>
<p class="muted" style="font-size:14px">Напомним, когда подписка будет заканчиваться, и пришлём важные новости.</p>
<button class="btn outline" id="pushBtn">Включить уведомления</button></div>`;
$("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);

BIN
web/icons/icon-180.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

BIN
web/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

BIN
web/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 KiB

BIN
web/icons/maskable-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

107
web/index.html Normal file
View file

@ -0,0 +1,107 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>Cosmo VPN</title>
<meta name="description" content="Cosmo VPN: пробный период, покупка и продление подписки без Telegram">
<meta name="theme-color" content="#17121c">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Cosmo VPN">
<link rel="manifest" href="manifest.json">
<link rel="apple-touch-icon" href="icons/icon-180.png">
<link rel="icon" type="image/png" sizes="192x192" href="icons/icon-192.png">
<style>
:root {
--bg: #17121c; --surface: #221a29; --surface2: #2c2234; --outline: #4a3d55;
--text: #efe3f5; --muted: #b3a4bd; --primary: #d9a6f5; --on-primary: #3b1150;
--sel: #6a2b8e; --sel-border: #e3b3ff; --accent: #d9a6f5; --error: #ffb4ab; --ok: #c8f5dc;
}
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { margin: 0; background: var(--bg); color: var(--text); }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
min-height: 100vh; min-height: 100dvh;
padding: max(16px, env(safe-area-inset-top)) 16px max(20px, env(safe-area-inset-bottom));
}
main { max-width: 480px; margin: 0 auto; }
h1 { font-size: 26px; font-weight: 600; margin: 8px 0 6px; }
h2 { font-size: 20px; font-weight: 600; margin: 0 0 4px; }
p { margin: 0; line-height: 1.45; }
.muted { color: var(--muted); }
.center { text-align: center; }
.logo { width: 116px; height: 116px; border-radius: 50%; display: block; margin: 28px auto 18px; box-shadow: 0 0 0 5px rgba(217,166,245,.35); }
.btn {
display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; min-height: 52px;
border: 0; border-radius: 14px; font: inherit; font-size: 16px; font-weight: 600; cursor: pointer;
background: var(--primary); color: var(--on-primary); text-decoration: none; margin-top: 10px; padding: 0 16px;
}
.btn.outline { background: transparent; color: var(--primary); border: 1px solid var(--outline); }
.btn.text { background: transparent; color: var(--primary); min-height: 44px; font-weight: 500; }
.btn[disabled] { opacity: .55; cursor: default; }
.row { display: flex; gap: 8px; }
.row > * { flex: 1; }
.card { background: var(--surface); border: 1px solid var(--outline); border-radius: 16px; padding: 16px; margin-top: 14px; }
.status { background: var(--sel); border-color: transparent; }
.status.expired { background: #5c1f24; }
.status .big { font-size: 22px; font-weight: 600; margin: 2px 0 4px; }
.pill { display: inline-block; padding: 4px 10px; border-radius: 99px; background: rgba(255,255,255,.12); font-size: 13px; margin: 8px 6px 0 0; }
.banner { background: var(--surface2); border-radius: 14px; padding: 12px 14px; margin-top: 14px; font-size: 14px; line-height: 1.45; }
.banner b { color: var(--accent); }
.error { color: var(--error); font-size: 14px; margin-top: 10px; }
.spinner { width: 22px; height: 22px; border: 2.5px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.loading { display: flex; justify-content: center; padding: 60px 0; color: var(--primary); }
input.link {
width: 100%; min-height: 48px; border-radius: 12px; border: 1px solid var(--outline); background: var(--surface2);
color: var(--text); font: inherit; font-size: 15px; padding: 0 12px; margin-top: 10px;
}
.linkbox { font-family: ui-monospace, Menlo, monospace; font-size: 12px; word-break: break-all; color: var(--muted); margin-top: 8px; }
/* bottom sheet */
.scrim { position: fixed; inset: 0; background: rgba(0,0,0,.55); display: none; z-index: 10; }
.scrim.open { display: block; }
.sheet {
position: fixed; left: 0; right: 0; bottom: 0; max-height: 92vh; overflow-y: auto; z-index: 11;
background: var(--surface); border-radius: 22px 22px 0 0; padding: 10px 16px max(18px, env(safe-area-inset-bottom));
transform: translateY(100%); transition: transform .22s ease; max-width: 560px; margin: 0 auto;
}
.sheet.open { transform: translateY(0); }
.grip { width: 36px; height: 4px; border-radius: 4px; background: var(--outline); margin: 4px auto 14px; }
.section { color: var(--accent); font-weight: 600; font-size: 16px; margin: 16px 0 8px; }
.section span { color: var(--muted); font-weight: 400; font-size: 14px; }
.stepper { display: flex; align-items: center; margin: 4px 0 10px; }
.stepper .lbl { flex: 1; font-size: 16px; }
.stepper button {
width: 44px; height: 44px; border-radius: 50%; border: 1px solid var(--outline); background: transparent;
color: var(--text); font-size: 22px; cursor: pointer;
}
.stepper button[disabled] { opacity: .35; }
.stepper .n { width: 52px; text-align: center; font-size: 22px; font-weight: 600; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.tariff {
text-align: left; border: 1px solid var(--outline); background: var(--bg); color: var(--text); border-radius: 14px;
padding: 12px 14px; font: inherit; cursor: pointer;
}
.tariff.sel { background: var(--sel); border: 1.5px solid var(--sel-border); }
.tariff .period { font-size: 17px; font-weight: 500; }
.tariff .name { font-size: 12px; color: var(--muted); margin-top: 2px; }
.tariff.sel .name { color: #e8d3f3; }
.tariff .price { font-size: 22px; font-weight: 700; margin-top: 8px; }
.note { font-size: 12px; color: var(--muted); margin-top: 10px; line-height: 1.4; }
.toast {
position: fixed; left: 50%; bottom: max(24px, env(safe-area-inset-bottom)); transform: translateX(-50%);
background: #efe3f5; color: #2a1633; padding: 10px 16px; border-radius: 12px; font-size: 14px; z-index: 20;
max-width: calc(100% - 32px); display: none;
}
</style>
</head>
<body>
<main id="view"><div class="loading"><div class="spinner"></div></div></main>
<div class="scrim" id="scrim"></div>
<div class="sheet" id="sheet"></div>
<div class="toast" id="toast"></div>
<script src="app.js"></script>
</body>
</html>

17
web/manifest.json Normal file
View file

@ -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"}
]
}

56
web/sw.js Normal file
View file

@ -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);
})
);
});