409 lines
20 KiB
JavaScript
409 lines
20 KiB
JavaScript
// 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);
|