diff --git a/aggregator.py b/aggregator.py index 3472e97..2888553 100644 --- a/aggregator.py +++ b/aggregator.py @@ -19,10 +19,11 @@ from datetime import datetime, timedelta from qrcode.image.styledpil import StyledPilImage from qrcode.image.styles.moduledrawers import SquareModuleDrawer from typing import List, Dict, Optional, Tuple -from contextlib import contextmanager +from contextlib import contextmanager, asynccontextmanager from fastapi import FastAPI, Response, HTTPException, Query, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse, FileResponse, JSONResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles import uvicorn import httpx import re @@ -39,6 +40,7 @@ SETTINGS_CONF = os.path.join(BASE_DIR, "settings.conf") USERS_DB = os.path.join(BASE_DIR, "users.db") LOGO_PATH = os.path.join(BASE_DIR, "logo.png") MOTD_PATH = os.path.join(BASE_DIR, "motd.txt") +WEB_DIR = os.path.join(BASE_DIR, "web") servers = [] settings = {} @@ -79,9 +81,6 @@ def clear_cache(sub_id: str = None): _links_cache.clear() _traffic_cache.clear() -# Uptime tracker: first_seen per server -_first_seen: Dict[str, float] = {} - def load_json(path: str, default: dict) -> dict: if os.path.exists(path): try: @@ -133,13 +132,26 @@ def load_configs(): "free": {"name": "Free", "servers": [], "traffic_limit_gb": 0}, "paid": {"name": "Premium", "servers": [], "traffic_limit_gb": 0} }, - "payments": {"donationalerts": {"enabled": False, "api_token": "", "webhook_secret": "", "check_interval_minutes": 5}} + "payments": {"donationalerts": {"enabled": False, "api_token": "", "webhook_secret": "", "check_interval_minutes": 5}}, + "auto_propagate": {"enabled": False, "interval_minutes": 60} }) logger.info(f"Loaded {len(servers)} servers") init_db() load_configs() +def render_template(name: str, **kwargs) -> str: + path = os.path.join(WEB_DIR, name) + try: + with open(path, "r", encoding="utf-8") as f: + html = f.read() + except FileNotFoundError: + logger.error(f"Template not found: {path}") + return "Template error" + for k, v in kwargs.items(): + html = html.replace(f"{{%{k}%}}", str(v)) + return html + def get_flag_emoji(country_code: str) -> str: if not country_code or len(country_code) < 2: return "" @@ -187,6 +199,61 @@ def format_bytes(bytes_val: int) -> str: i += 1 return f"{val:.1f} {sizes[i]}" if val < 100 else f"{int(val)} {sizes[i]}" +def _parse_throughput(s: str) -> float: + if not s: + return 0.0 + total_bytes = 0.0 + for m in re.finditer(r'([\d.]+)\s*([KMGT])?B/s', s): + val = float(m.group(1)) + unit = m.group(2) + if unit == 'K': val *= 1024 + elif unit == 'M': val *= 1024**2 + elif unit == 'G': val *= 1024**3 + elif unit == 'T': val *= 1024**4 + total_bytes += val + return total_bytes / 1024 + +def calc_health_score(checks: dict) -> tuple: + cpu = checks.get("CPU", {}).get("value") + ram = checks.get("RAM", {}).get("value") + disk = checks.get("Disk /", {}).get("value") + net_raw = checks.get("Net ↓↑", {}).get("value", "") + io_raw = checks.get("Disk I/O", {}).get("value", "") + + cpu_s = cpu if cpu is not None else 50 + ram_s = ram if ram is not None else 50 + disk_s = disk if disk is not None else 50 + + net_kbs = _parse_throughput(net_raw) + io_kbs = _parse_throughput(io_raw) + + if net_kbs < 500: + net_s = 0 + elif net_kbs < 10000: + net_s = (net_kbs - 500) / 9500 * 50 + elif net_kbs < 100000: + net_s = 50 + (net_kbs - 10000) / 90000 * 30 + else: + net_s = min(100, 80 + (net_kbs - 100000) / 100000 * 20) + + if io_kbs < 100: + io_s = 0 + elif io_kbs < 10000: + io_s = (io_kbs - 100) / 9900 * 50 + elif io_kbs < 100000: + io_s = 50 + (io_kbs - 10000) / 90000 * 30 + else: + io_s = min(100, 80 + (io_kbs - 100000) / 100000 * 20) + + score = cpu_s * 0.30 + ram_s * 0.25 + disk_s * 0.20 + net_s * 0.15 + io_s * 0.10 + + if score < 40: + return (round(score), "#10b981", "Отлично") + elif score < 70: + return (round(score), "#f59e0b", "Нагружен") + else: + return (round(score), "#f43f5e", "Критично") + def get_traffic_stats(sub_id: str) -> dict: total_up = 0 total_down = 0 @@ -340,8 +407,33 @@ def deduplicate_inbounds(servers_list: List[dict], tier: str) -> List[Tuple[dict result.append((srv, inbound)) return result -app = FastAPI(title="ZernProxy Manager", docs_url=None, redoc_url=None) +@asynccontextmanager +async def lifespan(app): + ap_cfg = settings.get("auto_propagate", {}) + if ap_cfg.get("enabled", False): + interval = max(10, ap_cfg.get("interval_minutes", 60)) * 60 + logger.info(f"Auto-propagate enabled, interval={interval}s") + loop = asyncio.get_event_loop() + async def _run(): + while True: + for srv in servers: + if not srv.get("is_active", True): + continue + try: + result = await loop.run_in_executor(None, propagate_server_sync, srv["name"]) + if result.get("added", 0) or result.get("failed", 0): + logger.info(f"Auto-propagate {srv['name']}: +{result.get('added',0)} added, {result.get('failed',0)} failed, {result.get('skipped',0)} skipped") + except: + logger.exception(f"Auto-propagate error on {srv.get('name','?')}") + await asyncio.sleep(interval) + task = asyncio.create_task(_run()) + yield + if ap_cfg.get("enabled", False): + task.cancel() + +app = FastAPI(title="ZernProxy Manager", docs_url=None, redoc_url=None, lifespan=lifespan) app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) +app.mount("/web", StaticFiles(directory=WEB_DIR), name="web") ADMIN_USER = settings.get("admin", {}).get("username", "admin") ADMIN_PASS = settings.get("admin", {}).get("password", "") @@ -363,31 +455,18 @@ def check_admin(request: Request) -> bool: pass return False -ADMIN_LOGIN_PAGE = ''' - -Вход -

🔐 Вход

-
-
-
- -
''' +@app.get("/admin/login") +async def admin_login_page(): + return HTMLResponse(content=render_template("admin_login.html", title="Вход", error_msg="")) @app.post("/admin/login") async def admin_login(request: Request): data = await request.form() if data.get("username") == ADMIN_USER and data.get("password") == ADMIN_PASS: - resp = RedirectResponse(url="/admin/users") + resp = RedirectResponse(url="/admin/users", status_code=303) resp.set_cookie(key="admin_token", value=ADMIN_PASS, max_age=86400 * 7, httponly=True) return resp - return HTMLResponse(content=ADMIN_LOGIN_PAGE.replace("Вход", "Вход · неверный пароль"), status_code=401) + return HTMLResponse(content=render_template("admin_login.html", title="Вход", error_msg='
Неверный пароль
'), status_code=401) @app.get("/sub/{subscription_id}") async def get_subscription(request: Request, subscription_id: str, format: str = Query("base64", pattern="^(json|base64|raw)$")): @@ -564,84 +643,23 @@ async def get_web_page(subscription_id: str): info_html = f'
{days_info}{traffic_info}{traffic_details}
' if days_info or traffic_info or traffic_details else '' - servers_html = "".join(f'{get_flag_emoji(srv.get("country", ""))} {srv["name"].upper()}' for srv in servers_for_tier) + servers_html = "".join(f'{get_flag_emoji(srv.get("country", ""))} {srv["name"].upper()}' for srv in servers_for_tier) support_btn = "" if da_config.get("enabled"): da_url = da_config.get("url", "#") support_btn = f''' - favorite Поддержать проект + Поддержать проект ''' - html = f''' - - - - - {title} - - - - - -
-
- {logo_html} -

{title}

- {tier_badge} -
- {announcement_html} -
- {info_html} -
{servers_html}
-
-
-
- QR Code -
{sub_url}
- - download Скачать подписку - - {support_btn} -
-
- -
- -''' - return HTMLResponse(content=html.replace("{sub_url}", sub_url).replace("{host}", host)) + html = render_template("sub.html", + title=title, logo_html=logo_html, tier_badge=tier_badge, + announcement_html=announcement_html, info_html=info_html, + servers_html=servers_html, qr_base64=qr_base64, + sub_url=sub_url, support_btn=support_btn) + return HTMLResponse(content=html) @app.post("/payment/webhook/donationalerts") async def webhook_donationalerts(request: Request): @@ -728,7 +746,7 @@ async def webhook_donationalerts(request: Request): @app.get("/admin/users") async def admin_users(request: Request): if not check_admin(request): - return HTMLResponse(content=ADMIN_LOGIN_PAGE) + return HTMLResponse(content=render_template("admin_login.html", title="Вход", error_msg="")) conn = get_db() try: users = conn.execute("SELECT * FROM users ORDER BY created_at DESC").fetchall() @@ -737,228 +755,39 @@ async def admin_users(request: Request): da_config = settings.get("payments", {}).get("donationalerts", {}) - html = f''' - - - - - Управление - {settings.get("general", {}).get("title", "ZernProxy")} - - - - - -
-
-

👥 Пользователи ({len(users)})

- -
-
- - - - - - {"".join(f''' - - - - - - - - - - - ''' for u in users)} - -
IDUsernameSub IDТарифДнейОплаченоТрафикСтатусСоздан
{u['id']}{u['username']}{u['subscription_id']}{u['tier'].upper()}{u['tariff_days_remaining']}{u['total_paid_rubles']}₽{u['traffic_limit_gb'] if u['traffic_limit_gb'] > 0 else '∞'} GB{"✓" if u['is_active'] else "✗"}{u['created_at'][:10]} - - -
-
-
- - - - -''' + title = settings.get("general", {}).get("title", "ZernProxy") + tier_options = "".join( + f'' + for k, v in settings.get("tiers", {}).items()) + users_rows = "" + for u in users: + traffic = f'{u["traffic_limit_gb"]} GB' if u['traffic_limit_gb'] > 0 else '∞' + active = '✓' if u['is_active'] else '✗' + tier_display = u['tier'].upper() + users_rows += f''' + {u['id']} + {u['username']} + {u['subscription_id']} + {tier_display} + {u['tariff_days_remaining']} + {u['total_paid_rubles']}₽ + {traffic} + {active} + {u['created_at'][:10]} + + + + + ''' + html = render_template("admin_users.html", + title=title, user_count=len(users), users_rows=users_rows, + tier_options=tier_options) return HTMLResponse(content=html) @app.get("/admin/dashboard") async def admin_dashboard(request: Request): if not check_admin(request): - return HTMLResponse(content=ADMIN_LOGIN_PAGE) + return HTMLResponse(content=render_template("admin_login.html", title="Вход", error_msg="")) conn = get_db() try: @@ -982,69 +811,23 @@ async def admin_dashboard(request: Request): except: pass - html = f''' - - - -Дашборд - ZernProxy - - - - -
-
-

📊 Дашборд

- -
-
-

Всего пользователей

{total_users}
-

Free

{free_users}
-

Test

{test_users}
-

Paid

{paid_users}
-

Онлайн

{online_count}
на всех серверах
-

Выручка

{total_revenue}₽
всего оплат
-

Серверов

{len(servers)}
-

Инбаундов

{sum(len(s.get("inbounds",[])) for s in servers)}
-
-
-

Серверы

-{"".join(f'
{get_flag_emoji(s.get("country",""))} {s["name"].upper()}{len(s.get("inbounds",[]))} inbound
' for s in servers)} -
-
- -''' + title = settings.get("general", {}).get("title", "ZernProxy") + servers_rows = "".join( + f'
{get_flag_emoji(s.get("country",""))} {s["name"].upper()}{len(s.get("inbounds",[]))} inbound
' + for s in servers) + html = render_template("admin_dashboard.html", + title=title, total_users=total_users, free_users=free_users, + paid_users=paid_users, test_users=test_users, + online_count=online_count, total_revenue=total_revenue, + server_count=len(servers), + inbound_count=sum(len(s.get("inbounds",[])) for s in servers), + servers_rows=servers_rows) return HTMLResponse(content=html) @app.get("/") async def home_page(): title = settings.get("general", {}).get("title", "ZernProxy") da_url = settings.get("payments", {}).get("donationalerts", {}).get("url", "") - host = settings.get("general", {}).get("host", "zernmc.ru") - - conn = get_db() - try: - total = conn.execute("SELECT COUNT(*) as c FROM users").fetchone()["c"] - finally: - conn.close() statuses = await fetch_servers_status() @@ -1054,15 +837,15 @@ async def home_page(): cpu = chk.get("CPU", {}).get("value") ram = chk.get("RAM", {}).get("value") disk = chk.get("Disk /", {}).get("value") - net_raw = chk.get("Net ↓↑", {}).get("value", "") srv_name = s.get("server_name", s["name"].upper()) delay = 0.45 + i * 0.15 online = s.get("online", False) - inbounds = [ib for ib in servers if ib["name"] == s["name"]] + srv_inbounds = [] for srv_cfg in servers: if srv_cfg["name"] == s["name"]: - all_ib = srv_cfg.get("inbounds", []) + srv_inbounds = srv_cfg.get("inbounds", []) + all_ib = srv_inbounds has_free = any(ib.get("is_free", True) for ib in all_ib) has_paid = any(not ib.get("is_free", True) for ib in all_ib) break @@ -1071,230 +854,50 @@ async def home_page(): has_paid = True if has_free and has_paid: - badge = 'Free + Premium' + badge = 'Free+Premium' elif has_free: badge = 'Free' else: badge = 'Premium' - uptime_str = s.get("uptime", "") - uptime_html = f'⏱ {uptime_str}' if online else '⏱ Offline' + if online: + _, hc, hl = calc_health_score(chk) + s1 = cpu if cpu is not None else 0 + s2 = ram if ram is not None else 0 + s3 = disk if disk is not None else 0 + stats = f'
CPU {s1:.0f}% · RAM {s2:.0f}% · DSK {s3:.0f}%
' + else: + hc = "#475569" + hl = "Offline" + stats = '
Offline
' + + inbound_cards = "" + for ib in srv_inbounds: + ib_name = ib.get("name", "unknown") + ib_type = ib.get("network", ib.get("flow", "")) + inbound_cards += f'
{ib_name}{ib_type}
\n' + + ru_note = "" + if s.get("country", "").lower() == "ru" and online: + ru_note = '
★ Нет рекламы в YouTube · Адаптирован к будущему ограничению 15 ГБ зарубежного трафика через Proxy
' srv_cards += f''' -
-
{get_flag_emoji(s.get("country",""))} {srv_name} {badge} {uptime_html}
-
-
CPU
{f"{cpu:.1f}%" if cpu is not None else "—"}
-
RAM
{f"{ram:.1f}%" if ram is not None else "—"}
-
DSK
{f"{disk:.1f}%" if disk is not None else "—"}
-
NET
{net_raw}
+
+
+ {get_flag_emoji(s.get("country",""))} {srv_name} {badge} + + {hl}
+ {stats} +
{inbound_cards}
+ {ru_note}
''' - return HTMLResponse(content=f''' - - - -{title} - - - - - -
- -
-

⚡ {title}

-

Быстрый VPN с собственными серверами в Европе. Безлимитный трафик, VLESS + XTLS, никаких логов.

-

Подписка на основе подписки · Happ-совместимость · Мгновенная активация

-
- -
-

Почему {title}

-
-
🚀

Скорость

Каналы 1–10 Gbit/s, собственные серверы, без посредников

-
🔒

Приватность

Zero-log политика — трафик не хранится и не анализируется

-
🌍

Покрытие

Серверы в Германии, Швеции и России — выбирай ближайший

-
💎

Тарифы

Бесплатный доступ к free-серверам или Premium от 150 ₽/мес

-
-
- -
-

🖥 Состояние серверов

-
{srv_cards}
-
- -
-

🔧 Технические детали

-
-
VLESS + XTLS Vision / Reality
-
TCP, WebSocket, gRPC
-
DDoS-защита на всех серверах
-
Zero-log политика
-
Поддержка Happ (iOS / Android / Desktop)
-
Автообновление подписки каждые 12ч
-
- -
- - - -
- -''') + return HTMLResponse(content=render_template("home.html", title=title, da_url=da_url, srv_cards=srv_cards)) async def fetch_servers_status() -> list: results = [] - now = time.time() async with httpx.AsyncClient(timeout=8.0, verify=False) as client: for srv in servers: url = srv.get("status_url", "") @@ -1309,19 +912,6 @@ async def fetch_servers_status() -> list: entry["online"] = True entry["server_name"] = data.get("server_name", "") entry["checks"] = data.get("checks", {}) - - name = srv["name"] - if name not in _first_seen: - _first_seen[name] = now - uptime_secs = now - _first_seen[name] - d = int(uptime_secs // 86400) - h = int((uptime_secs % 86400) // 3600) - m = int((uptime_secs % 3600) // 60) - parts = [] - if d > 0: parts.append(f"{d}д") - parts.append(f"{h}ч") - parts.append(f"{m}м") - entry["uptime"] = " ".join(parts) else: entry["checks"]["error"] = {"value": f"HTTP {resp.status_code}"} except Exception as e: @@ -1414,6 +1004,116 @@ def delete_3xui_client(username: str, sub_id: str, inbound: dict) -> dict: except Exception as e: return {"success": False, "error": str(e)[:100]} +def user_exists_on_server(sub_id: str, srv: dict) -> bool: + for inbound in srv.get("inbounds", []): + api_host = inbound.get("api_host") + api_user = inbound.get("api_user") + api_pass = inbound.get("api_pass") + inbound_id = inbound.get("id") + if not all([api_host, api_user, api_pass, inbound_id]): + continue + try: + api = Api(host=api_host, username=api_user, password=api_pass, use_tls_verify=False) + api.login() + for ib in api.inbound.get_list(): + if ib.id == inbound_id and ib.client_stats: + for client in ib.client_stats: + if getattr(client, 'sub_id', '') == sub_id: + return True + except: + pass + return False + +def fetch_server_sub_ids(srv: dict) -> set: + ids = set() + srv_name = srv.get("name", "?") + for inbound in srv.get("inbounds", []): + api_host = inbound.get("api_host") + api_user = inbound.get("api_user") + api_pass = inbound.get("api_pass") + inbound_id = inbound.get("id") + ib_name = inbound.get("name", "?") + if not all([api_host, api_user, api_pass, inbound_id]): + continue + try: + from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeout + def _fetch(): + api = Api(host=api_host, username=api_user, password=api_pass, use_tls_verify=False) + api.login() + result = set() + for ib in api.inbound.get_list(): + if ib.id == inbound_id and ib.client_stats: + for client in ib.client_stats: + sid = getattr(client, 'sub_id', '') or '' + if sid: + result.add(sid) + return result + with ThreadPoolExecutor(max_workers=1) as ex: + future = ex.submit(_fetch) + try: + fetched = future.result(timeout=20) + ids.update(fetched) + logger.info(f"fetch_server_sub_ids {srv_name}/{ib_name}: got {len(fetched)} sub_ids") + except FutureTimeout: + logger.warning(f"Timeout fetching clients from {srv_name}/{ib_name}") + except Exception as e: + logger.warning(f"fetch_server_sub_ids {srv_name}/{ib_name} error: {e}") + logger.info(f"fetch_server_sub_ids {srv_name}: total {len(ids)} sub_ids") + return ids + +def propagate_server_sync(server_name: str) -> dict: + target_srv = next((s for s in servers if s["name"] == server_name), None) + if not target_srv: + return {"error": "Server not found"} + conn = get_db() + try: + users = conn.execute("SELECT username, subscription_id, traffic_limit_gb, is_active FROM users WHERE is_active = 1").fetchall() + finally: + conn.close() + other_servers = [s for s in servers if s.get("is_active", True) and s["name"] != server_name] + threshold = max(1, len(other_servers) // 2 + 1) + target_ids = fetch_server_sub_ids(target_srv) + other_ids = {} + for s in other_servers: + other_ids[s["name"]] = fetch_server_sub_ids(s) + total = len(users) + already = added = skipped = failed = 0 + results = [] + for u in users: + sub_id = u["subscription_id"] + username = u["username"] + if sub_id in target_ids: + already += 1 + continue + count = sum(1 for s in other_servers if sub_id in other_ids.get(s["name"], set())) + if count >= threshold: + ok = True + for inbound in target_srv.get("inbounds", []): + r = create_3xui_client(username, sub_id, inbound, u["traffic_limit_gb"] or 0) + if not r.get("success"): + ok = False + if ok: + added += 1 + clear_cache(sub_id) + else: + failed += 1 + results.append({"username": username, "added": ok}) + else: + skipped += 1 + results.append({"username": username, "skipped": True, "servers": count, "total": len(other_servers)}) + return {"server": server_name, "total": total, "already_on_server": already, + "added": added, "skipped": skipped, "failed": failed, + "threshold": f"{threshold}/{len(other_servers)}", "results": results} + +@app.post("/admin/api/propagate/{server_name}") +async def propagate_server(request: Request, server_name: str): + if not check_admin(request): + return JSONResponse({"error": "Unauthorized"}, status_code=401) + result = propagate_server_sync(server_name) + if "error" in result: + return JSONResponse(result, status_code=404) + return JSONResponse(result) + @app.post("/admin/api/users") async def create_user(request: Request, data: dict): if not check_admin(request): @@ -1715,4 +1415,4 @@ if __name__ == "__main__": asyncio.set_event_loop(loop) loop.create_task(rotate_shortids()) loop.create_task(poll_donationalerts()) - uvicorn.run(app, host="0.0.0.0", port=8000, log_level="info") \ No newline at end of file + uvicorn.run(app, host="0.0.0.0", port=8000, log_level="info") diff --git a/web/admin_dashboard.html b/web/admin_dashboard.html new file mode 100644 index 0000000..38c2adc --- /dev/null +++ b/web/admin_dashboard.html @@ -0,0 +1,54 @@ + + + + +Дашборд · {%title%} + + + +
+
+
+
+

📊 Дашборд

+
+ +
+ +
+
Всего
{%total_users%}
+
Free
{%free_users%}
+
Test
{%test_users%}
+
Premium
{%paid_users%}
+
Онлайн
{%online_count%}
на всех серверах
+
Выручка
{%total_revenue%}₽
всего оплат
+
Серверов
{%server_count%}
+
Инбаундов
{%inbound_count%}
+
+ +
+

🖥 Серверы

+{%servers_rows%} +
+
+ + + diff --git a/web/admin_login.html b/web/admin_login.html new file mode 100644 index 0000000..f22c9e9 --- /dev/null +++ b/web/admin_login.html @@ -0,0 +1,28 @@ + + + + +{%title%} + + + + + + diff --git a/web/admin_users.html b/web/admin_users.html new file mode 100644 index 0000000..338a0e4 --- /dev/null +++ b/web/admin_users.html @@ -0,0 +1,86 @@ + + + + +Управление · {%title%} + + + +
+
+
+
+

👥 Пользователи

+{%user_count%} +
+
+📊 Дашборд + +
+
+ +
+ + + + +{%users_rows%} +
IDUsernameSub IDТарифДнейОплаченоТрафикСтатусСоздан
+
+
+ + + + + + + + diff --git a/web/css/admin.css b/web/css/admin.css new file mode 100644 index 0000000..a084dcd --- /dev/null +++ b/web/css/admin.css @@ -0,0 +1,240 @@ +@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap'); + +:root { + --bg: #080c18; + --surface: rgba(255,255,255,0.03); + --surface-hover: rgba(255,255,255,0.06); + --border: rgba(255,255,255,0.06); + --border-hover: rgba(255,255,255,0.12); + --text: #fff; + --text-sec: rgba(255,255,255,0.5); + --text-ter: rgba(255,255,255,0.3); + --accent: #6C63FF; + --green: #10b981; + --amber: #f59e0b; + --rose: #f43f5e; + --card-rad: 16px; +} + +* { margin:0; padding:0; box-sizing:border-box } +body { + font-family:'Plus Jakarta Sans',sans-serif; + background:var(--bg); + color:var(--text); + min-height:100vh; + overflow-x:hidden; +} +a { color:var(--accent); text-decoration:none } +a:hover { opacity:.85 } + +.admin-bg { + position:fixed;inset:0; + background: + radial-gradient(ellipse 70% 50% at 0% 30%, rgba(108,99,255,0.07) 0%, transparent 70%), + radial-gradient(ellipse 50% 40% at 100% 70%, rgba(16,185,129,0.05) 0%, transparent 70%); + pointer-events:none; + z-index:0; +} + +.admin-wrap { + position:relative;z-index:1; + max-width:1100px;margin:0 auto;padding:40px 24px; + animation:fadeUp .5s ease-out both; +} + +/* Header */ +.admin-header { + display:flex;justify-content:space-between;align-items:center; + margin-bottom:28px;gap:12px;flex-wrap:wrap; +} +.header-left { display:flex;align-items:center;gap:10px } +.admin-header h1 { font-size:22px; font-weight:700; letter-spacing:-.02em } +.header-count { + font-size:13px; font-weight:600; font-family:'JetBrains Mono',monospace; + padding:3px 10px; border-radius:20px; + background:rgba(108,99,255,0.12); color:var(--accent); +} +.header-right { display:flex;align-items:center;gap:10px } +.nav-link { + font-size:13px; font-weight:600; padding:8px 14px; + border-radius:10px; transition:background .2s; +} +.nav-link:hover { background:var(--surface) } + +/* Buttons */ +.btn { + display:inline-flex;align-items:center;gap:6px; + padding:10px 20px; border:none; border-radius:10px; + font-size:13px; font-weight:600; font-family:'Plus Jakarta Sans',sans-serif; + cursor:pointer; transition:transform .25s, box-shadow .25s; + text-decoration:none; line-height:1; +} +.btn:hover { transform:translateY(-2px) } +.btn-primary { background:var(--accent); color:#fff } +.btn-primary:hover { box-shadow:0 6px 24px rgba(108,99,255,0.25) } +.btn-danger { background:var(--rose); color:#fff } +.btn-danger:hover { box-shadow:0 6px 24px rgba(244,63,94,0.25) } +.btn-sm { + padding:6px 10px; border:none; border-radius:8px; + font-size:12px; cursor:pointer; background:var(--surface); + color:var(--text-sec); transition:background .2s, color .2s; +} +.btn-sm:hover { background:var(--surface-hover); color:var(--text) } +.btn-sm.danger { color:var(--rose) } +.btn-sm.danger:hover { background:rgba(244,63,94,0.12) } +.btn-full { width:100%; justify-content:center; padding:12px } + +/* Table */ +.table-wrap { + background:var(--surface); border:1px solid var(--border); + border-radius:var(--card-rad); + backdrop-filter:blur(12px); -webkit-backdrop-filter:blur(12px); + overflow-x:auto; + animation:fadeUp .5s .1s ease-out both; +} +.tbl { width:100%; border-collapse:collapse; font-size:13px } +.tbl th { + text-align:left; padding:14px 16px; + color:var(--text-ter); font-weight:600; font-size:11px; + text-transform:uppercase; letter-spacing:.04em; + border-bottom:1px solid var(--border); + white-space:nowrap; +} +.tbl td { + padding:12px 16px; + border-bottom:1px solid var(--border); + color:var(--text-sec); + white-space:nowrap; +} +.tbl tr:last-child td { border-bottom:none } +.tbl tr:hover td { background:rgba(255,255,255,0.02) } +.tbl code { font-family:'JetBrains Mono',monospace; font-size:10px; color:var(--text-ter) } +.tbl .actions { display:flex; gap:4px } + +/* Badge */ +.badge { + display:inline-block; padding:3px 10px; border-radius:20px; + font-size:11px; font-weight:600; letter-spacing:.02em; +} +.badge-free { background:rgba(16,185,129,0.12); color:var(--green) } +.badge-paid { background:rgba(245,158,11,0.12); color:var(--amber) } +.badge-test { background:rgba(108,99,255,0.12); color:var(--accent) } + +/* Stat grid */ +.stat-grid { + display:grid; grid-template-columns:repeat(auto-fill,minmax(140px,1fr)); + gap:10px; margin-bottom:28px; + animation:fadeUp .5s .05s ease-out both; +} +.stat-card { + background:var(--surface); border:1px solid var(--border); + border-radius:var(--card-rad); padding:18px 20px; + backdrop-filter:blur(12px); -webkit-backdrop-filter:blur(12px); + transition:border-color .3s; +} +.stat-card:hover { border-color:var(--border-hover) } +.stat-lbl { font-size:12px; color:var(--text-ter); font-weight:500; margin-bottom:6px; text-transform:uppercase; letter-spacing:.03em } +.stat-val { font-size:30px; font-weight:800; letter-spacing:-.03em } +.stat-sub { font-size:11px; color:var(--text-ter); margin-top:4px } + +/* Server section */ +.srv-section { animation:fadeUp .5s .15s ease-out both } +.srv-section h2 { font-size:16px; font-weight:600; margin-bottom:14px } +.srv-row { + display:flex; justify-content:space-between; align-items:center; + background:var(--surface); border:1px solid var(--border); + border-radius:12px; padding:14px 18px; margin-bottom:8px; + backdrop-filter:blur(12px); -webkit-backdrop-filter:blur(12px); + transition:border-color .3s; +} +.srv-row:hover { border-color:var(--border-hover) } +.srv-row .srv-name { font-size:14px; font-weight:600; display:flex; align-items:center; gap:6px } +.srv-row .srv-ibs { font-size:12px; color:var(--text-ter); font-family:'JetBrains Mono',monospace } + +/* Login */ +.login-wrap { + position:fixed;inset:0;display:flex; + align-items:center;justify-content:center; + padding:20px; +} +.login-card { + background:var(--surface); border:1px solid var(--border); + border-radius:var(--card-rad); padding:40px 36px; + max-width:380px; width:100%; + backdrop-filter:blur(20px); -webkit-backdrop-filter:blur(20px); + text-align:center; + animation:fadeUp .5s ease-out both; +} +.login-icon { font-size:36px; margin-bottom:8px } +.login-card h1 { font-size:20px; font-weight:700; margin-bottom:24px; letter-spacing:-.02em } +.login-err { + margin-top:16px; padding:10px 14px; + background:rgba(244,63,94,0.08); + border:1px solid rgba(244,63,94,0.2); + border-radius:10px; font-size:13px; color:var(--rose); +} + +/* Forms */ +.fg { margin-bottom:16px; text-align:left } +.fg label { display:block; margin-bottom:6px; font-size:12px; font-weight:600; color:var(--text-sec) } +.fg input, .fg select { + width:100%; padding:12px 14px; + background:rgba(255,255,255,0.04); + border:1px solid var(--border); + border-radius:10px; color:var(--text); + font-size:14px; font-family:'Plus Jakarta Sans',sans-serif; + outline:none; transition:border-color .2s; +} +.fg input:focus, .fg select:focus { border-color:var(--accent) } +.fg input::placeholder { color:var(--text-ter) } +.fg select option { background:var(--bg) } +.fg-check { margin-bottom:16px } +.fg-check label { font-size:13px; color:var(--text-sec); display:flex; align-items:center; gap:8px; cursor:pointer } +.fg-check input[type="checkbox"] { width:16px; height:16px; accent-color:var(--accent) } + +/* Modal */ +.modal { + display:none; position:fixed; inset:0; + z-index:1000; align-items:center; justify-content:center; + padding:20px; +} +.modal.active { display:flex } +.modal-overlay { + position:absolute;inset:0; + background:rgba(0,0,0,0.6); + backdrop-filter:blur(4px); -webkit-backdrop-filter:blur(4px); +} +.modal-card { + position:relative; + background:var(--bg); border:1px solid var(--border); + border-radius:var(--card-rad); padding:28px; + max-width:460px; width:100%; + animation:fadeUp .3s ease-out both; +} +.modal-h { display:flex; justify-content:space-between; align-items:center; margin-bottom:20px } +.modal-h h2 { font-size:18px; font-weight:700 } +.modal-x { + background:none; border:none; color:var(--text-ter); + font-size:24px; cursor:pointer; padding:4px; line-height:1; + transition:color .2s; +} +.modal-x:hover { color:var(--text) } + +@keyframes fadeUp { + from { opacity:0; transform:translateY(16px) } + to { opacity:1; transform:translateY(0) } +} + +@media(max-width:700px) { + .admin-wrap { padding:20px 12px } + .admin-header { flex-direction:column; align-items:stretch } + .header-right { justify-content:space-between } + .tbl { font-size:12px } + .tbl th, .tbl td { padding:10px 12px } + .stat-card { padding:14px 14px } + .stat-val { font-size:22px } +} +@media(max-width:480px) { + .login-card { padding:28px 20px } + .modal-card { padding:20px } +} diff --git a/web/css/home.css b/web/css/home.css new file mode 100644 index 0000000..dd5c239 --- /dev/null +++ b/web/css/home.css @@ -0,0 +1,165 @@ +@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap'); + +:root { + --bg: #080c18; + --surface: rgba(255,255,255,0.03); + --border: rgba(255,255,255,0.06); + --border-hover: rgba(255,255,255,0.12); + --text: #fff; + --text-sec: rgba(255,255,255,0.5); + --text-ter: rgba(255,255,255,0.3); + --accent: #6C63FF; + --green: #10b981; + --amber: #f59e0b; + --rose: #f43f5e; + --card-rad: 16px; +} + +* { margin:0; padding:0; box-sizing:border-box } +html { scroll-behavior:smooth } +body { + font-family:'Plus Jakarta Sans',sans-serif; + background:var(--bg); + color:var(--text); + min-height:100vh; + overflow-x:hidden; +} +body::before { + content:''; + position:fixed;inset:0; + background: + radial-gradient(ellipse 80% 50% at 10% 0%, rgba(108,99,255,0.08) 0%, transparent 70%), + radial-gradient(ellipse 60% 40% at 90% 100%, rgba(16,185,129,0.06) 0%, transparent 70%); + pointer-events:none; + z-index:0; +} +.container { position:relative; z-index:1; max-width:720px; margin:0 auto; padding:48px 20px 32px } + +.section { animation:fadeUp .6s ease-out var(--d,0s) both; margin-bottom:40px } + +/* Header */ +.header { text-align:center; --d:0.1s } +.header h1 { + font-size:34px; font-weight:800; letter-spacing:-.03em; + background:linear-gradient(135deg,#fff 30%,rgba(255,255,255,.6)); + -webkit-background-clip:text; -webkit-text-fill-color:transparent; + background-clip:text; +} +.header p { color:var(--text-sec); font-size:15px; margin-top:8px; line-height:1.6 } +.header .sub { color:var(--text-ter); font-size:13px; margin-top:4px } + +/* Why us */ +.why { --d:0.2s } +.why h2 { font-size:18px; font-weight:600; margin-bottom:14px } +.why-grid { display:grid; grid-template-columns:1fr 1fr; gap:10px } +.w-card { + background:var(--surface); border:1px solid var(--border); + border-radius:12px; padding:14px; + backdrop-filter:blur(12px); -webkit-backdrop-filter:blur(12px); + transition:border-color .3s, transform .3s; + animation:fadeUp .5s ease-out calc(var(--d,0s) + var(--i,0)*0.06s) both; +} +.w-card:hover { border-color:var(--border-hover); transform:translateY(-2px) } +.w-card .e { font-size:20px; margin-bottom:6px } +.w-card h4 { font-size:13px; font-weight:600; margin-bottom:3px } +.w-card p { font-size:11px; color:var(--text-sec); line-height:1.5 } + +/* Servers */ +.srv-section { --d:0.35s } +.srv-section h2 { font-size:18px; font-weight:600; margin-bottom:14px } +.servers { display:grid; gap:14px } + +/* Server card */ +.s-card { + background:var(--surface); border:1px solid var(--border); + border-left:3px solid var(--hl, transparent); + border-radius:var(--card-rad); padding:16px 18px; + backdrop-filter:blur(12px); -webkit-backdrop-filter:blur(12px); + transition:border-color .4s, transform .4s, box-shadow .4s; + animation:fadeUp .6s ease-out var(--d,0s) both; +} +.s-card:hover { border-color:var(--border-hover); transform:translateY(-3px); box-shadow:0 12px 48px rgba(108,99,255,0.06) } +.s-head { font-size:14px; font-weight:600; display:flex; align-items:center; gap:6px; flex-wrap:wrap } +.s-head .flag { font-size:20px; line-height:1 } +.tier { font-size:10px; font-weight:600; padding:2px 8px; border-radius:20px; letter-spacing:.02em } +.tier.both { background:rgba(108,99,255,0.15); color:var(--accent) } +.tier.free { background:rgba(16,185,129,0.12); color:var(--green) } +.tier.prem { background:rgba(245,158,11,0.12); color:var(--amber) } +.h-dot { width:8px; height:8px; border-radius:50%; display:inline-block; flex-shrink:0 } +.h-lbl { font-size:11px; font-weight:600; font-family:'JetBrains Mono',monospace; white-space:nowrap } +.s-stats { font-size:11px; color:var(--text-ter); font-family:'JetBrains Mono',monospace; margin-top:8px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis } +.s-stats.off { color:var(--rose) } + +/* Inbounds inside server card */ +.ib-list { margin-top:10px; display:flex; flex-wrap:wrap; gap:6px } +.ib-card { + display:inline-flex; align-items:center; gap:5px; + background:rgba(255,255,255,0.04); + border:1px solid rgba(255,255,255,0.06); + border-radius:8px; padding:5px 10px; + font-size:11px; font-family:'JetBrains Mono',monospace; + color:var(--text-sec); + transition:border-color .2s; +} +.ib-card:hover { border-color:var(--border-hover) } +.ib-dot { width:5px; height:5px; border-radius:50%; background:var(--green); flex-shrink:0 } +.ib-name { font-weight:500 } +.ib-type { color:var(--text-ter); font-size:10px } + +/* RU server special note */ +.ru-note { + margin-top:10px; padding:8px 12px; + background:rgba(245,158,11,0.08); + border:1px solid rgba(245,158,11,0.15); + border-radius:8px; + font-size:11px; color:var(--amber); + display:flex; align-items:center; gap:6px; +} + +/* Tech info */ +.tech { --d:0.5s } +.tech h2 { font-size:18px; font-weight:600; margin-bottom:14px } +.tech-grid { + display:grid; grid-template-columns:1fr 1fr; + background:var(--surface); border:1px solid var(--border); + border-radius:var(--card-rad); padding:20px; + backdrop-filter:blur(12px); -webkit-backdrop-filter:blur(12px); + transition:border-color .3s; +} +.tech-grid:hover { border-color:var(--border-hover) } +.t-item { font-size:12px; color:var(--text-sec); padding:8px 0; display:flex; align-items:center; gap:8px; border-bottom:1px solid var(--border) } +.t-item:last-child { border-bottom:none } +.t-item .t-dot { width:4px; height:4px; border-radius:2px; background:var(--accent); flex-shrink:0 } + +/* Buttons */ +.btns { display:flex; justify-content:center; gap:10px; margin-top:12px; animation:fadeUp .6s .55s ease-out both } +.btn { + display:inline-flex; align-items:center; gap:6px; + padding:12px 28px; border-radius:12px; + font-size:14px; font-weight:600; text-decoration:none; + transition:transform .3s, box-shadow .3s; + cursor:pointer; +} +.btn:hover { transform:translateY(-2px); box-shadow:0 8px 32px rgba(108,99,255,0.2) } +.btn.da { background:linear-gradient(135deg,#f43f5e,#e11d48); color:#fff } + +.footer { text-align:center; margin-top:40px; color:var(--text-ter); font-size:12px; animation:fadeUp .6s .6s ease-out both } + +@keyframes fadeUp { + from { opacity:0; transform:translateY(16px) } + to { opacity:1; transform:translateY(0) } +} + +@media(max-width:520px) { + .container { padding:24px 12px } + .header h1 { font-size:24px } + .why-grid { grid-template-columns:1fr } + .s-card { padding:12px 14px } + .s-head { font-size:13px; gap:4px } + .h-lbl { font-size:10px } + .s-stats { font-size:10px } + .ib-card { font-size:10px; padding:4px 8px } + .ru-note { font-size:10px; padding:6px 10px } + .tech-grid { grid-template-columns:1fr } + .btns { flex-direction:column; align-items:center } +} diff --git a/web/css/sub.css b/web/css/sub.css new file mode 100644 index 0000000..23ce1e5 --- /dev/null +++ b/web/css/sub.css @@ -0,0 +1,136 @@ +@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap'); + +:root { + --bg: #080c18; + --surface: rgba(255,255,255,0.03); + --border: rgba(255,255,255,0.06); + --border-hover: rgba(255,255,255,0.12); + --text: #fff; + --text-sec: rgba(255,255,255,0.5); + --text-ter: rgba(255,255,255,0.3); + --accent: #6C63FF; + --green: #10b981; + --amber: #f59e0b; + --rose: #f43f5e; + --card-rad: 16px; +} + +* { margin:0; padding:0; box-sizing:border-box } +body { + font-family:'Plus Jakarta Sans',sans-serif; + background:var(--bg); + color:var(--text); + min-height:100vh; + overflow-x:hidden; +} + +.sub-bg { + position:fixed;inset:0; + background: + radial-gradient(ellipse 70% 50% at 30% 0%, rgba(108,99,255,0.08) 0%, transparent 70%), + radial-gradient(ellipse 50% 40% at 70% 100%, rgba(16,185,129,0.06) 0%, transparent 70%); + pointer-events:none; + z-index:0; +} + +.sub-wrap { + position:relative;z-index:1; + max-width:600px;margin:0 auto;padding:40px 20px; +} + +.sub-card { + background:var(--surface); border:1px solid var(--border); + border-radius:var(--card-rad); padding:28px 24px; + margin-bottom:16px; + backdrop-filter:blur(16px); -webkit-backdrop-filter:blur(16px); + animation:fadeUp .5s ease-out var(--d,0s) both; +} + +.sub-top { text-align:center; --d:0s } +.sub-top h1 { + font-size:24px; font-weight:800; letter-spacing:-.03em; + background:linear-gradient(135deg,#fff 30%,rgba(255,255,255,.6)); + -webkit-background-clip:text; -webkit-text-fill-color:transparent; + background-clip:text; + margin:12px 0 8px; +} + +.logo-img { width:72px; height:72px; border-radius:14px } +.logo-emoji { font-size:48px } + +.tier-badge { + display:inline-block; + padding:4px 14px; border-radius:20px; + font-size:12px; font-weight:600; letter-spacing:.02em; + text-transform:uppercase; +} + +.announcement { + background:rgba(108,99,255,0.08); + border:1px solid rgba(108,99,255,0.15); + border-left:3px solid var(--accent); + border-radius:12px; padding:14px 16px; + margin-bottom:16px; font-size:14px; + animation:fadeUp .5s .05s ease-out both; +} + +.info-block { font-size:14px; line-height:1.8; margin-bottom:16px } +.info-block p { display:flex; align-items:center; gap:8px; color:var(--text-sec) } +.traffic-server { font-size:12px; margin-top:8px; padding-top:8px; border-top:1px solid var(--border) } +.traffic-server .server-name { color:var(--text-ter); font-weight:500 } +.traffic-server .traffic-values { color:var(--green); font-family:'JetBrains Mono',monospace } + +.srv-tags { display:flex; flex-wrap:wrap; gap:8px } +.srv-tag { + display:inline-flex; align-items:center; gap:4px; + background:rgba(255,255,255,0.04); + border:1px solid var(--border); + border-radius:20px; padding:5px 12px; + font-size:12px; font-weight:500; color:var(--text-sec); +} + +.sub-qr { text-align:center } +.qr-box { + display:inline-block; + background:rgba(255,255,255,0.04); + border-radius:14px; padding:8px; margin-bottom:14px; +} +.qr-box img { + display:block; + border-radius:10px; width:200px; height:200px; +} +.qr-url { + background:rgba(255,255,255,0.03); + border:1px solid var(--border); + border-radius:10px; padding:10px 14px; + word-break:break-all; font-size:11px; color:var(--text-ter); + font-family:'JetBrains Mono',monospace; margin-bottom:16px; +} +.qr-actions { display:flex; flex-direction:column; gap:10px; align-items:center } + +.btn { + display:inline-flex; align-items:center; gap:6px; + padding:12px 28px; border:none; border-radius:12px; + font-size:14px; font-weight:600; font-family:'Plus Jakarta Sans',sans-serif; + cursor:pointer; text-decoration:none; + transition:transform .3s, box-shadow .3s; +} +.btn:hover { transform:translateY(-2px) } +.btn-primary { background:var(--accent); color:#fff } +.btn-primary:hover { box-shadow:0 8px 32px rgba(108,99,255,0.25) } +.btn-support { background:var(--rose); color:#fff } +.btn-support:hover { box-shadow:0 8px 32px rgba(244,63,94,0.25) } + +.sub-footer { text-align:center; padding:20px; color:var(--text-ter); font-size:12px; --d:0.25s } + +@keyframes fadeUp { + from { opacity:0; transform:translateY(16px) } + to { opacity:1; transform:translateY(0) } +} + +@media(max-width:520px) { + .sub-wrap { padding:20px 12px } + .sub-card { padding:20px 16px } + .sub-top h1 { font-size:20px } + .qr-box img { width:160px; height:160px } +} diff --git a/web/home.html b/web/home.html new file mode 100644 index 0000000..ef40928 --- /dev/null +++ b/web/home.html @@ -0,0 +1,51 @@ + + + + +{%title%} + + + +
+ +
+

⚡ {%title%}

+

Быстрый VPN с собственными серверами в Европе. Безлимитный трафик, VLESS + XTLS, никаких логов.

+

Подписка на основе подписки · Happ-совместимость · Мгновенная активация

+
+ +
+

Почему {%title%}

+
+
🚀

Скорость

Каналы 1–10 Gbit/s, собственные серверы, без посредников

+
🔒

Приватность

Zero-log политика — трафик не хранится и не анализируется

+
🌎

Покрытие

Серверы в Германии, Швеции и России — выбирай ближайший

+
💎

Тарифы

Бесплатный доступ к free-серверам или Premium от 150 ₽/мес

+
+
+ +
+

🖥 Состояние серверов

+
{%srv_cards%}
+
+ +
+

🔧 Технические детали

+
+
VLESS + XTLS Vision / Reality
+
TCP, WebSocket, gRPC
+
DDoS-защита на всех серверах
+
Zero-log политика
+
Поддержка Happ (iOS / Android / Desktop)
+
Автообновление подписки каждые 12ч
+
+ +
+ + + +
+ + diff --git a/web/js/admin.js b/web/js/admin.js new file mode 100644 index 0000000..183533d --- /dev/null +++ b/web/js/admin.js @@ -0,0 +1,114 @@ +function openModal() { document.getElementById('userModal').classList.add('active'); } +function closeModal() { document.getElementById('userModal').classList.remove('active'); } + +function toast(msg, ok) { + var t = document.createElement('div'); + t.textContent = msg; + Object.assign(t.style, { + position:'fixed', bottom:'24px', left:'50%', transform:'translateX(-50%)', + padding:'12px 24px', borderRadius:'12px', fontSize:'13px', fontWeight:'600', + background: ok ? 'rgba(16,185,129,0.15)' : 'rgba(244,63,94,0.15)', + border: ok ? '1px solid rgba(16,185,129,0.3)' : '1px solid rgba(244,63,94,0.3)', + color: ok ? 'var(--green)' : 'var(--rose)', + backdropFilter:'blur(12px)', zIndex:'9999', + animation:'fadeUp .3s ease-out both', fontFamily:'"Plus Jakarta Sans",sans-serif' + }); + document.body.appendChild(t); + setTimeout(function() { t.style.opacity='0'; t.style.transition='opacity .4s'; setTimeout(function(){t.remove()},400) }, 3000); +} + +document.addEventListener('DOMContentLoaded', function() { + var form = document.getElementById('userForm'); + if (form) { + form.addEventListener('submit', async function(e) { + e.preventDefault(); + var formData = new FormData(e.target); + var username = formData.get('username'); + var btn = e.target.querySelector('button[type="submit"]'); + var orig = btn.textContent; + btn.disabled = true; + btn.textContent = '...'; + + var response = await fetch('/admin/api/users', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + username: username, + traffic_limit_gb: parseInt(formData.get('traffic_limit_gb')) || 0 + }) + }); + + var result = await response.json(); + + if (response.ok) { + var msg = 'Пользователь ' + username + ' создан!'; + toast(msg, true); + setTimeout(function(){ location.reload() }, 1000); + } else { + toast('Ошибка: ' + (result.error || 'unknown'), false); + btn.disabled = false; + btn.textContent = orig; + } + }); + } +}); + +function editUser(id, tier, days, traffic, active) { + document.getElementById('editId').value = id; + document.getElementById('et').value = tier; + document.getElementById('ed').value = days; + document.getElementById('etr').value = traffic; + document.getElementById('editActive').checked = active == 1; + document.getElementById('editModal').classList.add('active'); +} + +function closeEditModal() { document.getElementById('editModal').classList.remove('active'); } + +async function submitEditForm() { + var form = document.getElementById('editForm'); + var formData = new FormData(form); + var data = { + id: formData.get('id'), + tier: formData.get('tier'), + tariff_days_remaining: parseInt(formData.get('tariff_days_remaining')) || 0, + traffic_limit_gb: formData.get('traffic_limit_gb') === '' ? 0 : parseInt(formData.get('traffic_limit_gb')), + is_active: formData.get('is_active') === 'on' + }; + var btn = form.querySelector('button'); + var orig = btn.textContent; + btn.disabled = true; + btn.textContent = '...'; + + var response = await fetch('/admin/api/users/update', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(data) + }); + + if (response.ok) { + toast('Сохранено!', true); + setTimeout(function(){ location.reload() }, 1000); + } else { + var result = await response.json(); + toast('Ошибка: ' + (result.error || 'unknown'), false); + btn.disabled = false; + btn.textContent = orig; + } +} + +function deleteUser(id, username) { + if (confirm('Удалить пользователя ' + username + '? Это удалит его со всех серверов!')) { + fetch('/admin/api/users/delete', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({id: id, username: username}) + }).then(function(r) { + if (r.ok) { + toast('Удалён!', true); + setTimeout(function(){ location.reload() }, 1000); + } else { + toast('Ошибка удаления', false); + } + }); + } +} diff --git a/web/sub.html b/web/sub.html new file mode 100644 index 0000000..1dedf7e --- /dev/null +++ b/web/sub.html @@ -0,0 +1,40 @@ + + + + +Подписка · {%title%} + + + +
+
+
+{%logo_html%} +

{%title%}

+{%tier_badge%} +
+ +{%announcement_html%} + +
+{%info_html%} +
{%servers_html%}
+
+ +
+
+QR +
+
{%sub_url%}
+ +
+ + +
+ +