This commit is contained in:
Lev 2026-04-19 17:46:56 +03:00
parent 11a84f4926
commit 4c4d325245
19 changed files with 226 additions and 279 deletions

View file

@ -1,6 +1,7 @@
from datetime import datetime from datetime import datetime
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from utils.database import engine from utils.database import engine
import models import models
from routers import server, user, config from routers import server, user, config

View file

@ -96,8 +96,7 @@ class XUIClient:
@classmethod @classmethod
async def from_server(cls, server): async def from_server(cls, server):
#TODO заменить на https base_url = f"https://{server.host}:{server.port}"
base_url = f"http://{server.host}:{server.port}"
return cls(server.host, base_url, server.user, server.password, server.inbound_id, server.version) return cls(server.host, base_url, server.user, server.password, server.inbound_id, server.version)
async def _login(self): async def _login(self):

7
Frontend/Dockerfile Normal file
View file

@ -0,0 +1,7 @@
FROM nginx:stable-alpine
COPY Web/ /usr/share/nginx/html/
COPY Nginx/spectralvpn.ru.nginx /etc/nginx/conf.d/spectralvpn.ru.conf
COPY Nginx/spectralvpn.ru_http.nginx /etc/nginx/conf.d/spectralvpn.ru_http.conf
COPY Nginx/spectralvpn_api.nginx /etc/nginx/conf.d/spectralvpn_api.conf
EXPOSE 80 443
CMD ["nginx", "-g", "daemon off;"]

View file

@ -1,225 +0,0 @@
const API_BASE = "https://spectralvpn.ru:8500";
const getCookie = (name) => {
const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
return match ? decodeURIComponent(match[2]) : null;
};
const deleteCookie = (name) => {
document.cookie = `${name}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; Secure; SameSite=Strict`;
};
const sha256 = async (str) => {
const buf = new TextEncoder().encode(str);
const hash = await crypto.subtle.digest("SHA-256", buf);
return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, "0")).join("");
};
const showNotification = (text, color = "cyan") => {
const notification = document.createElement("div");
notification.textContent = text;
notification.style.cssText = `
position: fixed; top: 20px; right: 20px; padding: 15px 25px; border-radius: 12px;
z-index: 9999; font-weight: 600; background: ${color === "cyan" ? "#00ffff22" : "#ff444422"};
color: ${color}; border: 1px solid ${color === "cyan" ? "cyan" : "#f66"};
`;
document.body.appendChild(notification);
setTimeout(() => notification.remove(), 3000);
};
let currentEmail = null;
let currentHash = null;
const modal = document.getElementById("loginModal");
const showModal = () => modal.classList.add("active");
const hideModal = () => modal.classList.remove("active");
const showLoginError = (msg) => {
document.getElementById("loginError").textContent = msg;
};
const renderUrls = (urls) => {
const container = document.getElementById("urlsList");
container.innerHTML = "";
if (urls.length === 0) {
container.innerHTML = "<p style='text-align:center; color:#666;'>У вас пока нет конфигураций</p>";
return;
}
urls.forEach(name => {
const card = document.createElement("div");
card.className = "url-card";
card.innerHTML = `
<div>
<div class="url-name">${name}</div>
</div>
<div class="url-actions">
<button class="btn-copy" data-name="${name}">Скопировать</button>
<button class="btn-delete" data-name="${name}">Удалить</button>
</div>
`;
card.querySelector(".btn-copy").onclick = async () => {
try {
const url = `${API_BASE}/get_url?email=${encodeURIComponent(currentEmail)}&password=${encodeURIComponent(currentHash)}&urls_name=${encodeURIComponent(name)}`;
const res = await fetch(url);
const data = await res.json();
if (res.ok && data.url) {
await navigator.clipboard.writeText(data.url);
showNotification(`Конфиг "${name}" скопирован!`);
} else {
showNotification("Ошибка получения конфига", "red");
}
} catch (e) {
showNotification("Нет связи с сервером", "red");
}
};
card.querySelector(".btn-delete").onclick = async () => {
if (!confirm(`Удалить конфиг "${name}"?`)) return;
try {
const url = `${API_BASE}/del_url?email=${encodeURIComponent(currentEmail)}&password=${encodeURIComponent(currentHash)}&urls_name=${encodeURIComponent(name)}`;
const res = await fetch(url, { method: "DELETE" });
if (res.ok) {
card.remove();
showNotification(`Конфиг "${name}" удалён`, "red");
} else {
showNotification("Не удалось удалить", "red");
}
} catch (e) {
showNotification("Ошибка сети", "red");
}
};
container.appendChild(card);
});
};
const tryAutoLogin = async () => {
currentEmail = getCookie("user_email");
currentHash = getCookie("user_hash");
if (!currentEmail || !currentHash) {
showModal();
return;
}
try {
const res = await fetch(`${API_BASE}/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: currentEmail, password: currentHash })
});
if (res.ok) {
const data = await res.json();
document.getElementById("userEmail").textContent = currentEmail;
document.getElementById("mainContent").classList.remove("hidden");
renderUrls(data.urls || []);
} else {
deleteCookie("user_email");
deleteCookie("user_hash");
showModal();
}
} catch (e) {
showModal();
}
};
document.getElementById("loginSubmit").onclick = async () => {
const email = document.getElementById("loginEmail").value.trim();
const pass = document.getElementById("loginPassword").value;
if (!email || !pass) {
showLoginError("Заполните все поля");
return;
}
const hash = await sha256(pass);
try {
const res = await fetch(`${API_BASE}/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password: hash })
});
const data = await res.json();
if (res.ok) {
currentEmail = email;
currentHash = hash;
document.cookie = `user_email=${encodeURIComponent(email)}; path=/; max-age=2592000; Secure; SameSite=Strict`;
document.cookie = `user_hash=${hash}; path=/; max-age=2592000; Secure; SameSite=Strict`;
document.getElementById("userEmail").textContent = email;
hideModal();
document.getElementById("mainContent").classList.remove("hidden");
renderUrls(data.urls || []);
} else {
showLoginError("Неверный email или пароль");
}
} catch (e) {
showLoginError("Ошибка подключения");
}
};
document.getElementById("addUrlBtn").onclick = async () => {
const name = prompt("Введите название конфигурации:");
if (!name?.trim()) return;
try {
const res = await fetch(`${API_BASE}/add_url`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: currentEmail,
password: currentHash,
urls_name: name.trim()
})
});
const data = await res.json();
if (res.ok) {
renderUrls(data.urls);
showNotification(`Конфиг "${name}" создан! Скопируйте через кнопку.`);
} else {
showNotification("Ошибка: " + (data.detail || "неизвестно"), "red");
}
} catch (e) {
showNotification("Нет связи с сервером", "red");
}
};
document.getElementById("logoutBtn").onclick = () => {
deleteCookie("user_email");
deleteCookie("user_hash");
location.reload();
};
document.getElementById("closeModal").onclick = hideModal;
tryAutoLogin();
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.querySelectorAll('.instructions').forEach(el => el.classList.add('hidden'));
const platform = btn.dataset.platform;
const target = document.getElementById(`${platform}-instructions`);
if (target) {
target.classList.remove('hidden');
}
});
});
document.addEventListener('DOMContentLoaded', () => {});

View file

@ -10,7 +10,7 @@ server {
gzip_types text/css text/javascript text/plain application/javascript application/json; gzip_types text/css text/javascript text/plain application/javascript application/json;
gzip_min_length 1000; gzip_min_length 1000;
root /var/www/html/spectralvpn.ru; root /usr/share/nginx/html/;
index index.html; index index.html;
location / { location / {

View file

@ -3,4 +3,4 @@ server {
listen [::]:80; listen [::]:80;
server_name spectralvpn.ru; server_name spectralvpn.ru;
return 301 https://$host$request_uri; return 301 https://$host$request_uri;
} }

View file

@ -1,6 +1,6 @@
server { server {
listen 8500 ssl; listen 8000 ssl;
listen [::]:8500 ssl; listen [::]:8000 ssl;
server_name spectralvpn.ru; server_name spectralvpn.ru;
ssl_certificate /etc/letsencrypt/live/spectralvpn.ru/fullchain.pem; ssl_certificate /etc/letsencrypt/live/spectralvpn.ru/fullchain.pem;
@ -15,7 +15,7 @@ server {
return 204; return 204;
} }
proxy_pass http://127.0.0.1:8000; proxy_pass http://api:8000;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View file

@ -0,0 +1,174 @@
const API_BASE = "https://spectralvpn.ru:8000";
const getToken = () => localStorage.getItem("access_token");
const showNotification = (message, type = "success") => {
const color = type === "success" ? "#00ffff" : "#ff5555";
const notification = document.createElement("div");
notification.style.cssText = `
position: fixed; top: 20px; right: 20px; padding: 15px 25px; border-radius: 12px;
background: rgba(0,0,0,0.95); border: 1px solid ${color}; color: ${color};
z-index: 10000; font-weight: 500; box-shadow: 0 4px 15px rgba(0,0,0,0.5);
`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => notification.remove(), 4000);
};
async function loadPanel() {
const token = getToken();
if (!token) {
window.location.href = "register.html";
return;
}
try {
const res = await fetch(`${API_BASE}/config/get_info`, {
method: "GET",
headers: { "X-API-KEY": token }
});
if (res.status === 401) {
localStorage.removeItem("access_token");
window.location.href = "register.html";
return;
}
if (!res.ok) throw new Error("Failed to load configs");
const data = await res.json();
document.getElementById("userEmail").textContent = "Аккаунт активен";
renderConfigs(data.configs || []);
} catch (err) {
console.error(err);
showNotification("Ошибка загрузки данных", "error");
}
}
function renderConfigs(configs) {
const container = document.getElementById("urlsList");
container.innerHTML = "";
if (configs.length === 0) {
container.innerHTML = `<p style="text-align:center; color:#666; padding:40px 20px;">
У вас пока нет конфигураций.<br>Нажмите кнопку ниже, чтобы создать первую.
</p>`;
return;
}
configs.forEach(cfg => {
const trafficGB = (cfg.bytes_used / (1024 ** 3)).toFixed(2);
const card = document.createElement("div");
card.className = "url-card";
card.innerHTML = `
<div>
<div class="url-name">${cfg.name}</div>
<div style="font-size:13px; color:#888; margin-top:6px;">
Использовано: ${trafficGB} ГБ
</div>
</div>
<div class="url-actions">
<button class="btn-copy" data-config="${cfg.config}">Скопировать ссылку</button>
<button class="btn-delete" data-name="${cfg.name}">Удалить</button>
</div>
`;
card.querySelector(".btn-copy").addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(cfg.config);
showNotification(`Конфиг "${cfg.name}" скопирован в буфер`);
} catch (e) {
showNotification("Не удалось скопировать", "error");
}
});
card.querySelector(".btn-delete").addEventListener("click", async () => {
if (!confirm(`Удалить конфигурацию "${cfg.name}"?`)) return;
const token = getToken();
try {
const res = await fetch(`${API_BASE}/config/delete`, {
method: "DELETE",
headers: {
"X-API-KEY": token,
"Content-Type": "application/json"
},
body: JSON.stringify({ name: cfg.name })
});
if (res.ok) {
card.remove();
showNotification(`Конфиг "${cfg.name}" удалён`, "error");
} else {
showNotification("Не удалось удалить конфиг", "error");
}
} catch (e) {
showNotification("Ошибка сети", "error");
}
});
container.appendChild(card);
});
}
document.getElementById("addConfigBtn").addEventListener("click", async () => {
const name = prompt("Введите название конфигурации (например: Телефон, Ноутбук, Рабочий):");
if (!name || !name.trim()) return;
const token = getToken();
try {
const res = await fetch(`${API_BASE}/config/add`, {
method: "POST",
headers: {
"X-API-KEY": token,
"Content-Type": "application/json"
},
body: JSON.stringify({ name: name.trim() })
});
const data = await res.json();
if (res.ok) {
showNotification(`Конфиг "${name}" успешно создан!`);
loadPanel();
} else {
showNotification(data.detail || "Ошибка при создании конфига", "error");
}
} catch (e) {
showNotification("Нет связи с сервером", "error");
}
});
document.getElementById("deleteAccountBtn").addEventListener("click", async () => {
if (!confirm("Вы уверены, что хотите удалить аккаунт? Это действие необратимо!")) return;
const token = getToken();
try {
const res = await fetch(`${API_BASE}/user/delete`, {
method: "DELETE",
headers: { "X-API-KEY": token }
});
if (res.ok) {
localStorage.removeItem("access_token");
alert("Аккаунт успешно удалён.");
window.location.href = "index.html";
} else {
showNotification("Не удалось удалить аккаунт", "error");
}
} catch (e) {
showNotification("Ошибка сети", "error");
}
});
document.getElementById("logoutBtn").addEventListener("click", () => {
if (confirm("Выйти из аккаунта?")) {
localStorage.removeItem("access_token");
window.location.href = "index.html";
}
});
document.addEventListener("DOMContentLoaded", loadPanel);

View file

@ -4,26 +4,20 @@ document.addEventListener("DOMContentLoaded", () => {
const setError = (fieldId, message) => { const setError = (fieldId, message) => {
const errorEl = document.getElementById(fieldId); const errorEl = document.getElementById(fieldId);
const inputEl = errorEl.previousElementSibling; if (errorEl) {
errorEl.textContent = message; errorEl.textContent = message;
errorEl.classList.add("active"); errorEl.classList.add("active");
if (inputEl) inputEl.classList.add("invalid"); }
const input = document.getElementById(fieldId.replace("Error", ""));
if (input) input.classList.add("invalid");
}; };
const clearErrors = () => { const clearErrors = () => {
form.querySelectorAll(".error").forEach(el => { document.querySelectorAll(".error").forEach(el => {
el.textContent = ""; el.textContent = "";
el.classList.remove("active"); el.classList.remove("active");
}); });
form.querySelectorAll(".invalid").forEach(el => el.classList.remove("invalid")); document.querySelectorAll(".input").forEach(el => el.classList.remove("invalid"));
document.querySelector(".checkbox-container")?.classList.remove("invalid");
};
const sha256 = async (message) => {
const msgBuffer = new TextEncoder().encode(message);
const hashBuffer = await crypto.subtle.digest("SHA-256", msgBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
}; };
form.addEventListener("submit", async (e) => { form.addEventListener("submit", async (e) => {
@ -33,59 +27,49 @@ document.addEventListener("DOMContentLoaded", () => {
const email = document.getElementById("email").value.trim(); const email = document.getElementById("email").value.trim();
const password = document.getElementById("password").value; const password = document.getElementById("password").value;
const passwordReply = document.getElementById("password_reply").value; const passwordReply = document.getElementById("password_reply").value;
const terms = document.getElementById("terms").checked; const promo_code = document.getElementById("promo_code") ? document.getElementById("promo_code").value.trim() : "";
let hasError = false;
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
setError("emailError", "Введите корректный email"); setError("emailError", "Введите корректный email");
hasError = true; return;
} }
if (!password || password.length < 6 || !/(?=.*[A-Za-z])(?=.*\d)/.test(password)) { if (!password || password.length < 6) {
setError("passwordError", "Минимум 6 символов, буквы + цифры"); setError("passwordError", "Пароль минимум 6 символов");
hasError = true; return;
} }
if (password !== passwordReply) { if (password !== passwordReply) {
setError("passwordReplyError", "Пароли не совпадают"); setError("passwordReplyError", "Пароли не совпадают");
hasError = true; return;
} }
if (!terms) {
setError("termsError", "Принять условия обязательно");
document.querySelector(".checkbox-container").classList.add("invalid");
hasError = true;
}
if (hasError) return;
submitButton.disabled = true; submitButton.disabled = true;
submitButton.textContent = "Создаём аккаунт..."; submitButton.textContent = "Создаём аккаунт...";
try { try {
const passwordHash = await sha256(password); const response = await fetch("https://spectralvpn.ru:8000/user/signup", {
const response = await fetch("https://spectralvpn.ru:8500/registration", {
method: "POST", method: "POST",
headers: { headers: { "Content-Type": "application/json" },
"Content-Type": "application/json",
},
body: JSON.stringify({ body: JSON.stringify({
email: email, email: email,
password: passwordHash password: password,
promo_code: promo_code
}) })
}); });
const result = await response.json(); const result = await response.json();
if (response.ok) { if (response.ok) {
Cookies.set("user_email", email, { expires: 30, sameSite: "strict" }); // Сохраняем токен
Cookies.set("user_hash", passwordHash, { expires: 30, sameSite: "strict" }); localStorage.setItem("access_token", result.access_token);
window.location.href = "control-panel.html"; window.location.href = "control-panel.html";
} else { } else {
if (result.detail === "Email is busy.") { if (result.detail?.includes("Promo-code")) {
setError("emailError", "Этот email уже зарегистрирован"); setError("promoError", "Неверный промокод");
} else if (result.detail?.includes("Email") || result.detail?.includes("busy")) {
setError("emailError", "Этот email уже используется");
} else { } else {
setError("emailError", result.detail || "Ошибка сервера"); setError("emailError", result.detail || "Ошибка регистрации");
} }
} }
} catch (err) { } catch (err) {

View file

@ -21,6 +21,9 @@
<label for="password_reply">Повторите пароль</label> <label for="password_reply">Повторите пароль</label>
<input type="password" id="password_reply" class="input" placeholder="Повторите пароль"> <input type="password" id="password_reply" class="input" placeholder="Повторите пароль">
<div class="error" id="passwordReplyError"></div> <div class="error" id="passwordReplyError"></div>
<label for="promo_code">Промокод</label>
<input type="text" id="promo_code" class="input" placeholder="Введите прокод">
<div class="error" id="promoError"></div>
<div class="checkbox-container"> <div class="checkbox-container">
<input type="checkbox" id="terms" class="checkbox" required> <input type="checkbox" id="terms" class="checkbox" required>
<label for="terms">Я прочитал и согласен с <a href="offer.html" target="_blank">пользовательским соглашением</a> и <a href="privacy.html" target="_blank">политикой конфиденциальности</a>.</label> <label for="terms">Я прочитал и согласен с <a href="offer.html" target="_blank">пользовательским соглашением</a> и <a href="privacy.html" target="_blank">политикой конфиденциальности</a>.</label>

View file

@ -1,8 +1,6 @@
services: services:
api: api:
build: build: ./API
context: ./API
dockerfile: Dockerfile
container_name: spectralvpn-api container_name: spectralvpn-api
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
@ -16,9 +14,15 @@ services:
- host.docker.internal:172.17.0.1 - host.docker.internal:172.17.0.1
ports: ports:
- 8000:8000 - 8000:8000
web:
#web: build: ./Frontend
# Todo container_name: spectralvpn-web
restart: unless-stopped
depends_on:
- api
ports:
- 80:80
- 443:443
db: db:
image: postgres:latest image: postgres:latest
container_name: spectralvpn-db container_name: spectralvpn-db