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:
+110
-17
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 = "/";
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user