Unify auth into refresh-token cookie sessions; server-authoritative nodes and Cyberhack

Auth was effectively broken for every login path: the Telegram handler
built a Set-Cookie header but never attached it to the response, and
Ghost Protocol (seed) login never set a cookie at all — so billing, the
game, and admin actions never actually worked once the frontend was fixed
to stop trying to read an HttpOnly cookie from JS. Replaced the bare
10-minute JWT with AuthService::issue_session: a 15-minute access cookie
plus a rotating 30-day refresh cookie (opaque token, only its SHA-256
hash stored in the new refresh_sessions table); reusing an already-
rotated refresh token now revokes every session for that user. CSRF/CORS
origin allowlists moved from a single hardcoded domain to env-configured
lists so the same session works across the landing and account subdomains.
The bot's WebApp deep links (Личный Кабинет/Тарифы/Синдикат) now bridge
through a short-lived bootstrap token exchanged via POST /auth/exchange
instead of a bare access token in the URL.

Nodes: /nodes/routing now serializes the tunnel_* fields, public_key,
sni_domain and a one-time session token gated on an active subscription
instead of a stub ip/port/protocol list; node provisioning returns 202
immediately and finishes in the background instead of blocking the
request for the whole SSH run; a new background task TCP-pings nodes
every 60s and flips online/offline itself; admin can now force-restart a
node over SSH.

Billing: TON exchange rate is cached in Redis (60s) instead of hitting
TonAPI on every invoice, and the request/response now actually uses the
requested currency and TonAPI's real (uppercase) key casing — previously
only USD/RUB were ever requested and the lookup used the wrong case, so
non-USD/RUB plans could never price correctly.

Cyberhack: the server now generates and stores the board itself and
replays the client's raw click path to compute the score, instead of
clamping a client-reported number — closes the "final score is whatever
the browser sends" hole flagged in the security audit.

Frontend: api.ts no longer tries to read the HttpOnly session cookie from
JS (that never worked) and instead relies on same-origin credentials plus
a silent refresh-and-retry on 401; AdminDashboard's restart/delete actions
are wired up; Auth.tsx drops the artificial delays and requires an
explicit seed download/confirmation before continuing, since a lost Ghost
seed is unrecoverable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 15:29:51 +07:00
parent 588adbb92b
commit 52531dd41a
41 changed files with 2219 additions and 508 deletions
+110 -17
View File
@@ -1,6 +1,14 @@
import { useState, useEffect } from "react";
import { Shield, Server, Activity, Plus, Power, RefreshCw } from "lucide-react";
import { getTokenCookie, API_BASE } from "./api";
import {
Shield,
Server,
Activity,
Plus,
Power,
RefreshCw,
Trash2,
} from "lucide-react";
import { apiFetch } from "./api";
interface VpnNode {
id: string;
@@ -15,18 +23,20 @@ interface VpnNode {
export default function AdminDashboard() {
const [nodes, setNodes] = useState<VpnNode[]>([]);
const [loading, setLoading] = useState(true);
const [busyNodeId, setBusyNodeId] = useState<string | null>(null);
const [name, setName] = useState("");
const [ip, setIp] = useState("");
const [code, setCode] = useState("de");
const [sshPassword, setSshPassword] = useState("");
const [sniDomain, setSniDomain] = useState("");
const [publicKey, setPublicKey] = useState("");
const fetchNodes = async () => {
try {
const token = getTokenCookie();
const res = await fetch(`${API_BASE}/nodes`, {
headers: { Authorization: `Bearer ${token}` },
});
// /admin/nodes (не публичный /nodes) — админке нужно видеть все ноды,
// включая "provisioning"/"offline", а не только "online".
const res = await apiFetch("/admin/nodes");
if (!res.ok) throw new Error("API Error");
const data = await res.json();
if (Array.isArray(data)) {
@@ -58,25 +68,24 @@ export default function AdminDashboard() {
const handleAddNode = async (e: React.FormEvent) => {
e.preventDefault();
try {
const token = getTokenCookie();
const res = await fetch(`${API_BASE}/admin/nodes`, {
const res = await apiFetch("/admin/nodes", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
name,
country_code: code,
ip_address: ip,
port: 443,
ssh_password: sshPassword,
public_key: publicKey || null,
sni_domain: sniDomain || null,
}),
});
if (res.ok) {
setName("");
setIp("");
setSshPassword("");
setSniDomain("");
setPublicKey("");
fetchNodes();
} else {
console.error("Failed to add node:", await res.text());
@@ -86,6 +95,53 @@ export default function AdminDashboard() {
}
};
const handleRestartNode = async (node: VpnNode) => {
if (
!window.confirm(
`Force restart ${node.name}? Активные соединения на этой ноде оборвутся.`,
)
) {
return;
}
setBusyNodeId(node.id);
try {
const res = await apiFetch(`/admin/nodes/${node.id}/restart`, {
method: "POST",
});
if (!res.ok) console.error("Failed to restart node:", await res.text());
await fetchNodes();
} catch (e) {
console.error(e);
} finally {
setBusyNodeId(null);
}
};
const handleDeleteNode = async (node: VpnNode) => {
if (
!window.confirm(
`Удалить ноду ${node.name} безвозвратно? Это действие нельзя отменить.`,
)
) {
return;
}
setBusyNodeId(node.id);
try {
const res = await apiFetch(`/admin/nodes/${node.id}`, {
method: "DELETE",
});
if (res.ok) {
setNodes((prev) => prev.filter((n) => n.id !== node.id));
} else {
console.error("Failed to delete node:", await res.text());
}
} catch (e) {
console.error(e);
} finally {
setBusyNodeId(null);
}
};
return (
<div className="min-h-screen bg-[#05050A] text-foreground font-mono p-6 sm:p-12">
<header className="flex items-center justify-between mb-12 border-b border-white/10 pb-6">
@@ -171,6 +227,30 @@ export default function AdminDashboard() {
/>
</div>
<div>
<label className="text-[10px] text-muted-foreground uppercase tracking-widest">
SNI Decoy Domain (optional)
</label>
<input
value={sniDomain}
onChange={(e) => setSniDomain(e.target.value)}
placeholder="cloudflare.com"
className="w-full bg-white/5 border border-white/10 rounded-lg p-3 text-sm focus:border-secondary outline-none transition-colors mt-1 font-mono"
/>
</div>
<div>
<label className="text-[10px] text-muted-foreground uppercase tracking-widest">
Public Key (optional)
</label>
<input
value={publicKey}
onChange={(e) => setPublicKey(e.target.value)}
placeholder="node identity public key"
className="w-full bg-white/5 border border-white/10 rounded-lg p-3 text-sm focus:border-secondary outline-none transition-colors mt-1 font-mono"
/>
</div>
<button
type="submit"
className="w-full mt-4 bg-secondary/10 hover:bg-secondary/20 text-secondary border border-secondary/30 font-bold uppercase tracking-widest text-xs py-3 rounded-lg transition-all flex items-center justify-center gap-2"
@@ -194,7 +274,13 @@ export default function AdminDashboard() {
<div>
<div className="flex items-center gap-3 mb-1">
<div
className={`w-2 h-2 rounded-full ${node.status === "online" ? "bg-green-500 animate-pulse" : "bg-red-500"}`}
className={`w-2 h-2 rounded-full ${
node.status === "online"
? "bg-green-500 animate-pulse"
: node.status === "provisioning"
? "bg-yellow-500 animate-pulse"
: "bg-red-500"
}`}
/>
<h3 className="font-bold text-lg">{node.name}</h3>
<span className="text-[10px] bg-white/10 px-2 py-0.5 rounded text-muted-foreground uppercase">
@@ -218,15 +304,22 @@ export default function AdminDashboard() {
/>
</div>
</div>
{/* TODO: декоративная кнопка без onClick — ни рестарт, ни удаление
ноды не реализованы на фронте, хотя бэкенд уже умеет
DELETE /api/v1/admin/nodes/{id} (см. nodes/controller.rs::remove_node). */}
<button
className="p-2 bg-red-500/10 hover:bg-red-500/20 text-red-500 border border-red-500/20 rounded-lg transition-colors"
onClick={() => handleRestartNode(node)}
disabled={busyNodeId === node.id}
className="p-2 bg-red-500/10 hover:bg-red-500/20 text-red-500 border border-red-500/20 rounded-lg transition-colors disabled:opacity-40"
title="Force Restart"
>
<Power className="w-4 h-4" />
</button>
<button
onClick={() => handleDeleteNode(node)}
disabled={busyNodeId === node.id}
className="p-2 bg-white/5 hover:bg-red-500/20 text-muted-foreground hover:text-red-500 border border-white/10 hover:border-red-500/20 rounded-lg transition-colors disabled:opacity-40"
title="Terminate Node"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
+40 -2
View File
@@ -1,6 +1,7 @@
import { Suspense, lazy, useState } from "react";
import { Suspense, lazy, useEffect, useState } from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { TonConnectUIProvider } from "@tonconnect/ui-react";
import { exchangeBootstrapToken } from "./api";
const Profile = lazy(() => import("./Profile"));
const HackGame = lazy(() => import("./HackGame"));
@@ -15,9 +16,40 @@ const DICT = {
const getLang = (): "en" | "ru" =>
localStorage.getItem("lang") === "ru" ? "ru" : "en";
/// Мост из Telegram-бота (кнопки «Личный Кабинет»/«Тарифы»/«Синдикат»): бот
/// открывает WebApp с одноразовым bootstrap-токеном в hash-фрагменте
/// (`#token=...`), т.к. это единственный канал передачи данных при открытии
/// внешней ссылки из Telegram. Здесь меняем его на настоящую cookie-сессию и
/// сразу же чистим URL, чтобы токен не осел в истории браузера.
function useBootstrapTokenExchange() {
const [ready, setReady] = useState(false);
useEffect(() => {
const hash = window.location.hash;
const match = hash.match(/token=([^&]+)/);
if (!match) {
setReady(true);
return;
}
const token = decodeURIComponent(match[1]);
window.history.replaceState(
null,
"",
window.location.pathname + window.location.search,
);
exchangeBootstrapToken(token).finally(() => setReady(true));
}, []);
return ready;
}
function AuthHandler({ children }: { children: React.ReactNode }) {
const [lang] = useState<"en" | "ru">(getLang);
const t = DICT[lang];
const bootstrapReady = useBootstrapTokenExchange();
return (
<div className="relative min-h-screen w-full overflow-x-hidden bg-black">
@@ -28,7 +60,13 @@ function AuthHandler({ children }: { children: React.ReactNode }) {
</div>
}
>
{children}
{bootstrapReady ? (
children
) : (
<div className="flex items-center justify-center min-h-screen text-secondary font-mono">
{t.loading}
</div>
)}
</Suspense>
</div>
);
+125 -52
View File
@@ -8,8 +8,9 @@ import {
Loader2,
Terminal,
Key,
Download,
} from "lucide-react";
import { removeTokenCookie } from "./api";
import { apiFetch } from "./api";
export interface TelegramAuthUser {
id: number;
@@ -36,11 +37,17 @@ const DICT = {
granted: "ACCESS GRANTED",
awaiting: "AWAITING CREDENTIALS",
denied: "ACCESS DENIED",
seedReveal: "SEED GENERATED",
noToken:
"Authentication failed. Provide valid credentials or authenticate via secure Telegram widget.",
toBot: "INITIALIZE SECURE TERMINAL (BOT)",
authPrompt:
"Authorize via Telegram Widget to establish a secure connection.",
seedWarning:
"This seed is the ONLY way to access this anonymous account. It cannot be recovered, reset, or reissued if lost.",
downloadSeed: "Download key (.txt)",
confirmSaved: "I have saved this seed in a secure location",
continueToUplink: "CONTINUE TO UPLINK",
},
ru: {
processing: "ИНИЦИАЛИЗАЦИЯ РУКОПОЖАТИЯ...",
@@ -50,27 +57,51 @@ const DICT = {
granted: "ДОСТУП РАЗРЕШЕН",
awaiting: "ОЖИДАНИЕ УЧЕТНЫХ ДАННЫХ",
denied: "ДОСТУП ЗАПРЕЩЕН",
seedReveal: "SEED СГЕНЕРИРОВАН",
noToken:
"Сбой аутентификации. Предоставьте валидные данные или авторизуйтесь через виджет Telegram.",
toBot: "ЗАПУСТИТЬ ТЕРМИНАЛ (BOT)",
authPrompt:
"Авторизуйтесь через виджет Telegram для установки защищенного соединения.",
seedWarning:
"Этот seed — ЕДИНСТВЕННЫЙ способ попасть в анонимный аккаунт. Его нельзя восстановить или перевыпустить, если он будет потерян.",
downloadSeed: "Скачать ключ (.txt)",
confirmSaved: "Я сохранил(а) seed в надёжном месте",
continueToUplink: "ПРОДОЛЖИТЬ В ТЕРМИНАЛ",
},
};
const getLang = (): "en" | "ru" =>
localStorage.getItem("lang") === "ru" ? "ru" : "en";
function downloadSeedFile(seed: string) {
const contents = `Netrunner — Ghost Protocol Seed\n\n${seed}\n\nЭто единственный способ войти в этот анонимный аккаунт.\nSeed невозможно восстановить, если он будет утерян — храните его в надёжном месте.\n`;
const blob = new Blob([contents], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "netrunner-ghost-seed.txt";
link.click();
URL.revokeObjectURL(url);
}
export default function AuthClient() {
const navigate = useNavigate();
const [lang] = useState<"en" | "ru">(getLang);
const t = DICT[lang];
const [status, setStatus] = useState<
"processing" | "granted" | "denied" | "awaiting" | "ghost_login"
| "processing"
| "granted"
| "denied"
| "awaiting"
| "ghost_login"
| "seed_reveal"
>("processing");
const [logs, setLogs] = useState<string[]>([]);
const [seedInput, setSeedInput] = useState("");
const [revealedSeed, setRevealedSeed] = useState("");
const [seedSavedConfirmed, setSeedSavedConfirmed] = useState(false);
const tgContainerRef = useRef<HTMLDivElement>(null);
const initialized = useRef(false);
const widgetInjected = useRef(false);
@@ -83,16 +114,10 @@ export default function AuthClient() {
initialized.current = true;
const authenticate = async () => {
setLogs((prev) => [...prev, `> ${t.processing}`]);
// TODO: искусственная задержка чисто для вида терминала — задерживает реальный
// логин на 800мс без необходимости. Роадмап проекта (things.txt) просит убрать
// все "вайбкод"-паузы в этом файле (см. так же 6000мс ниже в handleGhostGenerate).
await new Promise((resolve) => setTimeout(resolve, 800));
setLogs((prev) => [...prev, `> ${t.verifying}`]);
setLogs((prev) => [...prev, `> ${t.processing}`, `> ${t.verifying}`]);
try {
const res = await fetch(`${API_BASE}/users/me`);
const res = await apiFetch("/users/me");
if (res.ok) {
setLogs((prev) => [...prev, "> [OK] SIGNATURE VALID"]);
@@ -100,7 +125,6 @@ export default function AuthClient() {
setLogs((prev) => [...prev, `> ${t.redirecting}`]);
setTimeout(() => navigate("/profile"), 1500);
} else {
removeTokenCookie();
setLogs((prev) => [
...prev,
"> [WAITING] INITIALIZE SECURE LOGIN...",
@@ -114,7 +138,7 @@ export default function AuthClient() {
};
authenticate();
}, [t, navigate, API_BASE]);
}, [t, navigate]);
useEffect(() => {
window.onTelegramAuth = async (user: TelegramAuthUser) => {
@@ -128,6 +152,7 @@ export default function AuthClient() {
try {
const res = await fetch(`${API_BASE}/auth/telegram`, {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(user),
});
@@ -179,21 +204,18 @@ export default function AuthClient() {
try {
const res = await fetch(`${API_BASE}/auth/seed/generate`, {
method: "POST",
credentials: "same-origin",
});
const data = await res.json();
if (res.ok) {
// TODO: Seed показывается только как текст в логе терминала — нет кнопки
// "Скачать ключ (.txt)" и явного предупреждения-подтверждения перед уходом
// с экрана. Seed — единственный способ входа в Ghost-аккаунт: если юзер не
// успеет скопировать его за 6 секунд до авто-редиректа, доступ теряется навсегда.
setLogs((prev) => [
...prev,
`> [CRITICAL] YOUR SEED: ${data.seed}`,
"> SAVE THIS SEED. IT CANNOT BE RECOVERED.",
"> ROUTING TO UPLINK...",
"> [OK] SEED GENERATED",
"> SESSION ESTABLISHED",
]);
setStatus("granted");
setTimeout(() => navigate("/profile"), 6000);
setRevealedSeed(data.seed);
setSeedSavedConfirmed(false);
setStatus("seed_reveal");
} else throw new Error();
} catch {
setLogs((prev) => [...prev, "> [FAIL] GENERATION FAILED"]);
@@ -208,6 +230,7 @@ export default function AuthClient() {
try {
const res = await fetch(`${API_BASE}/auth/seed/login`, {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ seed: seedInput }),
});
@@ -248,7 +271,9 @@ export default function AuthClient() {
{status === "denied" && (
<ShieldAlert className="w-20 h-20 text-red-500" />
)}
{(status === "awaiting" || status === "ghost_login") && (
{(status === "awaiting" ||
status === "ghost_login" ||
status === "seed_reveal") && (
<Terminal className="w-20 h-20 text-secondary" />
)}
</div>
@@ -259,40 +284,42 @@ export default function AuthClient() {
? t.granted
: status === "denied"
? t.denied
: t.awaiting}
: status === "seed_reveal"
? t.seedReveal
: t.awaiting}
</h1>
</div>
<div className="bg-black/60 border border-white/10 rounded-2xl p-6 font-mono text-sm text-muted-foreground/80 min-h-[140px] flex flex-col justify-end space-y-3 mb-8 shadow-inner overflow-y-auto">
<AnimatePresence>
{logs.map((log, i) => (
<motion.div
key={i}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
className={
log.includes("[OK]")
? "text-secondary font-bold"
: log.includes("[FAIL]")
? "text-red-500 font-bold"
: log.includes("[CRITICAL]")
? "text-primary font-bold animate-pulse"
{status !== "seed_reveal" && (
<div className="bg-black/60 border border-white/10 rounded-2xl p-6 font-mono text-sm text-muted-foreground/80 min-h-[140px] flex flex-col justify-end space-y-3 mb-8 shadow-inner overflow-y-auto">
<AnimatePresence>
{logs.map((log, i) => (
<motion.div
key={i}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
className={
log.includes("[OK]")
? "text-secondary font-bold"
: log.includes("[FAIL]")
? "text-red-500 font-bold"
: ""
}
>
{log}
</motion.div>
))}
</AnimatePresence>
{status === "processing" && (
<div className="flex items-center gap-2 text-primary mt-2">
<Loader2 className="w-4 h-4 animate-spin" />
<span className="animate-pulse font-bold text-lg leading-none">
_
</span>
</div>
)}
</div>
}
>
{log}
</motion.div>
))}
</AnimatePresence>
{status === "processing" && (
<div className="flex items-center gap-2 text-primary mt-2">
<Loader2 className="w-4 h-4 animate-spin" />
<span className="animate-pulse font-bold text-lg leading-none">
_
</span>
</div>
)}
</div>
)}
{status === "awaiting" && (
<motion.div
@@ -324,6 +351,52 @@ export default function AuthClient() {
</motion.div>
)}
{status === "seed_reveal" && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col items-center text-center gap-6"
>
<div className="w-full bg-primary/10 border border-primary/30 rounded-xl p-6">
<p className="text-[10px] uppercase tracking-widest text-primary/80 mb-2">
Ghost Seed
</p>
<p className="font-mono text-xl md:text-2xl text-primary font-black tracking-[0.15em] break-all">
{revealedSeed}
</p>
</div>
<p className="text-xs text-red-400 leading-relaxed max-w-md">
{t.seedWarning}
</p>
<button
onClick={() => downloadSeedFile(revealedSeed)}
className="w-full flex items-center justify-center gap-2 bg-white/5 hover:bg-white/10 border border-white/10 p-3 rounded-xl text-xs font-bold uppercase tracking-widest transition-colors"
>
<Download className="w-4 h-4" /> {t.downloadSeed}
</button>
<label className="flex items-center gap-3 text-xs text-muted-foreground w-full text-left cursor-pointer select-none">
<input
type="checkbox"
checked={seedSavedConfirmed}
onChange={(e) => setSeedSavedConfirmed(e.target.checked)}
className="w-4 h-4 accent-primary"
/>
{t.confirmSaved}
</label>
<button
disabled={!seedSavedConfirmed}
onClick={() => navigate("/profile")}
className="w-full bg-secondary/10 disabled:opacity-30 disabled:cursor-not-allowed text-secondary font-bold p-4 rounded-xl text-xs uppercase tracking-widest transition-opacity"
>
{t.continueToUplink}
</button>
</motion.div>
)}
{status === "ghost_login" && (
<motion.form
onSubmit={handleGhostLogin}
+37 -16
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { submitHackResult } from "./api";
import { startHackSession, submitHackResult, type HackSessionStart } from "./api";
import { Cyberhack, type CyberhackResult } from "cyberhack";
export default function HackGame() {
@@ -8,7 +8,10 @@ export default function HackGame() {
// 🔥 ИСПРАВЛЕНО: Инициализируем стейт пустым/дефолтным,
// а в useEffect подтягиваем актуальный язык из Profile.
const [lang, setLang] = useState<"en" | "ru">("en");
const [session, setSession] = useState<HackSessionStart | null>(null);
const [error, setError] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const started = useRef(false);
useEffect(() => {
// 1. Динамическая подгрузка языка
@@ -25,13 +28,21 @@ export default function HackGame() {
tg.disableVerticalSwipes();
}
}
// 3. Списываем билет и получаем от сервера доску — играем строго на ней
// (server-authoritative: сервер сам пересчитает награду из присланного
// пути, число очков от клиента нигде не используется).
if (started.current) return;
started.current = true;
startHackSession()
.then(setSession)
.catch((e) => setError(e.message || "Не удалось начать взлом"));
}, []);
const handleComplete = async (result: CyberhackResult) => {
try {
const earnedPoints = result.total_coins || 0;
if (earnedPoints > 0) {
await submitHackResult(earnedPoints);
if (session) {
await submitHackResult(session.session_id, result.path);
}
} catch (err) {
console.error("[ERROR] Failed to sync data:", err);
@@ -52,18 +63,28 @@ export default function HackGame() {
</button>
<div className="absolute inset-0 w-full h-full bg-[#05050A] overflow-hidden">
<Cyberhack
timeLimit={45}
baseValue={50}
locale={lang}
redirectUrl=""
theme={{
primary: "#8B3DFF",
secondary: "#00E5F2",
background: "#05050A",
}}
onComplete={handleComplete}
/>
{error && (
<div className="absolute inset-0 flex items-center justify-center text-center px-8 text-sm text-red-400 font-mono uppercase tracking-widest">
{error}
</div>
)}
{!error && session && (
<Cyberhack
timeLimit={session.time_limit}
baseValue={session.base_value}
matrix={session.matrix}
targets={session.targets}
sessionId={session.session_id}
locale={lang}
redirectUrl=""
theme={{
primary: "#8B3DFF",
secondary: "#00E5F2",
background: "#05050A",
}}
onComplete={handleComplete}
/>
)}
</div>
</div>
);
+109 -96
View File
@@ -1,73 +1,102 @@
export const API_BASE = import.meta.env.VITE_API_BASE || "/api/v1";
export const TOKEN_KEY = "nrxp_token";
export const removeTokenCookie = () => {
document.cookie = `${TOKEN_KEY}=;expires=Thu, 01 Jan 1970 00:00:00 UTC;path=/;`;
};
// Сессия живёт только в HttpOnly cookie, которую ставит сервер — JS её не видит
// и не должен: раньше здесь читался `document.cookie` в расчёте на токен,
// который сервер ставит как HttpOnly, поэтому чтение всегда возвращало null, а
// все "авторизованные" запросы ниже реально уходили без токена вообще.
export const getTokenCookie = (): string | null => {
const match = document.cookie.match(
new RegExp("(^| )" + TOKEN_KEY + "=([^;]+)"),
);
return match ? match[2] : null;
};
let refreshInFlight: Promise<boolean> | null = null;
/// Один раз пробует тихо обновить сессию через `/auth/refresh` (ротация
/// refresh-cookie); параллельные вызовы схлопываются в один запрос.
function refreshSession(): Promise<boolean> {
if (!refreshInFlight) {
refreshInFlight = fetch(`${API_BASE}/auth/refresh`, {
method: "POST",
credentials: "same-origin",
})
.then((res) => res.ok)
.catch(() => false)
.finally(() => {
refreshInFlight = null;
});
}
return refreshInFlight;
}
function toRequestInit(options: RequestInit): RequestInit {
const headers: Record<string, string> = { ...(options.headers as Record<string, string>) };
if (options.body && !headers["Content-Type"]) {
headers["Content-Type"] = "application/json";
}
return { ...options, credentials: "same-origin", headers };
}
/// Единая обёртка над fetch для авторизованных запросов: всегда с cookie,
/// при первом 401 пробует silent-refresh и повторяет запрос один раз, при
/// провале — чистит сторону клиента и уводит на экран логина.
export async function apiFetch(
path: string,
options: RequestInit = {},
): Promise<Response> {
const init = toRequestInit(options);
const res = await fetch(`${API_BASE}${path}`, init);
if (res.status !== 401) return res;
const refreshed = await refreshSession();
if (!refreshed) {
window.location.href = "/";
return res;
}
return fetch(`${API_BASE}${path}`, init);
}
async function parseOrThrow(res: Response, fallbackMessage: string) {
if (!res.ok) {
const errData = await res.json().catch(() => ({}) as { error?: string });
throw new Error(errData.error || fallbackMessage);
}
return res.json();
}
// --- API Запросы ---
export const fetchProfile = async () => {
const res = await fetch(`${API_BASE}/users/me`);
if (res.status === 401) {
window.location.href = "/"; // Сервер сам "выкинул" куку или она протухла
return;
}
if (!res.ok) throw new Error("Failed to fetch profile");
return res.json();
const res = await apiFetch("/users/me");
return parseOrThrow(res, "Failed to fetch profile");
};
export const submitHackResult = async (score: number) => {
const token = getTokenCookie();
if (!token) throw new Error("No token found");
export interface HackSessionStart {
session_id: string;
matrix: string[][];
targets: string[][];
base_value: number;
time_limit: number;
}
const res = await fetch(`${API_BASE}/users/hack/result`, {
/// Списывает билет и получает от сервера сгенерированную им доску — сервер
/// хранит эту же доску и позже сверит присланный путь, не доверяя очкам,
/// которые мог бы подсчитать сам клиент.
export const startHackSession = async (): Promise<HackSessionStart> => {
const res = await apiFetch("/users/hack/start", { method: "POST" });
return parseOrThrow(res, "Failed to start breach sequence");
};
export const submitHackResult = async (
sessionId: string,
path: [number, number][],
) => {
const res = await apiFetch("/users/hack/result", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ score }),
body: JSON.stringify({ session_id: sessionId, path }),
});
if (res.status === 401) {
removeTokenCookie();
window.location.href = "/";
return;
}
if (!res.ok) throw new Error("Failed to sync breach data");
return res.json();
return parseOrThrow(res, "Failed to sync breach data");
};
export const activateTrial = async () => {
const token = getTokenCookie();
if (!token) throw new Error("No token found");
const res = await fetch(`${API_BASE}/billing/trial`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
if (res.status === 401) {
removeTokenCookie();
window.location.href = "/";
return;
}
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.error || res.statusText || "Activation failed");
}
return res.json();
const res = await apiFetch("/billing/trial", { method: "POST" });
return parseOrThrow(res, "Activation failed");
};
export const initiatePayment = async (
@@ -75,56 +104,40 @@ export const initiatePayment = async (
provider: string,
currency: string,
) => {
const token = getTokenCookie();
if (!token) throw new Error("No token found");
const res = await fetch(`${API_BASE}/billing/pay/initiate`, {
const res = await apiFetch("/billing/pay/initiate", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ plan, provider, currency }),
});
if (res.status === 401) {
removeTokenCookie();
window.location.href = "/";
return;
}
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.error || "Initiate payment failed");
}
return res.json();
return parseOrThrow(res, "Initiate payment failed");
};
export const confirmPayment = async (
invoice_id: string,
external_tx_id: string,
) => {
const token = getTokenCookie();
if (!token) throw new Error("No token found");
const res = await fetch(`${API_BASE}/billing/pay/confirm`, {
const res = await apiFetch("/billing/pay/confirm", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ invoice_id, external_tx_id }),
});
if (res.status === 401) {
removeTokenCookie();
window.location.href = "/";
return;
}
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.error || "Payment verification failed");
}
return res.json();
return parseOrThrow(res, "Payment verification failed");
};
/// Обменивает короткоживущий bootstrap-токен из URL (мост из бота, см.
/// `App.tsx`) на полноценную cookie-сессию.
export const exchangeBootstrapToken = async (token: string): Promise<boolean> => {
const res = await fetch(`${API_BASE}/auth/exchange`, {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
});
return res.ok;
};
export const logout = async () => {
await fetch(`${API_BASE}/auth/logout`, {
method: "POST",
credentials: "same-origin",
}).catch(() => {});
window.location.href = "/";
};