PWA for iPhone/browsers: trial, purchase, renewal, Happ hand-off, Web Push mailings and expiry reminders
This commit is contained in:
parent
43160e1a39
commit
44e6d1ba88
10 changed files with 770 additions and 6 deletions
172
app.py
172
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}</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")
|
||||
|
|
|
|||
15
install.sh
15
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
|
||||
|
|
|
|||
409
web/app.js
Normal file
409
web/app.js
Normal 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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
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
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
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
BIN
web/icons/maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 365 KiB |
107
web/index.html
Normal file
107
web/index.html
Normal 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
17
web/manifest.json
Normal 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
56
web/sw.js
Normal 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);
|
||||
})
|
||||
);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue