commit f6eb56b88d72bb72bc6106c00686aa601b14e4c7 Author: Андрей Бобырев Date: Sat Jun 6 10:57:31 2026 +0300 feat: split out GoTelegram project diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2ca184 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.py[cod] +.DS_Store +.env +*.log +work/ +dist/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..fa53464 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# GoTelegram Pro + +Отдельный проект GoTelegram Pro: MTProxy на базе `telemt`, Telegram-бот, локальная web-admin панель, статистика, backup/restore, шаблоны сайта. + +## Установка + +На сервере под `root`: + +```bash +curl -fsSL https://raw.githubusercontent.com/andrey271192/gotelegram/main/bootstrap.sh | bash +``` + +Если репозиторий private, первый `curl` тоже должен получить GitHub token: + +```bash +curl -fsSL -H "Authorization: Bearer $GITHUB_TOKEN" \ + https://raw.githubusercontent.com/andrey271192/gotelegram/main/bootstrap.sh | \ + GITHUB_TOKEN="$GITHUB_TOKEN" bash +``` + +После установки команда доступна как: + +```bash +gotelegram +``` + +## Локальная проверка без GitHub + +```bash +rsync -a ./ root@SERVER:/opt/gotelegram/ +ssh root@SERVER 'chmod +x /opt/gotelegram/install.sh /opt/gotelegram/install_gotelegram_bot.sh /opt/gotelegram/lib/*.sh && ln -sf /opt/gotelegram/install.sh /usr/local/bin/gotelegram && gotelegram' +``` + +## Состав + +| Путь | Назначение | +| --- | --- | +| `bootstrap.sh` | загружает файлы проекта в `/opt/gotelegram` и запускает меню | +| `install.sh` | основное CLI-меню GoTelegram | +| `install_gotelegram_bot.sh` | отдельная установка Telegram-бота | +| `lib/` | общие функции, telemt, nginx/site, stats, backup, i18n | +| `gotelegram-bot/` | Python Telegram bot | +| `admin-web/` | локальная web-admin панель | +| `templates_catalog.json` | каталог HTML-шаблонов | + +## Переменные + +| Переменная | По умолчанию | Описание | +| --- | --- | --- | +| `GOTELEGRAM_BASE` | `https://raw.githubusercontent.com/andrey271192/gotelegram/main` | база загрузки файлов | +| `GOTELEGRAM_INSTALL_DIR` | `/opt/gotelegram` | путь установки | +| `GITHUB_TOKEN` / `GH_TOKEN` | пусто | token для private GitHub raw downloads | + +## Проверки + +```bash +bash -n bootstrap.sh install.sh install_gotelegram_bot.sh lib/*.sh lib/lang/*.sh +rg -i "old-brand-name" . +``` diff --git a/admin-web/server.py b/admin-web/server.py new file mode 100644 index 0000000..43ffb3b --- /dev/null +++ b/admin-web/server.py @@ -0,0 +1,1665 @@ +#!/usr/bin/env python3 +""" +goTelegram Pro local web admin. + +The service is intentionally bound to 127.0.0.1:1984. Operators reach it +through an SSH tunnel; it must never be exposed directly on the public network. +""" + +from __future__ import annotations + +import csv +import fcntl +import hashlib +import json +import mimetypes +import os +import re +import secrets +import shlex +import socket +import subprocess +import time +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any + + +ADMIN_DIR = Path(os.getenv("GOTELEGRAM_ADMIN_DIR", "/opt/gotelegram-admin")) +STATIC_DIR = Path(os.getenv("GOTELEGRAM_ADMIN_STATIC", str(ADMIN_DIR / "static"))) + +GOTELEGRAM_CONFIG = Path(os.getenv("GOTELEGRAM_CONFIG", "/opt/gotelegram/config.json")) +TELEMT_CONFIG = Path(os.getenv("TELEMT_CONFIG", "/etc/telemt/config.toml")) +HISTORY_FILE = Path(os.getenv("GOTELEGRAM_STATS_HISTORY", "/opt/gotelegram/stats_history.csv")) +USER_HISTORY_FILE = Path(os.getenv("GOTELEGRAM_USER_STATS_HISTORY", "/opt/gotelegram/user_stats_history.csv")) +CURRENT_STATS = Path(os.getenv("GOTELEGRAM_STATS_CURRENT", "/run/gotelegram/stats_current.json")) +BACKUP_DIR = Path(os.getenv("GOTELEGRAM_BACKUP_DIR", "/opt/gotelegram/backups")) +INSTALL_DIR = Path(os.getenv("GOTELEGRAM_DIR", "/opt/gotelegram")) +BOT_DIR = Path(os.getenv("GOTELEGRAM_BOT_DIR", "/opt/gotelegram-bot")) +DISABLED_USERS_FILE = Path(os.getenv("GOTELEGRAM_DISABLED_USERS", "/opt/gotelegram/disabled_users.json")) +USER_LOCK_FILE = Path(os.getenv("GOTELEGRAM_USER_LOCK", "/run/gotelegram/admin-users.lock")) +SHARED_443_CONFIG = Path(os.getenv("GOTELEGRAM_SHARED_443", "/opt/gotelegram/shared-443.json")) +BACKUP_SCHEDULE_FILE = Path(os.getenv("GOTELEGRAM_BACKUP_SCHEDULE", "/opt/gotelegram/backup_schedule.json")) +BACKUP_RESTORE_LOG = Path(os.getenv("GOTELEGRAM_BACKUP_RESTORE_LOG", "/var/log/gotelegram-restore.log")) + +HOST = os.getenv("GOTELEGRAM_ADMIN_HOST", "127.0.0.1") +PORT = int(os.getenv("GOTELEGRAM_ADMIN_PORT", "1984")) +VERSION = "2.5.0" +USER_RE = re.compile(r"^[A-Za-z0-9_.-]{1,48}$") +LANG_RE = re.compile(r"^(en|ru)$") +SENSITIVE_CONFIG_KEYS = {"secret"} +BACKUP_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]+\.tar\.gz(\.enc)?$") +MAX_UNIQUE_IP_LIMIT = 1000000 +TELEMT_RESTART_DEBOUNCE_SECONDS = float(os.getenv("GOTELEGRAM_TELEMT_RESTART_DEBOUNCE", "8")) +_LAST_TELEMT_RESTART = 0.0 +TRAFFIC_WINDOWS = { + "15m": 15 * 60, + "1h": 60 * 60, + "24h": 24 * 60 * 60, + "month": 30 * 24 * 60 * 60, +} + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def run(cmd: list[str], timeout: int = 8) -> tuple[int, str, str]: + try: + proc = subprocess.run( + cmd, + text=True, + capture_output=True, + timeout=timeout, + check=False, + ) + return proc.returncode, proc.stdout, proc.stderr + except Exception as exc: # pragma: no cover - system dependent + return 125, "", str(exc) + + +def run_bytes(cmd: list[str], timeout: int = 8) -> tuple[int, bytes, str]: + try: + proc = subprocess.run( + cmd, + capture_output=True, + timeout=timeout, + check=False, + ) + return proc.returncode, proc.stdout, proc.stderr.decode("utf-8", errors="replace") + except Exception as exc: # pragma: no cover - system dependent + return 125, b"", str(exc) + + +class FileLock: + def __init__(self, path: Path): + self.path = path + self.handle: Any = None + + def __enter__(self) -> "FileLock": + self.path.parent.mkdir(parents=True, exist_ok=True) + self.handle = self.path.open("w", encoding="utf-8") + fcntl.flock(self.handle.fileno(), fcntl.LOCK_EX) + return self + + def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None: + if self.handle: + fcntl.flock(self.handle.fileno(), fcntl.LOCK_UN) + self.handle.close() + + +def load_json(path: Path, fallback: Any = None) -> Any: + try: + with path.open("r", encoding="utf-8") as fh: + return json.load(fh) + except Exception: + return fallback + + +def save_json(path: Path, data: Any, mode: int = 0o600) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(data, ensure_ascii=False, indent=4) + "\n", encoding="utf-8") + os.chmod(tmp, mode) + tmp.replace(path) + + +def read_language(config: dict[str, Any] | None = None) -> str: + config = config or load_json(GOTELEGRAM_CONFIG, {}) or {} + lang = str(config.get("language") or config.get("lang") or "").strip().lower() + marker = INSTALL_DIR / ".language" + if lang not in {"en", "ru"} and marker.exists(): + try: + lang = marker.read_text(encoding="utf-8", errors="ignore").strip().lower()[:2] + except OSError: + lang = "" + return lang if lang in {"en", "ru"} else "en" + + +def public_config(config: dict[str, Any]) -> dict[str, Any]: + return {key: value for key, value in config.items() if key not in SENSITIVE_CONFIG_KEYS} + + +def write_language(lang: str) -> dict[str, Any]: + lang = str(lang or "").strip().lower() + if not LANG_RE.match(lang): + raise ValueError("unsupported language") + config = load_json(GOTELEGRAM_CONFIG, {}) or {} + if not isinstance(config, dict): + config = {} + config["language"] = lang + config["updated_at"] = utc_now() + save_json(GOTELEGRAM_CONFIG, config) + INSTALL_DIR.mkdir(parents=True, exist_ok=True) + (INSTALL_DIR / ".language").write_text(lang + "\n", encoding="utf-8") + bot_env = BOT_DIR / ".env" + if bot_env.exists(): + lines = bot_env.read_text(encoding="utf-8", errors="ignore").splitlines() + found = False + out = [] + for line in lines: + if line.startswith("BOT_LANG="): + out.append(f"BOT_LANG={lang}") + found = True + else: + out.append(line) + if not found: + out.append(f"BOT_LANG={lang}") + bot_env.write_text("\n".join(out).rstrip() + "\n", encoding="utf-8") + os.chmod(bot_env, 0o600) + return {"language": lang} + + +def read_telemt_users() -> dict[str, str]: + if not TELEMT_CONFIG.exists(): + return {} + users: dict[str, str] = {} + in_users = False + for raw in TELEMT_CONFIG.read_text(encoding="utf-8", errors="ignore").splitlines(): + line = raw.strip() + if line == "[access.users]": + in_users = True + continue + if in_users and line.startswith("["): + break + if not in_users or not line or line.startswith("#") or "=" not in line: + continue + name, value = line.split("=", 1) + name = parse_toml_key(name) + value = value.strip().split("#", 1)[0].strip() + if value.startswith('"') and '"' in value[1:]: + value = value[1:].split('"', 1)[0] + elif value.startswith("'") and "'" in value[1:]: + value = value[1:].split("'", 1)[0] + if USER_RE.match(name) and value: + users[name] = value + return users + + +def read_toml_int_table(table: str) -> dict[str, int]: + if not TELEMT_CONFIG.exists(): + return {} + values: dict[str, int] = {} + section = f"[{table}]" + in_table = False + for raw in TELEMT_CONFIG.read_text(encoding="utf-8", errors="ignore").splitlines(): + line = raw.strip() + if line == section: + in_table = True + continue + if in_table and line.startswith("["): + break + if not in_table or not line or line.startswith("#") or "=" not in line: + continue + name, value = line.split("=", 1) + name = parse_toml_key(name) + if not USER_RE.match(name): + continue + raw_value = value.strip().split("#", 1)[0].strip().strip('"').strip("'") + try: + number = int(raw_value) + except ValueError: + continue + values[name] = max(0, number) + return values + + +def read_user_max_unique_ips() -> dict[str, int]: + return read_toml_int_table("access.user_max_unique_ips") + + +def read_disabled_users() -> dict[str, str]: + raw = load_json(DISABLED_USERS_FILE, {}) or {} + if not isinstance(raw, dict): + return {} + users = raw.get("users") if isinstance(raw.get("users"), dict) else raw + if not isinstance(users, dict): + return {} + clean: dict[str, str] = {} + for name, secret in users.items(): + if name in {"version", "updated_at"}: + continue + name_s = str(name).strip() + secret_s = str(secret or "").strip() + if USER_RE.match(name_s) and secret_s: + clean[name_s] = secret_s + return clean + + +def write_disabled_users(users: dict[str, str]) -> None: + payload = { + "version": 1, + "updated_at": utc_now(), + "users": {name: users[name] for name in sorted(users)}, + } + save_json(DISABLED_USERS_FILE, payload) + + +def read_user_records() -> dict[str, dict[str, Any]]: + active = read_telemt_users() + disabled = read_disabled_users() + ip_limits = read_user_max_unique_ips() + records: dict[str, dict[str, Any]] = {} + for name, secret in disabled.items(): + records[name] = {"secret": secret, "enabled": False, "max_unique_ips": ip_limits.get(name, 0)} + for name, secret in active.items(): + records[name] = {"secret": secret, "enabled": True, "max_unique_ips": ip_limits.get(name, 0)} + return records + + +def _ordered_user_lines(users: dict[str, str]) -> list[str]: + names = [] + if "main" in users: + names.append("main") + names.extend(sorted(n for n in users if n != "main")) + return [f'{quote_toml_key(name)} = "{users[name]}"' for name in names] + + +def _ordered_user_int_lines(values: dict[str, int]) -> list[str]: + positive: dict[str, int] = {} + for name, value in values.items(): + name_s = str(name) + if not USER_RE.match(name_s): + continue + try: + number = int(value) + except (TypeError, ValueError): + continue + if number > 0: + positive[name_s] = number + names = [] + if "main" in positive: + names.append("main") + names.extend(sorted(n for n in positive if n != "main")) + return [f'{quote_toml_key(name)} = {positive[name]}' for name in names] + + +def parse_toml_key(raw: str) -> str: + key = raw.strip() + if len(key) >= 2 and key[0] == key[-1] == '"': + try: + return json.loads(key) + except json.JSONDecodeError: + return key[1:-1].replace('\\"', '"').replace("\\\\", "\\") + if len(key) >= 2 and key[0] == key[-1] == "'": + return key[1:-1] + return key + + +def quote_toml_key(name: str) -> str: + escaped = name.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + + +def write_telemt_users(users: dict[str, str]) -> None: + TELEMT_CONFIG.parent.mkdir(parents=True, exist_ok=True) + lines = TELEMT_CONFIG.read_text(encoding="utf-8", errors="ignore").splitlines() if TELEMT_CONFIG.exists() else [] + rendered = _ordered_user_lines(users) + out: list[str] = [] + in_users = False + found = False + + for raw in lines: + if raw.strip() == "[access.users]": + found = True + in_users = True + out.append(raw) + out.extend(rendered) + continue + if in_users and raw.strip().startswith("["): + in_users = False + if in_users: + continue + out.append(raw) + + if not found: + if out and out[-1].strip(): + out.append("") + out.append("[access.users]") + out.extend(rendered) + + tmp = TELEMT_CONFIG.with_name(TELEMT_CONFIG.name + ".tmp") + tmp.write_text("\n".join(out).rstrip() + "\n", encoding="utf-8") + os.chmod(tmp, 0o600) + tmp.replace(TELEMT_CONFIG) + + +def write_toml_int_table(table: str, values: dict[str, int]) -> None: + TELEMT_CONFIG.parent.mkdir(parents=True, exist_ok=True) + lines = TELEMT_CONFIG.read_text(encoding="utf-8", errors="ignore").splitlines() if TELEMT_CONFIG.exists() else [] + rendered = _ordered_user_int_lines(values) + header = f"[{table}]" + out: list[str] = [] + in_table = False + found = False + + for raw in lines: + if raw.strip() == header: + found = True + in_table = True + if rendered: + out.append(raw) + out.extend(rendered) + continue + if in_table and raw.strip().startswith("["): + in_table = False + if in_table: + continue + out.append(raw) + + if not found and rendered: + if out and out[-1].strip(): + out.append("") + out.append(header) + out.extend(rendered) + + tmp = TELEMT_CONFIG.with_name(TELEMT_CONFIG.name + ".tmp") + tmp.write_text("\n".join(out).rstrip() + "\n", encoding="utf-8") + os.chmod(tmp, 0o600) + tmp.replace(TELEMT_CONFIG) + + +def write_user_max_unique_ips(values: dict[str, int]) -> None: + write_toml_int_table("access.user_max_unique_ips", values) + + +def normalize_max_unique_ips(value: Any) -> int: + try: + number = int(value) + except (TypeError, ValueError): + raise ValueError("max_unique_ips must be an integer") from None + if number < 0 or number > MAX_UNIQUE_IP_LIMIT: + raise ValueError(f"max_unique_ips must be between 0 and {MAX_UNIQUE_IP_LIMIT}") + return number + + +def restart_service(name: str) -> bool: + code, _, _ = run(["systemctl", "restart", name], timeout=25) + if code != 0: + return False + if name == "telemt": + return wait_tcp_port(read_telemt_port(), timeout=90) + return True + + +def request_service_restart(name: str) -> bool: + global _LAST_TELEMT_RESTART + if name == "telemt": + now = time.monotonic() + if _LAST_TELEMT_RESTART > 0 and now - _LAST_TELEMT_RESTART < TELEMT_RESTART_DEBOUNCE_SECONDS: + status = service_status(name) + if status in {"running", "activating"}: + return True + run(["systemctl", "reset-failed", name], timeout=5) + _LAST_TELEMT_RESTART = now + code, _, _ = run(["systemctl", "--no-block", "restart", name], timeout=5) + return code == 0 + + +def service_status(name: str) -> str: + code, stdout, _ = run(["systemctl", "is-active", name], timeout=3) + value = stdout.strip() + if code == 0 and value == "active": + return "running" + code, stdout, _ = run(["systemctl", "list-unit-files", f"{name}.service", "--no-legend"], timeout=3) + if code != 0 or not stdout.strip(): + return "not_installed" + if value in {"failed", "inactive", "activating", "deactivating"}: + return value + return "stopped" + + +def read_telemt_port() -> int: + if not TELEMT_CONFIG.exists(): + return 443 + in_server = False + for raw in TELEMT_CONFIG.read_text(encoding="utf-8", errors="ignore").splitlines(): + line = raw.strip() + if line == "[server]": + in_server = True + continue + if in_server and line.startswith("["): + break + if in_server and line.startswith("port") and "=" in line: + try: + return int(line.split("=", 1)[1].strip().split("#", 1)[0]) + except ValueError: + return 443 + return 443 + + +def _is_port_addr(value: str, port: int) -> bool: + token = value.strip() + if token.startswith("[") and "]:" in token: + return token.rsplit(":", 1)[-1] == str(port) + return token.rsplit(":", 1)[-1] == str(port) if ":" in token else False + + +def _process_role(process: str) -> str: + lowered = process.lower() + if "telemt" in lowered or "mtproto" in lowered: + return "mtproxy" + if "nginx" in lowered or "apache" in lowered or "caddy" in lowered: + return "site" + if "xray" in lowered or "x-ui" in lowered or "3x-ui" in lowered or "xui" in lowered: + return "xray" + if "amnezia" in lowered or "awg" in lowered or "wireguard" in lowered or re.search(r"\bwg\b", lowered): + return "amneziawg" + return "other" + + +def parse_ss_listeners(output: str, proto: str, port: int = 443) -> list[dict[str, Any]]: + listeners: list[dict[str, Any]] = [] + seen: set[tuple[str, str, str]] = set() + for line in output.splitlines(): + parts = line.split() + address = next((part for part in parts if _is_port_addr(part, port)), "") + if not address: + continue + matches = re.findall(r'\("([^"]+)",pid=(\d+)', line) + if matches: + process_names = [] + pids = [] + for proc, pid in matches: + if proc not in process_names: + process_names.append(proc) + if pid not in pids: + pids.append(pid) + process = ", ".join(process_names) + pid_text = ", ".join(pids) + else: + process = "unknown" + pid_text = "" + key = (proto, address, process) + if key in seen: + continue + seen.add(key) + listeners.append({ + "proto": proto.upper(), + "address": address, + "process": process, + "pid": pid_text, + "role": _process_role(process), + }) + return listeners + + +def collect_port_listeners(port: int) -> tuple[list[dict[str, Any]], list[str]]: + listeners: list[dict[str, Any]] = [] + errors: list[str] = [] + for proto, args in { + "tcp": ["ss", "-H", "-ltnp"], + "udp": ["ss", "-H", "-lunp"], + }.items(): + code, stdout, stderr = run(args, timeout=2) + if code == 0: + listeners.extend(parse_ss_listeners(stdout, proto, port)) + elif stderr.strip(): + errors.append(stderr.strip()) + listeners.sort(key=lambda item: (item["proto"], item["address"], item["process"])) + return listeners, errors + + +def read_telemt_edge_settings() -> dict[str, Any]: + settings: dict[str, Any] = {"tls_domain": "", "mask_port": 0, "dns_overrides": []} + if not TELEMT_CONFIG.exists(): + return settings + section = "" + for raw in TELEMT_CONFIG.read_text(encoding="utf-8", errors="ignore").splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + if line.startswith("[") and line.endswith("]"): + section = line.strip("[]") + continue + if "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().split("#", 1)[0].strip() + if section == "censorship" and key == "tls_domain": + settings["tls_domain"] = value.strip('"').strip("'") + elif section == "censorship" and key == "mask_port": + try: + settings["mask_port"] = int(value) + except ValueError: + settings["mask_port"] = 0 + elif section == "network" and key == "dns_overrides": + settings["dns_overrides"] = re.findall(r'"([^"]+)"', value) + return settings + + +def load_shared443_config() -> dict[str, Any]: + raw = load_json(SHARED_443_CONFIG, {}) or {} + if not isinstance(raw, dict): + return {} + routes = raw.get("xray_routes") if isinstance(raw.get("xray_routes"), list) else [] + clean_routes = [] + for item in routes: + if not isinstance(item, dict): + continue + public = str(item.get("public") or item.get("domain") or "").strip() + target = str(item.get("target") or "").strip() + if public and target: + clean_routes.append({"public": public, "target": target}) + return { + "enabled": bool(raw.get("enabled")), + "dispatcher": str(raw.get("dispatcher") or "nginx-stream"), + "public_port": _int_value(raw.get("public_port") or 443) or 443, + "telemt_target": str(raw.get("telemt_target") or "127.0.0.1:7443"), + "site_target": str(raw.get("site_target") or ""), + "xray_routes": clean_routes, + "updated_at": str(raw.get("updated_at") or ""), + } + + +def listener_for_target(target: str) -> dict[str, Any] | None: + try: + port = int(target.rsplit(":", 1)[-1]) + except ValueError: + return None + listeners, _ = collect_port_listeners(port) + return listeners[0] if listeners else None + + +def routed_behind_443() -> list[dict[str, Any]]: + config = load_json(GOTELEGRAM_CONFIG, {}) or {} + mode = str(config.get("mode") or "") + domain = str(config.get("domain") or "") + settings = read_telemt_edge_settings() + shared = load_shared443_config() + mask_port = int(settings.get("mask_port") or 0) + tls_domain = str(settings.get("tls_domain") or domain) + routes: list[dict[str, Any]] = [] + if shared.get("enabled"): + telemt_target = str(shared.get("telemt_target") or "127.0.0.1:7443") + telemt_listener = listener_for_target(telemt_target) + routes.append({ + "role": "mtproxy", + "proto": "MTProxy", + "public": f"{domain or tls_domain or 'default'}:443", + "target": telemt_target, + "process": (telemt_listener or {}).get("process") or "telemt", + "pid": (telemt_listener or {}).get("pid") or "", + "status": service_status("telemt"), + "via": "nginx stream ssl_preread", + "tls_domain": tls_domain, + "details": ["default -> telemt"] if not shared.get("xray_routes") else [], + }) + for item in shared.get("xray_routes", []): + target = item.get("target", "") + listener = listener_for_target(target) + public = item.get("public", "") + if public and ":" not in public: + public = f"{public}:443" + routes.append({ + "role": "xray", + "proto": "VLESS", + "public": public or "xray:443", + "target": target, + "process": (listener or {}).get("process") or "xray", + "pid": (listener or {}).get("pid") or "", + "status": "running" if listener else "not_installed", + "via": "nginx stream ssl_preread", + "tls_domain": public.split(":", 1)[0] if public else "", + "details": [], + }) + if mode == "pro" and domain and mask_port and mask_port != 443: + internal, _ = collect_port_listeners(mask_port) + site_listener = next((item for item in internal if item.get("role") == "site"), None) + routes.append({ + "role": "site", + "proto": "HTTPS", + "public": f"{domain}:443", + "target": f"127.0.0.1:{mask_port}", + "process": (site_listener or {}).get("process") or "nginx", + "pid": (site_listener or {}).get("pid") or "", + "status": service_status("nginx"), + "via": "telemt dns_overrides", + "tls_domain": tls_domain, + "details": settings.get("dns_overrides") or [], + }) + return routes + + +def port_443_status() -> dict[str, Any]: + listeners, errors = collect_port_listeners(443) + shared = load_shared443_config() + if shared.get("enabled"): + for item in listeners: + if item.get("role") == "site" and "nginx" in str(item.get("process", "")).lower(): + item["role"] = "edge" + item["details"] = "nginx stream ssl_preread" + return { + "checked_at": int(time.time()), + "configured_port": read_telemt_port(), + "listeners": listeners, + "routes": routed_behind_443(), + "shared_443": shared, + "ok": not errors, + "error": "; ".join(errors[:2]), + } + + +def wait_tcp_port(port: int, timeout: int = 90) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if service_status("telemt") not in {"running", "activating"}: + return False + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.6): + return True + except OSError: + time.sleep(1) + return False + + +def public_ip() -> str: + code, stdout, _ = run(["curl", "-s", "-4", "--max-time", "3", "https://api.ipify.org"], timeout=5) + ip = stdout.strip() + if code == 0 and re.match(r"^\d{1,3}(\.\d{1,3}){3}$", ip): + return ip + code, stdout, _ = run(["hostname", "-I"], timeout=3) + return stdout.split()[0] if code == 0 and stdout.split() else "0.0.0.0" + + +def proxy_link(secret: str) -> str: + config = load_json(GOTELEGRAM_CONFIG, {}) or {} + mode = str(config.get("mode", "lite")) + port = int(config.get("port", 443) or 443) + domain = str(config.get("domain", "") or "") + mask_host = str(config.get("mask_host", "") or "") + + if mode == "pro" and domain: + host_hex = domain.encode().hex() + return f"tg://proxy?server={domain}&port={port}&secret=ee{secret}{host_hex}" + + server = public_ip() + if mask_host: + host_hex = mask_host.encode().hex() + return f"tg://proxy?server={server}&port={port}&secret=ee{secret}{host_hex}" + return f"tg://proxy?server={server}&port={port}&secret={secret}" + + +def telemt_api(path: str) -> Any: + try: + with urllib.request.urlopen(f"http://127.0.0.1:9091{path}", timeout=1.8) as resp: + payload = resp.read(256 * 1024) + return json.loads(payload.decode("utf-8")) + except Exception: + return None + + +def site_status(config: dict[str, Any] | None = None) -> dict[str, Any]: + config = config or load_json(GOTELEGRAM_CONFIG, {}) or {} + host = str(config.get("domain") or "").strip() + if not host: + return {"host": "", "url": "", "http_code": 0, "ok": False, "checked": False, "error": "domain_missing"} + if not re.match(r"^[A-Za-z0-9.-]{1,253}$", host) or ".." in host or host.startswith(".") or host.endswith("."): + return {"host": host, "url": "", "http_code": 0, "ok": False, "checked": False, "error": "invalid_domain"} + url = f"https://{host}/" + code, stdout, stderr = run(["curl", "-k", "-L", "-sS", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", "8", url], timeout=10) + raw_code = stdout.strip() + try: + http_code = int(raw_code) + except ValueError: + http_code = 0 + return { + "host": host, + "url": url, + "http_code": http_code, + "ok": code == 0 and http_code == 200, + "checked": True, + "error": "" if code == 0 else (stderr.strip() or f"curl exit {code}"), + "checked_at": int(time.time()), + } + + +def load_stats_history(limit: int | None = 240) -> list[dict[str, int]]: + if not HISTORY_FILE.exists(): + return [] + rows: list[dict[str, int]] = [] + try: + with HISTORY_FILE.open("r", encoding="utf-8", newline="") as fh: + for row in csv.DictReader(fh): + try: + rows.append({ + "epoch": int(row.get("epoch") or 0), + "proxy_bytes": int(row.get("proxy_bytes") or 0), + "site_bytes": int(row.get("site_bytes") or 0), + }) + except ValueError: + continue + except OSError: + return [] + if limit: + rows = rows[-limit:] + previous = None + enriched: list[dict[str, int]] = [] + for row in rows: + item = dict(row) + if previous: + item["proxy_delta"] = max(0, row["proxy_bytes"] - previous["proxy_bytes"]) + item["site_delta"] = max(0, row["site_bytes"] - previous["site_bytes"]) + else: + item["proxy_delta"] = 0 + item["site_delta"] = 0 + enriched.append(item) + previous = row + return enriched + + +def _int_value(value: Any) -> int: + try: + return int(value or 0) + except (TypeError, ValueError): + return 0 + + +def load_user_stats_history(name: str | None = None, limit: int | None = 240) -> list[dict[str, Any]]: + if not USER_HISTORY_FILE.exists(): + return [] + rows: list[dict[str, Any]] = [] + try: + with USER_HISTORY_FILE.open("r", encoding="utf-8", newline="") as fh: + for row in csv.DictReader(fh): + user = str(row.get("user") or "").strip() + if name is not None and user != name: + continue + if not USER_RE.match(user): + continue + rows.append({ + "epoch": _int_value(row.get("epoch")), + "user": user, + "total_octets": _int_value(row.get("total_octets")), + "current_connections": _int_value(row.get("current_connections")), + "active_unique_ips": _int_value(row.get("active_unique_ips")), + "recent_unique_ips": _int_value(row.get("recent_unique_ips")), + }) + except OSError: + return [] + rows.sort(key=lambda item: (item["user"], item["epoch"])) + if limit and name is not None: + rows = rows[-limit:] + + previous_by_user: dict[str, dict[str, Any]] = {} + enriched: list[dict[str, Any]] = [] + for row in rows: + item = dict(row) + previous = previous_by_user.get(row["user"]) + item["total_delta"] = max(0, row["total_octets"] - previous["total_octets"]) if previous else 0 + enriched.append(item) + previous_by_user[row["user"]] = row + if limit and name is None: + enriched = enriched[-limit:] + return enriched + + +def latest_user_stats() -> dict[str, dict[str, Any]]: + latest: dict[str, dict[str, Any]] = {} + if not USER_HISTORY_FILE.exists(): + return latest + try: + with USER_HISTORY_FILE.open("r", encoding="utf-8", newline="") as fh: + for row in csv.DictReader(fh): + user = str(row.get("user") or "").strip() + if not USER_RE.match(user): + continue + item = { + "epoch": _int_value(row.get("epoch")), + "user": user, + "total_octets": _int_value(row.get("total_octets")), + "current_connections": _int_value(row.get("current_connections")), + "active_unique_ips": _int_value(row.get("active_unique_ips")), + "recent_unique_ips": _int_value(row.get("recent_unique_ips")), + } + if item["epoch"] >= latest.get(user, {}).get("epoch", 0): + latest[user] = item + except OSError: + return {} + return latest + + +def runtime_user_traffic(name: str, enabled: bool = True) -> dict[str, Any]: + if not enabled: + return {"ok": False, "enabled": False, "total_octets": 0, "current_connections": 0, "active_unique_ips": 0, "recent_unique_ips": 0} + payload = telemt_api(f"/v1/users/{urllib.parse.quote(name, safe='')}") + data = payload.get("data", payload) if isinstance(payload, dict) else {} + if not isinstance(data, dict): + data = {} + return { + "ok": bool(payload), + "enabled": True, + "total_octets": _int_value(data.get("total_octets")), + "current_connections": _int_value(data.get("current_connections")), + "active_unique_ips": _int_value(data.get("active_unique_ips")), + "recent_unique_ips": _int_value(data.get("recent_unique_ips")), + "in_runtime": bool(data.get("in_runtime")) if data else False, + } + + +def current_user_traffic_snapshot( + name: str, + enabled: bool, + history_snapshot: dict[str, Any] | None = None, + now: int | None = None, +) -> dict[str, Any]: + """Return live counters for key cards, preserving only total bytes from history. + + History rows are minute snapshots. They are useful for charts, but stale + connection/IP values make the keys list look like users are still online. + """ + history_snapshot = history_snapshot or {} + fallback = { + "epoch": _int_value(history_snapshot.get("epoch")), + "total_octets": _int_value(history_snapshot.get("total_octets")), + "current_connections": 0, + "active_unique_ips": 0, + "recent_unique_ips": 0, + } + if not enabled: + return fallback + runtime = runtime_user_traffic(name, enabled) + if not runtime.get("ok"): + return fallback + return { + "epoch": _int_value(now if now is not None else time.time()), + "total_octets": _int_value(runtime.get("total_octets")), + "current_connections": _int_value(runtime.get("current_connections")), + "active_unique_ips": _int_value(runtime.get("active_unique_ips")), + "recent_unique_ips": _int_value(runtime.get("recent_unique_ips")), + } + + +def history_limit_for_range(range_key: str) -> int: + return { + "15m": 180, + "1h": 240, + "24h": 1800, + "month": 50000, + }.get(range_key, 240) + + +def normalize_range(range_key: str) -> str: + return range_key if range_key in TRAFFIC_WINDOWS else "1h" + + +def filter_history_by_range(rows: list[dict[str, int]], range_key: str) -> list[dict[str, int]]: + if not rows: + return [] + seconds = TRAFFIC_WINDOWS[normalize_range(range_key)] + latest = max(row.get("epoch", 0) for row in rows) + cutoff = latest - seconds + return [row for row in rows if row.get("epoch", 0) >= cutoff] + + +def traffic_interval_summaries(rows: list[dict[str, int]]) -> list[dict[str, Any]]: + if not rows: + return [ + {"range": key, "points": 0, "from": 0, "to": 0, "proxy_delta": 0, "site_delta": 0, "proxy_total": 0, "site_total": 0} + for key in TRAFFIC_WINDOWS + ] + latest = max(row.get("epoch", 0) for row in rows) + summaries = [] + for key, seconds in TRAFFIC_WINDOWS.items(): + window = [row for row in rows if row.get("epoch", 0) >= latest - seconds] + if not window: + summaries.append({"range": key, "points": 0, "from": 0, "to": latest, "proxy_delta": 0, "site_delta": 0, "proxy_total": 0, "site_total": 0}) + continue + first = window[0] + last = window[-1] + summaries.append({ + "range": key, + "points": len(window), + "from": first.get("epoch", 0), + "to": last.get("epoch", 0), + "proxy_delta": sum(max(0, int(item.get("proxy_delta", 0))) for item in window), + "site_delta": sum(max(0, int(item.get("site_delta", 0))) for item in window), + "proxy_total": int(last.get("proxy_bytes", 0)), + "site_total": int(last.get("site_bytes", 0)), + }) + return summaries + + +def user_traffic_interval_summaries(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + if not rows: + return [ + {"range": key, "points": 0, "from": 0, "to": 0, "total_delta": 0, "total_octets": 0} + for key in TRAFFIC_WINDOWS + ] + latest = max(row.get("epoch", 0) for row in rows) + summaries = [] + for key, seconds in TRAFFIC_WINDOWS.items(): + window = [row for row in rows if row.get("epoch", 0) >= latest - seconds] + if not window: + summaries.append({"range": key, "points": 0, "from": 0, "to": latest, "total_delta": 0, "total_octets": 0}) + continue + first = window[0] + last = window[-1] + summaries.append({ + "range": key, + "points": len(window), + "from": first.get("epoch", 0), + "to": last.get("epoch", 0), + "total_delta": sum(max(0, int(item.get("total_delta", 0))) for item in window), + "total_octets": int(last.get("total_octets", 0)), + }) + return summaries + + +def count_history_rows() -> int: + if not HISTORY_FILE.exists(): + return 0 + try: + with HISTORY_FILE.open("r", encoding="utf-8", errors="ignore") as fh: + return sum(1 for line in fh if line and line[0].isdigit()) + except OSError: + return 0 + + +def count_user_history_rows(name: str | None = None) -> int: + if not USER_HISTORY_FILE.exists(): + return 0 + try: + with USER_HISTORY_FILE.open("r", encoding="utf-8", errors="ignore") as fh: + if name is None: + return sum(1 for line in fh if line and line[0].isdigit()) + return sum(1 for line in fh if line.startswith(tuple(str(d) for d in range(10))) and f",{name}," in line) + except OSError: + return 0 + + +def stats_status(current: dict[str, Any] | None = None, history: list[dict[str, int]] | None = None) -> dict[str, Any]: + current = current if current is not None else (load_json(CURRENT_STATS, {}) or {}) + history = history if history is not None else load_stats_history(limit=2) + service = service_status("gotelegram-stats") + now = int(time.time()) + ts = int(current.get("ts") or 0) if isinstance(current, dict) else 0 + age = max(0, now - ts) if ts else None + error = str(current.get("error") or "") if isinstance(current, dict) else "" + history_rows = count_history_rows() + if error: + health = "error" + elif service == "running" and current and age is not None and age <= 180: + health = "ok" + elif service == "running": + health = "stale" + elif service == "not_installed": + health = "not_installed" + else: + health = "stopped" + return { + "health": health, + "service": service, + "current_exists": CURRENT_STATS.exists(), + "history_exists": HISTORY_FILE.exists(), + "history_rows": history_rows, + "history_points": len(history or []), + "last_ts": ts, + "age_seconds": age, + "error": error, + } + + +def run_stats_action(action: str) -> tuple[bool, str, dict[str, Any]]: + if action == "repair": + body = ( + "source /opt/gotelegram/lib/common.sh; " + "source /opt/gotelegram/lib/i18n.sh; " + "source /opt/gotelegram/lib/stats.sh; " + "load_language \"$(detect_language 2>/dev/null || echo en)\"; " + "install_stats_collector; " + "stats_collect" + ) + timeout = 180 + else: + body = ( + "source /opt/gotelegram/lib/common.sh; " + "source /opt/gotelegram/lib/stats.sh; " + "stats_init >/dev/null 2>&1 || true; " + "stats_collect" + ) + timeout = 30 + code, stdout, stderr = run(["bash", "-lc", body], timeout=timeout) + message = (stdout.strip().splitlines()[-1:] or stderr.strip().splitlines()[-1:] or [""])[0] + current = load_json(CURRENT_STATS, {}) or {} + history = load_stats_history() + return code == 0, message, {"current": current, "history": history, "status": stats_status(current, history)} + + +def list_backups() -> list[dict[str, Any]]: + if not BACKUP_DIR.exists(): + return [] + items = [] + for path in sorted(BACKUP_DIR.glob("*.tar.gz*"), key=lambda p: p.stat().st_mtime, reverse=True): + if path.name.endswith(".sha256"): + continue + try: + st = path.stat() + except OSError: + continue + items.append({ + "name": path.name, + "path": str(path), + "size": st.st_size, + "mtime": int(st.st_mtime), + "encrypted": path.name.endswith(".enc"), + }) + return items[:30] + + +def backup_schedule_calendar(frequency: str) -> str | None: + calendars = { + "off": None, + "daily": "*-*-* 03:20:00", + "weekly": "Sun 03:20:00", + "monthly": "*-*-01 03:20:00", + } + if frequency not in calendars: + raise ValueError("unsupported backup schedule") + return calendars[frequency] + + +def backup_schedule_status() -> dict[str, Any]: + raw = load_json(BACKUP_SCHEDULE_FILE, {}) or {} + if not isinstance(raw, dict): + raw = {} + frequency = str(raw.get("frequency") or "off") + try: + calendar = backup_schedule_calendar(frequency) + except ValueError: + frequency = "off" + calendar = None + active_code, active, _ = run(["systemctl", "is-active", "gotelegram-backup.timer"], timeout=5) + enabled_code, enabled, _ = run(["systemctl", "is-enabled", "gotelegram-backup.timer"], timeout=5) + _, next_run, _ = run(["systemctl", "show", "gotelegram-backup.timer", "--property=NextElapseUSecRealtime", "--value"], timeout=5) + return { + "frequency": frequency, + "calendar": calendar, + "enabled": enabled_code == 0 and enabled.strip() == "enabled", + "active": active_code == 0 and active.strip() == "active", + "next": next_run.strip(), + "updated_at": raw.get("updated_at") or "", + } + + +def set_backup_schedule(frequency: str) -> tuple[bool, str, dict[str, Any]]: + backup_schedule_calendar(frequency) + script = ( + "source /opt/gotelegram/lib/common.sh; " + "source /opt/gotelegram/lib/i18n.sh; " + "source /opt/gotelegram/lib/backup.sh; " + "load_language \"$(detect_language 2>/dev/null || echo en)\"; " + f"set_backup_schedule {shlex.quote(frequency)}" + ) + code, stdout, stderr = run(["bash", "-lc", script], timeout=120) + message = (stdout.strip().splitlines()[-1:] or stderr.strip().splitlines()[-1:] or [""])[0] + return code == 0, message, backup_schedule_status() + + +def create_backup() -> tuple[bool, str]: + script = ( + "source /opt/gotelegram/lib/common.sh; " + "source /opt/gotelegram/lib/i18n.sh; " + "source /opt/gotelegram/lib/telemt.sh; " + "source /opt/gotelegram/lib/website.sh; " + "source /opt/gotelegram/lib/backup.sh; " + "load_language \"$(detect_language 2>/dev/null || echo en)\"; " + "create_backup \"\"; " + "cleanup_old_backups 30" + ) + code, stdout, stderr = run(["bash", "-lc", script], timeout=180) + text = (stdout.strip().splitlines()[-1:] or stderr.strip().splitlines()[-1:] or [""])[0] + return code == 0, text + + +def safe_backup_path(name: str) -> Path: + raw = str(name or "").strip() + if not raw or raw != os.path.basename(raw) or not BACKUP_NAME_RE.match(raw) or raw.endswith(".sha256"): + raise ValueError("invalid backup name") + candidate = (BACKUP_DIR / raw).resolve() + base = BACKUP_DIR.resolve() + if base != candidate.parent: + raise ValueError("invalid backup path") + if not candidate.exists(): + raise FileNotFoundError("backup not found") + return candidate + + +def launch_restore_backup(name: str, password: str = "") -> dict[str, Any]: + backup_path = safe_backup_path(name) + if backup_path.name.endswith(".enc") and not password: + raise ValueError("password required for encrypted backup") + BACKUP_RESTORE_LOG.parent.mkdir(parents=True, exist_ok=True) + quoted_path = shlex.quote(str(backup_path)) + quoted_password = shlex.quote(password) + quoted_log = shlex.quote(str(BACKUP_RESTORE_LOG)) + script = ( + "sleep 1; " + "source /opt/gotelegram/lib/common.sh; " + "source /opt/gotelegram/lib/i18n.sh; " + "source /opt/gotelegram/lib/telemt.sh; " + "source /opt/gotelegram/lib/website.sh; " + "source /opt/gotelegram/lib/backup.sh; " + "load_language \"$(detect_language 2>/dev/null || echo en)\"; " + "create_backup \"\" >/dev/null 2>&1 || true; " + f"restore_backup {quoted_path} {quoted_password} yes; " + "cleanup_old_backups 30" + ) + with BACKUP_RESTORE_LOG.open("ab") as log: + log.write(f"\n[{utc_now()}] restore requested for {backup_path.name}\n".encode("utf-8")) + subprocess.Popen( + ["bash", "-lc", f"{script} >> {quoted_log} 2>&1"], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + return {"name": backup_path.name, "started": True, "log": str(BACKUP_RESTORE_LOG)} + + +def user_qr_png(name: str) -> tuple[bytes, str]: + users = read_user_records() + record = users.get(name) + if not record: + raise FileNotFoundError("user not found") + link = proxy_link(str(record.get("secret", ""))) + code, image, error = run_bytes(["qrencode", "-t", "PNG", "-s", "8", "-m", "2", "-o", "-", link], timeout=8) + if code != 0 or not image: + raise RuntimeError(error.strip() or "qrencode is not installed") + return image, link + + +def read_log_payload(service: str) -> dict[str, Any]: + allowed = {"telemt", "nginx", "gotelegram-bot", "gotelegram-stats", "gotelegram-admin"} + if service not in allowed: + raise ValueError("unsupported service") + code, stdout, stderr = run(["journalctl", "-u", service, "-n", "180", "--no-pager", "-o", "short-iso"], timeout=10) + text = stdout if code == 0 else stderr + lines = text.splitlines() + if code == 0 and not lines: + text = f"No journal entries for {service}." + lines = [text] + return { + "service": service, + "ok": code == 0, + "exit_code": code, + "line_count": len(lines), + "text": text, + } + + +def user_payload( + name: str, + secret: str, + enabled: bool = True, + max_unique_ips: int = 0, + include_runtime: bool = False, + traffic_snapshot: dict[str, Any] | None = None, +) -> dict[str, Any]: + item: dict[str, Any] = { + "name": name, + "secret": secret, + "link": proxy_link(secret), + "main": name == "main", + "enabled": bool(enabled), + "max_unique_ips": _int_value(max_unique_ips), + } + if traffic_snapshot: + item["traffic"] = { + "epoch": traffic_snapshot.get("epoch", 0), + "total_octets": traffic_snapshot.get("total_octets", 0), + "current_connections": traffic_snapshot.get("current_connections", 0), + "active_unique_ips": traffic_snapshot.get("active_unique_ips", 0), + "recent_unique_ips": traffic_snapshot.get("recent_unique_ips", 0), + } + if include_runtime and enabled: + item["runtime"] = telemt_api(f"/v1/users/{urllib.parse.quote(name, safe='')}") + return item + + +def overview_payload() -> dict[str, Any]: + config = load_json(GOTELEGRAM_CONFIG, {}) or {} + language = read_language(config) + users = read_user_records() + current = load_json(CURRENT_STATS, {}) or {} + history = load_stats_history() + summary = telemt_api("/v1/stats/summary") + services = { + "telemt": service_status("telemt"), + "nginx": service_status("nginx"), + "bot": service_status("gotelegram-bot"), + "stats": service_status("gotelegram-stats"), + "admin": service_status("gotelegram-admin"), + } + return { + "version": VERSION, + "time": utc_now(), + "language": language, + "admin_bind": {"host": HOST, "port": PORT}, + "config": public_config(config), + "site_status": site_status(config), + "users_count": len(users), + "services": services, + "port_443": port_443_status(), + "stats_current": current, + "stats_history": history, + "stats_status": stats_status(current, history), + "runtime_summary": summary, + "backups": list_backups(), + "backup_schedule": backup_schedule_status(), + } + + +class AdminHandler(BaseHTTPRequestHandler): + server_version = "goTelegramProAdmin/2.5.0" + + def log_message(self, fmt: str, *args: Any) -> None: + print("%s - %s" % (self.address_string(), fmt % args)) + + def send_json(self, payload: Any, status: int = 200) -> None: + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def send_bytes(self, body: bytes, content_type: str, status: int = 200) -> None: + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def send_error_json(self, status: int, message: str) -> None: + self.send_json({"ok": False, "error": message}, status) + + def read_json_body(self) -> Any: + length = int(self.headers.get("Content-Length", "0") or 0) + if length > 1024 * 1024: + raise ValueError("request body too large") + if length <= 0: + return {} + return json.loads(self.rfile.read(length).decode("utf-8")) + + def require_write_guard(self) -> bool: + if self.command in {"POST", "PUT", "PATCH", "DELETE"} and self.headers.get("X-GoTelegram-Admin") != "1": + self.send_error_json(403, "missing write guard") + return False + return True + + def route_get_api(self, parsed: urllib.parse.ParseResult) -> None: + path = parsed.path + if path == "/api/overview": + self.send_json({"ok": True, "data": overview_payload()}) + elif path == "/api/users": + users = read_user_records() + latest = latest_user_stats() + items = [] + for name in sorted(users, key=lambda item: (item != "main", item)): + record = users[name] + items.append(user_payload( + name, + record["secret"], + record["enabled"], + record.get("max_unique_ips", 0), + traffic_snapshot=current_user_traffic_snapshot(name, record["enabled"], latest.get(name)), + )) + self.send_json({"ok": True, "data": items}) + elif path.startswith("/api/users/") and path.endswith("/qr"): + name = urllib.parse.unquote(path[len("/api/users/"):-len("/qr")]) + try: + png, link = user_qr_png(name) + except FileNotFoundError: + self.send_error_json(404, "user not found") + return + except Exception as exc: + self.send_error_json(503, str(exc)) + return + self.send_response(200) + self.send_header("Content-Type", "image/png") + self.send_header("Cache-Control", "no-store") + self.send_header("X-Proxy-Link", urllib.parse.quote(link, safe="")) + self.send_header("Content-Length", str(len(png))) + self.end_headers() + self.wfile.write(png) + elif path.startswith("/api/users/") and path.endswith("/traffic"): + name = urllib.parse.unquote(path[len("/api/users/"):-len("/traffic")]) + users = read_user_records() + if name not in users: + self.send_error_json(404, "user not found") + return + qs = urllib.parse.parse_qs(parsed.query) + range_key = normalize_range(qs.get("range", ["1h"])[0]) + all_history = load_user_stats_history(name, limit=history_limit_for_range("month")) + history = filter_history_by_range(all_history[-history_limit_for_range(range_key):], range_key) + current = runtime_user_traffic(name, bool(users[name].get("enabled"))) + self.send_json({ + "ok": True, + "data": { + "name": name, + "range": range_key, + "current": current, + "history": history, + "summary_rows": user_traffic_interval_summaries(all_history), + "status": { + "history_exists": USER_HISTORY_FILE.exists(), + "history_rows": count_user_history_rows(name), + "history_points": len(history), + "last_ts": history[-1]["epoch"] if history else 0, + "runtime_ok": current.get("ok", False), + }, + }, + }) + elif path.startswith("/api/users/"): + name = urllib.parse.unquote(path[len("/api/users/"):]) + users = read_user_records() + if name not in users: + self.send_error_json(404, "user not found") + return + record = users[name] + self.send_json({"ok": True, "data": user_payload( + name, + record["secret"], + record["enabled"], + record.get("max_unique_ips", 0), + include_runtime=True, + traffic_snapshot=current_user_traffic_snapshot(name, record["enabled"], latest_user_stats().get(name)), + )}) + elif path == "/api/backups": + self.send_json({"ok": True, "data": list_backups()}) + elif path == "/api/backups/schedule": + self.send_json({"ok": True, "data": backup_schedule_status()}) + elif path == "/api/stats": + qs = urllib.parse.parse_qs(parsed.query) + range_key = normalize_range(qs.get("range", ["1h"])[0]) + current = load_json(CURRENT_STATS, {}) or {} + all_history = load_stats_history(limit=history_limit_for_range("month")) + history = filter_history_by_range(all_history[-history_limit_for_range(range_key):], range_key) + self.send_json({ + "ok": True, + "data": { + "range": range_key, + "current": current, + "history": history, + "summary_rows": traffic_interval_summaries(all_history), + "status": stats_status(current, history), + }, + }) + elif path == "/api/site/check": + self.send_json({"ok": True, "data": site_status()}) + elif path == "/api/logs": + qs = urllib.parse.parse_qs(parsed.query) + service = qs.get("service", ["telemt"])[0] + try: + payload = read_log_payload(service) + except ValueError: + self.send_error_json(400, "unsupported service") + return + self.send_json({"ok": True, "data": payload}) + else: + self.send_error_json(404, "not found") + + def route_post_api(self, parsed: urllib.parse.ParseResult) -> None: + if not self.require_write_guard(): + return + path = parsed.path + try: + body = self.read_json_body() + except Exception as exc: + self.send_error_json(400, str(exc)) + return + + if path == "/api/users": + name = str(body.get("name", "")).strip() + if not USER_RE.match(name): + self.send_error_json(400, "invalid user name") + return + try: + with FileLock(USER_LOCK_FILE): + records = read_user_records() + if name in records: + self.send_error_json(409, "user already exists") + return + users = read_telemt_users() + seed = f"{name}:{time.time()}:{secrets.token_hex(32)}".encode() + secret = hashlib.sha256(seed).hexdigest()[:32] + users[name] = secret + write_telemt_users(users) + except Exception as exc: + self.send_error_json(500, f"failed to save config: {exc}") + return + restart_requested = request_service_restart("telemt") + self.send_json({"ok": True, "data": user_payload(name, secret, True, 0), "restart": {"mode": "async", "requested": restart_requested}}) + elif path.startswith("/api/users/") and path.endswith("/max-ips"): + name = urllib.parse.unquote(path[len("/api/users/"):-len("/max-ips")]) + try: + limit = normalize_max_unique_ips(body.get("max_unique_ips")) + except ValueError as exc: + self.send_error_json(400, str(exc)) + return + try: + with FileLock(USER_LOCK_FILE): + records = read_user_records() + if name not in records: + self.send_error_json(404, "user not found") + return + limits = read_user_max_unique_ips() + if limit > 0: + limits[name] = limit + else: + limits.pop(name, None) + write_user_max_unique_ips(limits) + record = read_user_records()[name] + except Exception as exc: + self.send_error_json(500, f"failed to save config: {exc}") + return + restart_requested = request_service_restart("telemt") + self.send_json({"ok": True, "data": user_payload( + name, + record["secret"], + record["enabled"], + record.get("max_unique_ips", 0), + traffic_snapshot=current_user_traffic_snapshot(name, record["enabled"], latest_user_stats().get(name)), + ), "restart": {"mode": "async", "requested": restart_requested}}) + elif path.startswith("/api/users/") and path.endswith("/enabled"): + name = urllib.parse.unquote(path[len("/api/users/"):-len("/enabled")]) + if name == "main": + self.send_error_json(400, "main user cannot be disabled") + return + enabled = bool(body.get("enabled")) + try: + with FileLock(USER_LOCK_FILE): + active = read_telemt_users() + disabled = read_disabled_users() + records = read_user_records() + if name not in records: + self.send_error_json(404, "user not found") + return + if enabled: + secret = disabled.pop(name, records[name]["secret"]) + active[name] = secret + else: + secret = active.pop(name, records[name]["secret"]) + disabled[name] = secret + if enabled: + write_telemt_users(active) + write_disabled_users(disabled) + else: + write_disabled_users(disabled) + write_telemt_users(active) + except Exception as exc: + self.send_error_json(500, f"failed to save config: {exc}") + return + restart_requested = request_service_restart("telemt") + self.send_json({"ok": True, "data": user_payload( + name, + secret, + enabled, + records[name].get("max_unique_ips", 0), + traffic_snapshot=current_user_traffic_snapshot(name, enabled, latest_user_stats().get(name)), + ), "restart": {"mode": "async", "requested": restart_requested}}) + elif path == "/api/backups": + ok, result = create_backup() + self.send_json({"ok": ok, "data": {"path": result, "backups": list_backups()}}, 200 if ok else 500) + elif path == "/api/backups/schedule": + try: + frequency = str(body.get("frequency") or "off").strip().lower() + ok, message, status = set_backup_schedule(frequency) + except ValueError as exc: + self.send_error_json(400, str(exc)) + return + self.send_json({"ok": ok, "data": {"message": message, "schedule": status}}, 200 if ok else 500) + elif path == "/api/backups/restore": + try: + payload = launch_restore_backup(str(body.get("name") or ""), str(body.get("password") or "")) + except FileNotFoundError: + self.send_error_json(404, "backup not found") + return + except ValueError as exc: + self.send_error_json(400, str(exc)) + return + except Exception as exc: + self.send_error_json(500, str(exc)) + return + self.send_json({"ok": True, "data": payload}, 202) + elif path == "/api/stats/collect": + ok, message, payload = run_stats_action("collect") + payload["message"] = message + self.send_json({"ok": ok, "data": payload}, 200 if ok else 500) + elif path == "/api/stats/repair": + ok, message, payload = run_stats_action("repair") + payload["message"] = message + self.send_json({"ok": ok, "data": payload}, 200 if ok else 500) + elif path == "/api/settings/language": + try: + lang_payload = write_language(str(body.get("language", ""))) + except Exception as exc: + self.send_error_json(400, str(exc)) + return + self.send_json({"ok": True, "data": lang_payload}) + elif path.startswith("/api/services/") and path.endswith("/restart"): + service = path[len("/api/services/"):-len("/restart")] + allowed = {"telemt", "nginx", "gotelegram-bot", "gotelegram-stats"} + if service not in allowed: + self.send_error_json(400, "unsupported service") + return + ok = restart_service(service) + self.send_json({"ok": ok, "status": service_status(service)}, 200 if ok else 500) + else: + self.send_error_json(404, "not found") + + def route_delete_api(self, parsed: urllib.parse.ParseResult) -> None: + if not self.require_write_guard(): + return + path = parsed.path + if not path.startswith("/api/users/"): + self.send_error_json(404, "not found") + return + name = urllib.parse.unquote(path[len("/api/users/"):]) + if name == "main": + self.send_error_json(400, "main user cannot be deleted") + return + try: + with FileLock(USER_LOCK_FILE): + active = read_telemt_users() + disabled = read_disabled_users() + records = read_user_records() + if name not in records: + self.send_error_json(404, "user not found") + return + active.pop(name, None) + disabled.pop(name, None) + limits = read_user_max_unique_ips() + limits.pop(name, None) + write_telemt_users(active) + write_disabled_users(disabled) + write_user_max_unique_ips(limits) + except Exception as exc: + self.send_error_json(500, f"failed to save config: {exc}") + return + restart_requested = request_service_restart("telemt") + self.send_json({"ok": True, "restart": {"mode": "async", "requested": restart_requested}}) + + def send_static(self, parsed: urllib.parse.ParseResult) -> None: + rel = parsed.path.lstrip("/") or "index.html" + if rel.startswith("api/") or ".." in rel.split("/"): + self.send_error(404) + return + path = STATIC_DIR / rel + if path.is_dir(): + path = path / "index.html" + if not path.exists(): + path = STATIC_DIR / "index.html" + try: + body = path.read_bytes() + except OSError: + self.send_error(404) + return + mime = mimetypes.guess_type(str(path))[0] or "application/octet-stream" + self.send_response(200) + self.send_header("Content-Type", mime) + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self) -> None: + parsed = urllib.parse.urlparse(self.path) + if parsed.path.startswith("/api/"): + self.route_get_api(parsed) + else: + self.send_static(parsed) + + def do_POST(self) -> None: + parsed = urllib.parse.urlparse(self.path) + if parsed.path.startswith("/api/"): + self.route_post_api(parsed) + else: + self.send_error(404) + + def do_DELETE(self) -> None: + parsed = urllib.parse.urlparse(self.path) + if parsed.path.startswith("/api/"): + self.route_delete_api(parsed) + else: + self.send_error(404) + + +def main() -> None: + if not STATIC_DIR.exists(): + raise SystemExit(f"static dir not found: {STATIC_DIR}") + httpd = ThreadingHTTPServer((HOST, PORT), AdminHandler) + print(f"goTelegram Pro admin listening on http://{HOST}:{PORT}") + httpd.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/admin-web/static/app.js b/admin-web/static/app.js new file mode 100644 index 0000000..a95b128 --- /dev/null +++ b/admin-web/static/app.js @@ -0,0 +1,1755 @@ +const $ = (sel) => document.querySelector(sel); +const $$ = (sel) => Array.from(document.querySelectorAll(sel)); + +const i18n = { + en: { + brandSubtitle: "Local Admin", + navDashboard: "Dashboard", + navTraffic: "Traffic", + navKeys: "Keys", + navBackups: "Backups", + navLogs: "Logs", + navSettings: "Settings", + refresh: "Refresh", + autoRefresh: "Auto refresh every 5 seconds", + autoRefreshOn: "Auto refresh is on", + autoRefreshOff: "Auto refresh is off", + autoRefreshOffShort: "off", + themeDark: "Dark", + themeLight: "Light", + metricMode: "Mode", + metricKeys: "Keys", + metricProxyTraffic: "Proxy traffic", + metricSiteTraffic: "Site traffic", + configuredUsers: "configured users", + packets: "packets", + servicesEyebrow: "Services", + servicesTitle: "Service health", + servicesHelp: "Systemd service status for telemt, nginx, the bot, the traffic collector and the local admin.", + runtimeEyebrow: "Runtime", + runtimeTitle: "telemt summary", + runtimeHelp: "Runtime data comes from the local telemt API and shows what the proxy engine sees right now.", + trafficEyebrow: "Traffic", + trafficTitle: "History", + keysEyebrow: "Access", + keysTitle: "User keys", + backupsEyebrow: "Snapshots", + backupsTitle: "Backups", + eventsEyebrow: "Events", + eventsTitle: "Activity", + logsEyebrow: "Journal", + logsTitle: "Logs", + settingsEyebrow: "Settings", + settingsTitle: "Panel preferences", + configEyebrow: "Config", + configTitle: "Installation state", + collector: "Collector", + lastPoint: "Last point", + historyRows: "History rows", + collectStats: "Update stats", + collectStatsHelp: "Run one traffic collection now.", + repairStats: "Restart collector", + repairStatsHelp: "Reinstall and restart the background service that writes traffic history.", + tableTime: "Time", + tablePeriod: "Period", + tableStatus: "Status", + tableProxyDelta: "Proxy delta", + tableSiteDelta: "Site delta", + tableProxyTotal: "Proxy total", + tableSiteTotal: "Site total", + tableUser: "User", + tableSecret: "Secret", + tableLink: "Link", + tableTraffic: "Traffic", + ipLimit: "IP limit", + ipLimitHint: "0 = unlimited", + saveIpLimit: "OK", + tableTrafficDelta: "Traffic delta", + tableTrafficTotal: "Total", + tableActions: "Actions", + userPlaceholder: "client-name", + addKey: "Add key", + copyLink: "Copy link", + copySecret: "Copy secret", + showQr: "QR", + delete: "Delete", + enabled: "Enabled", + disabled: "Disabled", + applying: "Applying...", + changesApplyInBackground: "Changes are being applied in the background", + disableKey: "Disable key", + enableKey: "Enable key", + main: "main", + createBackup: "Create backup", + restoreBackup: "Restore", + encryptedRestoreCli: "Encrypted backups are restored from CLI", + backupScheduleTitle: "Automatic backups", + backupScheduleLoading: "Loading schedule...", + backupIncludesTitle: "Backup contents", + backupIncludesText: "telemt config, goTelegram settings, keys, disabled keys, site, templates, SSL certificates, bot, admin panel and traffic history.", + scheduleOff: "Off", + scheduleDaily: "Daily", + scheduleWeekly: "Weekly", + scheduleMonthly: "Monthly", + scheduleSaved: "Schedule saved", + scheduleNext: "Next run: {value}", + scheduleDisabled: "Automatic backups are disabled", + backupRestoreStarted: "Restore started", + confirmRestoreBackup: "Restore backup", + loadLogs: "Load", + panelLanguage: "Panel language", + theme: "Theme", + bindAddress: "Bind address", + dashboard: "Dashboard", + noKeys: "No keys yet", + noBackups: "No backups yet", + noEvents: "No events yet", + noHistory: "No traffic history yet", + noTrafficForRange: "No data for this range yet", + noRuntime: "Runtime data is not available", + userTrafficEyebrow: "Per user", + userTrafficTitle: "User traffic", + selectUserTraffic: "Select a key to see its traffic history", + openStats: "Stats", + trafficTotal: "Total", + currentConnections: "Connections", + activeIps: "Active IPs", + recentIps: "Recent IPs", + trafficRuntimeUnavailable: "Runtime unavailable", + badConnections: "Bad connections", + connections: "Connections", + uptime: "Uptime", + users: "Users", + revision: "Revision", + healthOk: "OK", + healthError: "Error", + healthStale: "Stale", + healthStopped: "Stopped", + healthNotInstalled: "Not installed", + healthUnknown: "Unknown", + statusRunning: "running", + statusInactive: "inactive", + statusStopped: "stopped", + statusFailed: "failed", + statusNotInstalled: "not installed", + statusActivating: "activating", + statusDeactivating: "deactivating", + statusUnknown: "unknown", + statsMissing: "Collector is not running", + statsOk: "Collector is running", + statsStale: "Snapshot is stale", + statsError: "Collector error", + restart: "Restart", + copied: "Copied", + copyFailed: "Copy failed", + keyCreated: "Key created", + keyDeleted: "Key deleted", + backupCreated: "Backup created", + qrUnavailable: "QR code is unavailable", + serviceRestarted: "Service restarted", + statsRepaired: "Collector restarted", + statsCollected: "Statistics collected", + confirmDelete: "Delete key", + confirmRestart: "Restart", + invalidUser: "Use latin letters, digits, _, . or -", + loading: "Loading...", + never: "never", + lightTheme: "Light", + darkTheme: "Dark", + configMode: "Mode", + configDomain: "Domain", + configSiteStatus: "Site check", + configTemplate: "Template", + configVersion: "Version", + siteOk: "Site 200 OK", + siteHttp: "Site HTTP", + siteMissing: "Domain is not configured", + siteInvalid: "Invalid domain", + siteError: "Site check failed", + siteNotChecked: "Site check pending", + logsLines: "lines", + logsNoData: "No log lines", + languageSaved: "Language saved", + keyEnabled: "Key enabled", + keyDisabled: "Key disabled", + ipLimitSaved: "IP limit saved", + visualTitle: "Port 443 map", + visualText: "Shows the public 443 listener and services routed behind it, including the website on local nginx.", + port443Checked: "checked", + port443NoListeners: "No 443 listeners found", + port443Listeners: "listeners", + port443Routes: "routed", + port443Error: "Port check failed", + port443Public: "public", + port443Configured: "telemt: {port}", + port443PublicSection: "Public 443", + port443BehindSection: "Behind 443", + port443NoRoutes: "No routed services detected", + port443Via: "via {value}", + roleMtproxy: "MTProxy", + roleEdge: "443 Edge", + roleSite: "Website", + roleXray: "Xray / 3x-ui", + roleAmneziawg: "AmneziaWG", + roleOther: "Other", + range15m: "15 min", + range1h: "1 hour", + range24h: "24 hours", + rangeMonth: "Month", + viewChart: "Chart", + viewRows: "Rows", + chartMax: "max {value} per interval", + chartProxy: "proxy", + chartSite: "site", + encrypted: "encrypted", + ariaAdminSections: "Admin sections", + ariaMenu: "Open menu", + ariaLanguage: "Language", + ariaClose: "Close", + ariaTrafficHistory: "Traffic history", + ariaTrafficRange: "Traffic range", + ariaTrafficView: "Traffic view", + promoEyebrow: "Promo", + promoTitle: "Support goTelegram Pro", + promoHosting1: "Hosting #1", + promoHosting2: "Hosting #2", + promoTips: "Tips", + qrEyebrow: "QR import", + qrTitle: "Scan Telegram proxy", + pageDashboardTitle: "Dashboard", + pageDashboardKicker: "Local Admin", + pageTrafficTitle: "Traffic", + pageTrafficKicker: "Statistics", + pageKeysTitle: "Keys", + pageKeysKicker: "Access", + pageBackupsTitle: "Backups", + pageBackupsKicker: "Migration", + pageLogsTitle: "Logs", + pageLogsKicker: "Journal", + pageSettingsTitle: "Settings", + pageSettingsKicker: "Preferences", + }, + ru: { + brandSubtitle: "Локальная админка", + navDashboard: "Обзор", + navTraffic: "Трафик", + navKeys: "Ключи", + navBackups: "Бекапы", + navLogs: "Логи", + navSettings: "Настройки", + refresh: "Обновить", + autoRefresh: "Автообновление каждые 5 секунд", + autoRefreshOn: "Автообновление включено", + autoRefreshOff: "Автообновление выключено", + autoRefreshOffShort: "выкл", + themeDark: "Тёмная", + themeLight: "Светлая", + metricMode: "Режим", + metricKeys: "Ключи", + metricProxyTraffic: "Трафик прокси", + metricSiteTraffic: "Трафик сайта", + configuredUsers: "настроенных пользователей", + packets: "пакетов", + servicesEyebrow: "Сервисы", + servicesTitle: "Состояние служб", + servicesHelp: "Статус systemd-служб: telemt, nginx, бот, сборщик трафика и локальная админка.", + runtimeEyebrow: "Среда выполнения", + runtimeTitle: "Сводка telemt", + runtimeHelp: "Данные среды выполнения берутся из локального API telemt и показывают, что ядро прокси видит прямо сейчас.", + trafficEyebrow: "Трафик", + trafficTitle: "История", + keysEyebrow: "Доступ", + keysTitle: "Ключи пользователей", + backupsEyebrow: "Снимки", + backupsTitle: "Бекапы", + eventsEyebrow: "События", + eventsTitle: "Активность", + logsEyebrow: "Журнал", + logsTitle: "Логи", + settingsEyebrow: "Настройки", + settingsTitle: "Параметры панели", + configEyebrow: "Конфиг", + configTitle: "Состояние установки", + collector: "Сборщик", + lastPoint: "Последняя точка", + historyRows: "Строк истории", + collectStats: "Обновить статистику", + collectStatsHelp: "Запустить один сбор трафика прямо сейчас.", + repairStats: "Перезапустить сборщик", + repairStatsHelp: "Переустановить и перезапустить фоновую службу, которая пишет историю трафика.", + tableTime: "Время", + tablePeriod: "Период", + tableStatus: "Статус", + tableProxyDelta: "Прирост прокси", + tableSiteDelta: "Прирост сайта", + tableProxyTotal: "Всего прокси", + tableSiteTotal: "Всего по сайту", + tableUser: "Пользователь", + tableSecret: "Секрет", + tableLink: "Ссылка", + tableTraffic: "Трафик", + ipLimit: "Лимит IP", + ipLimitHint: "0 = безлимит", + saveIpLimit: "OK", + tableTrafficDelta: "Прирост трафика", + tableTrafficTotal: "Всего", + tableActions: "Действия", + userPlaceholder: "client-name", + addKey: "Добавить ключ", + copyLink: "Копировать ссылку", + copySecret: "Копировать секрет", + showQr: "QR", + delete: "Удалить", + enabled: "Включён", + disabled: "Отключён", + applying: "Применяется...", + changesApplyInBackground: "Изменения применяются в фоне", + disableKey: "Отключить ключ", + enableKey: "Включить ключ", + main: "основной", + createBackup: "Создать бекап", + restoreBackup: "Восстановить", + encryptedRestoreCli: "Зашифрованные бекапы восстанавливаются через CLI", + backupScheduleTitle: "Автобекапы", + backupScheduleLoading: "Загрузка расписания...", + backupIncludesTitle: "Что входит в бекап", + backupIncludesText: "конфиг telemt, настройки goTelegram, ключи, отключённые ключи, сайт, шаблоны, SSL-сертификаты, бот, админка и история трафика.", + scheduleOff: "Выкл", + scheduleDaily: "Каждый день", + scheduleWeekly: "Каждую неделю", + scheduleMonthly: "Каждый месяц", + scheduleSaved: "Расписание сохранено", + scheduleNext: "Следующий запуск: {value}", + scheduleDisabled: "Автобекапы отключены", + backupRestoreStarted: "Восстановление запущено", + confirmRestoreBackup: "Восстановить бекап", + loadLogs: "Загрузить", + panelLanguage: "Язык панели", + theme: "Тема", + bindAddress: "Адрес привязки", + dashboard: "Обзор", + noKeys: "Ключей пока нет", + noBackups: "Бекапов пока нет", + noEvents: "Событий пока нет", + noHistory: "Истории трафика пока нет", + noTrafficForRange: "За этот период данных пока нет", + noRuntime: "Данные среды выполнения недоступны", + userTrafficEyebrow: "По пользователю", + userTrafficTitle: "Трафик ключа", + selectUserTraffic: "Выберите ключ, чтобы увидеть историю трафика", + openStats: "Статистика", + trafficTotal: "Всего", + currentConnections: "Подключения", + activeIps: "Активные IP", + recentIps: "Недавние IP", + trafficRuntimeUnavailable: "Runtime недоступен", + badConnections: "Ошибочные подключения", + connections: "Подключения", + uptime: "Аптайм", + users: "Пользователи", + revision: "Ревизия", + healthOk: "OK", + healthError: "Ошибка", + healthStale: "Устарело", + healthStopped: "Остановлено", + healthNotInstalled: "Не установлен", + healthUnknown: "Неизвестно", + statusRunning: "работает", + statusInactive: "неактивен", + statusStopped: "остановлен", + statusFailed: "ошибка", + statusNotInstalled: "не установлен", + statusActivating: "запускается", + statusDeactivating: "останавливается", + statusUnknown: "неизвестно", + statsMissing: "Сборщик не запущен", + statsOk: "Сборщик работает", + statsStale: "Снимок устарел", + statsError: "Ошибка сборщика", + restart: "Перезапустить", + copied: "Скопировано", + copyFailed: "Не удалось скопировать", + keyCreated: "Ключ создан", + keyDeleted: "Ключ удалён", + backupCreated: "Бекап создан", + qrUnavailable: "QR-код недоступен", + serviceRestarted: "Сервис перезапущен", + statsRepaired: "Сборщик перезапущен", + statsCollected: "Статистика собрана", + confirmDelete: "Удалить ключ", + confirmRestart: "Перезапустить", + invalidUser: "Используйте латиницу, цифры, _, . или -", + loading: "Загрузка...", + never: "никогда", + lightTheme: "Светлая", + darkTheme: "Тёмная", + configMode: "Режим", + configDomain: "Домен", + configSiteStatus: "Проверка сайта", + configTemplate: "Шаблон", + configVersion: "Версия", + siteOk: "Сайт 200 OK", + siteHttp: "Сайт HTTP", + siteMissing: "Домен не настроен", + siteInvalid: "Некорректный домен", + siteError: "Проверка сайта не прошла", + siteNotChecked: "Проверка сайта ожидает", + logsLines: "строк", + logsNoData: "Строк логов нет", + languageSaved: "Язык сохранён", + keyEnabled: "Ключ включён", + keyDisabled: "Ключ отключён", + ipLimitSaved: "Лимит IP сохранён", + visualTitle: "Карта порта 443", + visualText: "Показывает публичного слушателя 443 и сервисы, которые живут за ним, включая сайт на локальном nginx.", + port443Checked: "проверено", + port443NoListeners: "Слушателей 443 не найдено", + port443Listeners: "слушателей", + port443Routes: "за 443", + port443Error: "Проверка порта не удалась", + port443Public: "публичный", + port443Configured: "telemt: {port}", + port443PublicSection: "Публичный 443", + port443BehindSection: "За портом 443", + port443NoRoutes: "Маршрутизируемых сервисов не найдено", + port443Via: "через {value}", + roleMtproxy: "MTProxy", + roleEdge: "443 Edge", + roleSite: "Сайт", + roleXray: "Xray / 3x-ui", + roleAmneziawg: "AmneziaWG", + roleOther: "Другое", + range15m: "15 мин", + range1h: "1 час", + range24h: "24 часа", + rangeMonth: "Месяц", + viewChart: "График", + viewRows: "Строки", + chartMax: "макс. {value} за интервал", + chartProxy: "прокси", + chartSite: "сайт", + encrypted: "зашифровано", + ariaAdminSections: "Разделы админки", + ariaMenu: "Открыть меню", + ariaLanguage: "Язык", + ariaClose: "Закрыть", + ariaTrafficHistory: "История трафика", + ariaTrafficRange: "Период трафика", + ariaTrafficView: "Вид трафика", + promoEyebrow: "Промо", + promoTitle: "Поддержать goTelegram Pro", + promoHosting1: "Хостинг #1", + promoHosting2: "Хостинг #2", + promoTips: "Чаевые", + qrEyebrow: "QR-импорт", + qrTitle: "Сканирование прокси Telegram", + pageDashboardTitle: "Обзор", + pageDashboardKicker: "Локальная админка", + pageTrafficTitle: "Трафик", + pageTrafficKicker: "Статистика", + pageKeysTitle: "Ключи", + pageKeysKicker: "Доступ", + pageBackupsTitle: "Бекапы", + pageBackupsKicker: "Переезд", + pageLogsTitle: "Логи", + pageLogsKicker: "Журнал", + pageSettingsTitle: "Настройки", + pageSettingsKicker: "Параметры", + }, +}; + +const state = { + overview: null, + stats: null, + users: [], + events: [], + lang: "en", + page: "dashboard", + theme: document.documentElement.dataset.theme || "light", + trafficRange: "1h", + trafficView: "chart", + trafficLoading: false, + userTrafficUser: "", + userTrafficRange: "1h", + userTrafficView: "chart", + userTraffic: null, + userTrafficLoading: false, + backupSchedule: null, + qrLink: "", + pendingUsers: new Set(), + refreshingAll: false, + autoRefreshEnabled: localStorage.getItem("gotelegram-auto-refresh") !== "0", +}; + +const t = (key) => (i18n[state.lang] && i18n[state.lang][key]) || i18n.en[key] || key; + +const trafficRanges = ["15m", "1h", "24h", "month"]; +const AUTO_REFRESH_MS = 5000; +let autoRefreshTimer = null; + +const fmtBytes = (value = 0) => { + const units = ["B", "KB", "MB", "GB", "TB"]; + let n = Number(value) || 0; + let i = 0; + while (n >= 1024 && i < units.length - 1) { + n /= 1024; + i += 1; + } + return `${n.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; +}; + +const fmtDate = (epoch) => { + if (!epoch) return t("never"); + return new Date(epoch * 1000).toLocaleString(state.lang === "ru" ? "ru-RU" : "en-US"); +}; + +const fmtDuration = (seconds = 0) => { + let value = Math.max(0, Math.floor(Number(seconds) || 0)); + const days = Math.floor(value / 86400); + value %= 86400; + const hours = Math.floor(value / 3600); + value %= 3600; + const minutes = Math.floor(value / 60); + if (days) return `${days}d ${hours}h`; + if (hours) return `${hours}h ${minutes}m`; + return `${minutes}m`; +}; + +const escapeHtml = (value) => String(value ?? "").replace(/[&<>"']/g, (ch) => ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", +})[ch]); + +const escapeAttr = (value) => escapeHtml(value).replace(/`/g, "`"); + +const toast = (message) => { + const el = $("#toast"); + el.textContent = message; + el.classList.add("show"); + clearTimeout(toast._timer); + toast._timer = setTimeout(() => el.classList.remove("show"), 2800); +}; + +const addEvent = (title, detail = "") => { + state.events.unshift({ title, detail, time: new Date() }); + state.events = state.events.slice(0, 10); + renderEvents(); +}; + +function updateAutoRefreshToggle() { + const button = $("#autoRefreshToggle"); + if (!button) return; + button.classList.toggle("active", state.autoRefreshEnabled); + button.setAttribute("aria-pressed", String(state.autoRefreshEnabled)); + button.title = state.autoRefreshEnabled ? t("autoRefreshOn") : t("autoRefreshOff"); + const label = button.querySelector(".auto-refresh-state"); + if (label) label.textContent = state.autoRefreshEnabled ? "5s" : t("autoRefreshOffShort"); +} + +function syncAutoRefreshTimer() { + if (autoRefreshTimer) { + clearInterval(autoRefreshTimer); + autoRefreshTimer = null; + } + if (!state.autoRefreshEnabled) return; + autoRefreshTimer = setInterval(() => { + refreshAll().catch((err) => toast(err.message)); + }, AUTO_REFRESH_MS); +} + +function setAutoRefresh(enabled) { + state.autoRefreshEnabled = Boolean(enabled); + localStorage.setItem("gotelegram-auto-refresh", state.autoRefreshEnabled ? "1" : "0"); + updateAutoRefreshToggle(); + syncAutoRefreshTimer(); +} + +async function api(path, options = {}) { + const headers = { + "Accept": "application/json", + "X-GoTelegram-Admin": "1", + ...(options.headers || {}), + }; + if (options.body && !headers["Content-Type"]) headers["Content-Type"] = "application/json"; + const res = await fetch(path, { ...options, headers, credentials: "same-origin" }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data.ok === false) throw new Error(data.error || `HTTP ${res.status}`); + return data.data ?? data; +} + +function applyI18n() { + document.documentElement.lang = state.lang; + $$("[data-i18n]").forEach((el) => { + el.textContent = t(el.dataset.i18n); + }); + $$("[data-i18n-placeholder]").forEach((el) => { + el.placeholder = t(el.dataset.i18nPlaceholder); + }); + $$("[data-i18n-title]").forEach((el) => { + el.title = t(el.dataset.i18nTitle); + }); + $$("[data-i18n-aria-label]").forEach((el) => { + el.setAttribute("aria-label", t(el.dataset.i18nAriaLabel)); + }); + $("#themeToggle").textContent = state.theme === "dark" ? t("themeLight") : t("themeDark"); + $("#languageSelect").value = state.lang; + $("#settingsLanguage").textContent = state.lang === "ru" ? "Русский" : "English"; + $("#settingsTheme").textContent = state.theme === "dark" ? t("darkTheme") : t("lightTheme"); + $("#visualTitle").textContent = t("visualTitle"); + $("#visualText").textContent = t("visualText"); + updateTrafficControls(); + updateUserTrafficControls(); + renderBackupSchedule(); + updatePageTitle(); + updateAutoRefreshToggle(); +} + +function setTheme(theme) { + state.theme = theme === "dark" ? "dark" : "light"; + document.documentElement.dataset.theme = state.theme; + localStorage.setItem("gotelegram-theme", state.theme); + applyI18n(); + if (state.overview) renderStats(); + if (state.userTraffic) renderUserTraffic(); +} + +async function setLanguage(lang) { + const previous = state.lang; + state.lang = lang === "ru" ? "ru" : "en"; + applyI18n(); + try { + const data = await api("/api/settings/language", { + method: "POST", + body: JSON.stringify({ language: state.lang }), + }); + state.lang = data.language === "ru" ? "ru" : "en"; + applyI18n(); + toast(t("languageSaved")); + await refreshAll(); + } catch (err) { + state.lang = previous; + applyI18n(); + toast(err.message); + } +} + +function setPage(page, push = true) { + const next = $(`[data-page="${page}"]`) ? page : "dashboard"; + state.page = next; + $$(".page-panel").forEach((panel) => panel.classList.toggle("active", panel.dataset.page === next)); + $$("[data-nav]").forEach((item) => item.classList.toggle("active", item.dataset.nav === next)); + $("#sidebar").classList.remove("open"); + updatePageTitle(); + if (push && location.hash !== `#${next}`) { + history.replaceState(null, "", `#${next}`); + } + requestAnimationFrame(() => { + window.scrollTo({ top: 0, behavior: push ? "smooth" : "auto" }); + }); + if (next === "traffic") { + refreshStats().catch((err) => toast(err.message)); + } else if (next === "keys") { + ensureUserTrafficSelection(); + renderUserTraffic(); + if (state.userTrafficUser) refreshUserTraffic().catch((err) => toast(err.message)); + } +} + +function updatePageTitle() { + const cap = state.page.charAt(0).toUpperCase() + state.page.slice(1); + $("#pageTitle").textContent = t(`page${cap}Title`); + $("#pageKicker").textContent = t(`page${cap}Kicker`); +} + +function updateLanguageFromOverview(data) { + const lang = String(data.language || data.config?.language || "en").toLowerCase(); + state.lang = lang === "ru" ? "ru" : "en"; + applyI18n(); +} + +function statusLabel(status) { + const key = `status${String(status || "unknown").replace(/(^|_)([a-z])/g, (_, __, ch) => ch.toUpperCase())}`; + const label = t(key); + return label === key ? (status || t("healthUnknown")) : label; +} + +function healthLabel(health) { + const labels = { + ok: t("healthOk"), + error: t("healthError"), + stale: t("healthStale"), + stopped: t("healthStopped"), + not_installed: t("healthNotInstalled"), + }; + return labels[health] || t("healthUnknown"); +} + +function renderServices(services = {}) { + const items = [ + { key: "telemt", label: "telemt", api: "telemt" }, + { key: "nginx", label: "nginx", api: "nginx" }, + { key: "bot", label: "bot", api: "gotelegram-bot" }, + { key: "stats", label: "stats", api: "gotelegram-stats" }, + { key: "admin", label: "admin", api: "gotelegram-admin" }, + ]; + $("#services").innerHTML = items.map((item) => { + const status = services[item.key] || "unknown"; + const disabled = item.key === "admin" || status === "not_installed"; + return `
+
+ ${escapeHtml(item.label)} + ${escapeHtml(statusLabel(status))} +
+ +
`; + }).join(""); +} + +function runtimeData() { + const raw = state.overview?.runtime_summary; + if (!raw || typeof raw !== "object") return null; + return raw.data && typeof raw.data === "object" ? raw.data : raw; +} + +function renderRuntime() { + const data = runtimeData(); + if (!data) { + $("#runtimeCards").innerHTML = `
${escapeHtml(t("noRuntime"))}
`; + $("#runtimeIssues").innerHTML = ""; + return; + } + const revision = String(data.revision || state.overview?.runtime_summary?.revision || "--"); + const cards = [ + [t("uptime"), fmtDuration(data.uptime_seconds)], + [t("connections"), data.connections_total ?? 0], + [t("badConnections"), data.connections_bad_total ?? 0], + [t("users"), data.configured_users ?? state.overview?.users_count ?? 0], + [t("revision"), revision.slice(0, 10)], + ]; + $("#runtimeCards").innerHTML = cards.map(([label, value]) => ` +
+ ${escapeHtml(label)} + ${escapeHtml(value)} +
+ `).join(""); + const bad = Array.isArray(data.connections_bad_by_class) ? data.connections_bad_by_class : []; + $("#runtimeIssues").innerHTML = bad.length ? bad.map((item) => ` +
+ ${escapeHtml(item.class || "unknown")} + ${escapeHtml(item.total ?? 0)} +
+ `).join("") : ""; +} + +function siteStatusText(site = {}) { + if (!site.host) return t("siteMissing"); + if (site.error === "invalid_domain") return t("siteInvalid"); + if (site.ok) return t("siteOk"); + if (site.checked && site.http_code) return `${t("siteHttp")} ${site.http_code}`; + if (site.error) return t("siteError"); + return t("siteNotChecked"); +} + +function siteStatusClass(site = {}) { + if (site.ok) return "ok"; + if (!site.host || !site.checked) return "warn"; + return "error"; +} + +function renderSiteStatus() { + const cfg = state.overview?.config || {}; + const site = state.overview?.site_status || {}; + $("#metricDomain").textContent = site.host || cfg.domain || cfg.mask_host || "--"; + const statusEl = $("#siteStatus"); + statusEl.textContent = siteStatusText(site); + statusEl.className = `metric-status ${siteStatusClass(site)}`; + statusEl.title = site.url || ""; +} + +function roleLabel(role) { + const key = `role${String(role || "other").replace(/(^|_)([a-z])/g, (_, __, ch) => ch.toUpperCase())}`; + const label = t(key); + return label === key ? t("roleOther") : label; +} + +function renderPort443(payload = {}) { + const listeners = Array.isArray(payload.listeners) ? payload.listeners : []; + const routes = Array.isArray(payload.routes) ? payload.routes : []; + const summary = $("#port443Summary"); + const list = $("#port443List"); + const configuredPort = Number(payload.configured_port) || 443; + $("#port443Number").textContent = "443"; + $("#port443Configured").textContent = configuredPort === 443 ? t("port443Public") : t("port443Configured").replace("{port}", configuredPort); + if (payload.error) { + summary.textContent = t("port443Error"); + summary.className = "port-status error"; + } else if (!listeners.length) { + summary.textContent = t("port443NoListeners"); + summary.className = "port-status warn"; + } else { + summary.textContent = `${listeners.length} ${t("port443Listeners")}${routes.length ? ` · ${routes.length} ${t("port443Routes")}` : ""}`; + summary.className = "port-status ok"; + } + const listenerHtml = listeners.length ? listeners.map((item) => { + const title = `${item.proto || ""} ${item.address || ""} · ${item.process || "unknown"}${item.pid ? ` · pid ${item.pid}` : ""}`; + return `
+
+ ${escapeHtml(roleLabel(item.role))} + ${escapeHtml(item.process || "unknown")}${item.pid ? ` · pid ${escapeHtml(item.pid)}` : ""} +
+ ${escapeHtml(item.proto || "--")} · ${escapeHtml(item.address || "--")} +
`; + }).join("") : `
${escapeHtml(payload.error || t("port443NoListeners"))}
`; + const routeHtml = routes.length ? routes.map((item) => { + const via = item.via ? t("port443Via").replace("{value}", item.via) : ""; + const title = `${item.public || ""} → ${item.target || ""} · ${item.process || ""}`; + return `
+
+ ${escapeHtml(roleLabel(item.role))} + ${escapeHtml(item.process || "unknown")}${item.status ? ` · ${escapeHtml(statusLabel(item.status))}` : ""} +
+ ${escapeHtml(item.public || "--")} → ${escapeHtml(item.target || "--")}${via ? ` · ${escapeHtml(via)}` : ""} +
`; + }).join("") : `
${escapeHtml(t("port443NoRoutes"))}
`; + list.innerHTML = ` +
${escapeHtml(t("port443PublicSection"))}
+ ${listenerHtml} +
${escapeHtml(t("port443BehindSection"))}
+ ${routeHtml} + `; +} + +function renderOverview() { + const data = state.overview; + if (!data) return; + const cfg = data.config || {}; + const stats = data.stats_current || {}; + const bind = data.admin_bind || {}; + $("#sidebarVersion").textContent = `v${data.version || "--"}`; + $("#sidebarBind").textContent = `${bind.host || "127.0.0.1"}:${bind.port || 1984}`; + $("#settingsBind").textContent = `${bind.host || "127.0.0.1"}:${bind.port || 1984}`; + $("#metricMode").textContent = cfg.mode || "--"; + renderSiteStatus(); + renderPort443(data.port_443 || {}); + $("#metricUsers").textContent = data.users_count ?? 0; + $("#metricProxyTraffic").textContent = fmtBytes(stats.proxy_bytes); + $("#metricProxyPackets").textContent = `${stats.proxy_pkts || 0} ${t("packets")}`; + $("#metricSiteTraffic").textContent = fmtBytes(stats.site_bytes); + $("#metricSitePackets").textContent = `${stats.site_pkts || 0} ${t("packets")}`; + $("#lastRefresh").textContent = fmtDate(Math.floor(Date.now() / 1000)); + renderServices(data.services || {}); + renderRuntime(); + renderStats(); + renderBackups(data.backups || []); + renderConfig(); +} + +function statsPayload() { + if (state.stats) return state.stats; + return { + current: state.overview?.stats_current || {}, + history: state.overview?.stats_history || [], + status: state.overview?.stats_status || {}, + summary_rows: [], + }; +} + +function updateTrafficControls() { + $$("[data-traffic-range]").forEach((btn) => { + btn.classList.toggle("active", btn.dataset.trafficRange === state.trafficRange); + }); + $$("[data-traffic-view]").forEach((btn) => { + btn.classList.toggle("active", btn.dataset.trafficView === state.trafficView); + }); +} + +function updateUserTrafficControls() { + $$("[data-user-traffic-range]").forEach((btn) => { + btn.classList.toggle("active", btn.dataset.userTrafficRange === state.userTrafficRange); + }); + $$("[data-user-traffic-view]").forEach((btn) => { + btn.classList.toggle("active", btn.dataset.userTrafficView === state.userTrafficView); + }); +} + +function trafficRangeLabel(range) { + const labels = { + "15m": t("range15m"), + "1h": t("range1h"), + "24h": t("range24h"), + month: t("rangeMonth"), + }; + return labels[range] || range; +} + +function rangeSeconds(range) { + return { + "15m": 15 * 60, + "1h": 60 * 60, + "24h": 24 * 60 * 60, + month: 30 * 24 * 60 * 60, + }[range] || 60 * 60; +} + +function filterTrafficRows(rows, range = state.trafficRange) { + if (!Array.isArray(rows) || !rows.length) return []; + const latest = Math.max(...rows.map((row) => Number(row.epoch) || 0)); + const cutoff = latest - rangeSeconds(range); + return rows.filter((row) => (Number(row.epoch) || 0) >= cutoff); +} + +function bucketTrafficRows(rows) { + const filtered = filterTrafficRows(rows); + if (filtered.length <= 140) return filtered; + const chunk = Math.ceil(filtered.length / 120); + const buckets = []; + for (let i = 0; i < filtered.length; i += chunk) { + const slice = filtered.slice(i, i + chunk); + const last = slice[slice.length - 1]; + buckets.push({ + epoch: last.epoch, + proxy_delta: slice.reduce((sum, item) => sum + (Number(item.proxy_delta) || 0), 0), + site_delta: slice.reduce((sum, item) => sum + (Number(item.site_delta) || 0), 0), + proxy_bytes: last.proxy_bytes, + site_bytes: last.site_bytes, + }); + } + return buckets; +} + +function fallbackTrafficSummaries(rows) { + return trafficRanges.map((range) => { + const windowRows = filterTrafficRows(rows, range); + if (!windowRows.length) { + return { range, points: 0, proxy_delta: 0, site_delta: 0, proxy_total: 0, site_total: 0 }; + } + const first = windowRows[0]; + const last = windowRows[windowRows.length - 1]; + return { + range, + points: windowRows.length, + proxy_delta: windowRows.reduce((sum, item) => sum + Math.max(0, Number(item.proxy_delta) || 0), 0), + site_delta: windowRows.reduce((sum, item) => sum + Math.max(0, Number(item.site_delta) || 0), 0), + proxy_total: Number(last.proxy_bytes) || 0, + site_total: Number(last.site_bytes) || 0, + }; + }); +} + +function renderTrafficLoading() { + $("#trafficChart").classList.toggle("is-hidden", state.trafficView !== "chart"); + $("#trafficTableWrap").classList.toggle("is-hidden", state.trafficView !== "table"); + $("#trafficChart").innerHTML = `
${escapeHtml(t("loading"))}
`; + $("#historyTable").innerHTML = `${escapeHtml(t("loading"))}`; +} + +function renderStats() { + const payload = statsPayload(); + const status = payload.status || {}; + const stats = payload.current || {}; + const historyRows = payload.history || []; + const summaryRows = payload.summary_rows?.length ? payload.summary_rows : fallbackTrafficSummaries(historyRows); + $("#statsHealth").className = `status-pill health-${escapeAttr(status.health || "unknown")}`; + $("#statsHealth").textContent = healthLabel(status.health); + $("#collectorState").textContent = status.service ? statusLabel(status.service) : "--"; + $("#lastStatsPoint").textContent = status.last_ts ? fmtDate(status.last_ts) : t("never"); + $("#historyRows").textContent = status.history_rows ?? historyRows.length; + $("#repairStatsBtn").classList.toggle("attention", status.health !== "ok"); + $("#collectStatsBtn").disabled = status.service === "not_installed"; + $("#metricProxyTraffic").textContent = fmtBytes(stats.proxy_bytes); + $("#metricSiteTraffic").textContent = fmtBytes(stats.site_bytes); + updateTrafficControls(); + if (state.trafficLoading) { + renderTrafficLoading(); + return; + } + $("#trafficChart").classList.toggle("is-hidden", state.trafficView !== "chart"); + $("#trafficTableWrap").classList.toggle("is-hidden", state.trafficView !== "table"); + drawTrafficChart(historyRows); + renderHistoryTable(summaryRows); +} + +function drawTrafficChart(rows) { + const el = $("#trafficChart"); + const points = bucketTrafficRows(rows); + const proxyColor = getComputedStyle(document.documentElement).getPropertyValue("--blue").trim() || "#2563eb"; + const siteColor = getComputedStyle(document.documentElement).getPropertyValue("--green").trim() || "#0f9f6e"; + if (points.length < 2) { + el.innerHTML = `
+ ${escapeHtml(points.length ? t("noTrafficForRange") : t("noHistory"))} + ${escapeHtml(state.overview?.stats_status?.health === "ok" ? t("statsOk") : t("statsMissing"))} +
`; + return; + } + const width = 900; + const height = 300; + const pad = { l: 54, r: 22, t: 24, b: 42 }; + const max = Math.max(1, ...points.map((p) => Math.max(p.proxy_delta || 0, p.site_delta || 0))); + const plotW = width - pad.l - pad.r; + const plotH = height - pad.t - pad.b; + const toX = (i) => pad.l + (plotW * i) / Math.max(1, points.length - 1); + const toY = (v) => pad.t + plotH - ((v || 0) / max) * plotH; + const pathFor = (key) => points.map((p, i) => `${i === 0 ? "M" : "L"}${toX(i).toFixed(1)},${toY(p[key]).toFixed(1)}`).join(" "); + const grid = Array.from({ length: 5 }, (_, i) => { + const y = pad.t + (plotH / 4) * i; + return ``; + }).join(""); + const axis = t("chartMax").replace("{value}", fmtBytes(max)); + el.innerHTML = ` + ${grid} + + + + ${escapeHtml(axis)} + ${escapeHtml(t("chartProxy"))} + ${escapeHtml(t("chartSite"))} + `; +} + +function renderHistoryTable(rows) { + if (!rows.length) { + $("#historyTable").innerHTML = `${escapeHtml(t("noHistory"))}`; + return; + } + $("#historyTable").innerHTML = rows.map((row) => ` + + ${escapeHtml(trafficRangeLabel(row.range))}${escapeHtml(row.points ? `${row.points} ${t("historyRows").toLowerCase()}` : t("noTrafficForRange"))} + ${escapeHtml(fmtBytes(row.proxy_delta))} + ${escapeHtml(fmtBytes(row.site_delta))} + ${escapeHtml(fmtBytes(row.proxy_total))} + ${escapeHtml(fmtBytes(row.site_total))} + + `).join(""); +} + +function ensureUserTrafficSelection() { + if (state.userTrafficUser && state.users.some((user) => user.name === state.userTrafficUser)) return; + state.userTrafficUser = state.users[0]?.name || ""; +} + +async function selectUserTraffic(name, options = {}) { + const next = String(name || ""); + if (!next || !state.users.some((user) => user.name === next)) return; + const changed = state.userTrafficUser !== next; + state.userTrafficUser = next; + if (changed) { + state.userTraffic = null; + } + renderUsers(); + renderUserTraffic(); + if (options.scroll) { + $("#userTrafficPanel")?.scrollIntoView({ behavior: "smooth", block: "start" }); + } + try { + await refreshUserTraffic({ showLoading: true }); + } catch (err) { + toast(err.message); + } +} + +function userTrafficRows() { + return state.userTraffic?.history || []; +} + +function bucketUserTrafficRows(rows) { + const filtered = filterTrafficRows(rows, state.userTrafficRange); + if (filtered.length <= 140) return filtered; + const chunk = Math.ceil(filtered.length / 120); + const buckets = []; + for (let i = 0; i < filtered.length; i += chunk) { + const slice = filtered.slice(i, i + chunk); + const last = slice[slice.length - 1]; + buckets.push({ + epoch: last.epoch, + total_delta: slice.reduce((sum, item) => sum + (Number(item.total_delta) || 0), 0), + total_octets: last.total_octets, + current_connections: last.current_connections, + active_unique_ips: last.active_unique_ips, + }); + } + return buckets; +} + +function fallbackUserTrafficSummaries(rows) { + return trafficRanges.map((range) => { + const windowRows = filterTrafficRows(rows, range); + if (!windowRows.length) { + return { range, points: 0, total_delta: 0, total_octets: 0 }; + } + const last = windowRows[windowRows.length - 1]; + return { + range, + points: windowRows.length, + total_delta: windowRows.reduce((sum, item) => sum + Math.max(0, Number(item.total_delta) || 0), 0), + total_octets: Number(last.total_octets) || 0, + }; + }); +} + +function renderUserTrafficLoading() { + $("#userTrafficChart").classList.toggle("is-hidden", state.userTrafficView !== "chart"); + $("#userTrafficTableWrap").classList.toggle("is-hidden", state.userTrafficView !== "table"); + $("#userTrafficChart").innerHTML = `
${escapeHtml(t("loading"))}
`; + $("#userTrafficTable").innerHTML = `${escapeHtml(t("loading"))}`; +} + +function drawUserTrafficChart(rows) { + const el = $("#userTrafficChart"); + const points = bucketUserTrafficRows(rows); + const color = getComputedStyle(document.documentElement).getPropertyValue("--blue").trim() || "#2563eb"; + if (points.length < 2) { + el.innerHTML = `
+ ${escapeHtml(state.userTrafficUser ? t("noTrafficForRange") : t("selectUserTraffic"))} + ${escapeHtml(state.userTraffic?.status?.runtime_ok ? t("statsOk") : t("trafficRuntimeUnavailable"))} +
`; + return; + } + const width = 900; + const height = 260; + const pad = { l: 54, r: 22, t: 24, b: 42 }; + const max = Math.max(1, ...points.map((p) => Number(p.total_delta) || 0)); + const plotW = width - pad.l - pad.r; + const plotH = height - pad.t - pad.b; + const toX = (i) => pad.l + (plotW * i) / Math.max(1, points.length - 1); + const toY = (v) => pad.t + plotH - ((v || 0) / max) * plotH; + const path = points.map((p, i) => `${i === 0 ? "M" : "L"}${toX(i).toFixed(1)},${toY(p.total_delta).toFixed(1)}`).join(" "); + const grid = Array.from({ length: 5 }, (_, i) => { + const y = pad.t + (plotH / 4) * i; + return ``; + }).join(""); + const axis = t("chartMax").replace("{value}", fmtBytes(max)); + el.innerHTML = ` + ${grid} + + + ${escapeHtml(axis)} + ${escapeHtml(state.userTrafficUser || t("users"))} + `; +} + +function renderUserTrafficTable(rows) { + if (!rows.length) { + $("#userTrafficTable").innerHTML = `${escapeHtml(t("noHistory"))}`; + return; + } + $("#userTrafficTable").innerHTML = rows.map((row) => ` + + ${escapeHtml(trafficRangeLabel(row.range))}${escapeHtml(row.points ? `${row.points} ${t("historyRows").toLowerCase()}` : t("noTrafficForRange"))} + ${escapeHtml(fmtBytes(row.total_delta))} + ${escapeHtml(fmtBytes(row.total_octets))} + + `).join(""); +} + +function renderUserTraffic() { + updateUserTrafficControls(); + if (!state.userTrafficUser) { + $("#userTrafficTitle").textContent = t("userTrafficTitle"); + $("#userTrafficHealth").className = "status-pill health-unknown"; + $("#userTrafficHealth").textContent = "--"; + $("#userTrafficTotal").textContent = "--"; + $("#userTrafficConnections").textContent = "--"; + $("#userTrafficIps").textContent = "--"; + $("#userTrafficChart").innerHTML = `
${escapeHtml(t("selectUserTraffic"))}
`; + $("#userTrafficTable").innerHTML = `${escapeHtml(t("selectUserTraffic"))}`; + return; + } + $("#userTrafficTitle").textContent = `${t("userTrafficTitle")}: ${state.userTrafficUser}`; + if (state.userTrafficLoading) { + renderUserTrafficLoading(); + return; + } + const payload = state.userTraffic || {}; + const current = payload.current || {}; + const rows = userTrafficRows(); + const last = rows[rows.length - 1] || {}; + const total = Number(current.total_octets) || Number(last.total_octets) || 0; + $("#userTrafficHealth").className = `status-pill ${current.enabled === false ? "health-stopped" : (current.ok ? "health-ok" : "health-stale")}`; + $("#userTrafficHealth").textContent = current.enabled === false ? t("disabled") : (current.ok ? t("healthOk") : t("trafficRuntimeUnavailable")); + $("#userTrafficTotal").textContent = fmtBytes(total); + $("#userTrafficConnections").textContent = current.current_connections ?? last.current_connections ?? 0; + $("#userTrafficIps").textContent = current.active_unique_ips ?? last.active_unique_ips ?? 0; + $("#userTrafficChart").classList.toggle("is-hidden", state.userTrafficView !== "chart"); + $("#userTrafficTableWrap").classList.toggle("is-hidden", state.userTrafficView !== "table"); + drawUserTrafficChart(rows); + renderUserTrafficTable(payload.summary_rows?.length ? payload.summary_rows : fallbackUserTrafficSummaries(rows)); +} + +function renderUsers() { + const container = $("#usersTable"); + if (!state.users.length) { + container.innerHTML = `
${escapeHtml(t("noKeys"))}
`; + return; + } + container.innerHTML = state.users.map((user) => { + const pending = state.pendingUsers.has(user.name); + const selected = user.name === state.userTrafficUser; + const traffic = user.traffic || {}; + const trafficTotal = Number(traffic.total_octets) ? fmtBytes(traffic.total_octets) : "--"; + const activeIps = Number(traffic.active_unique_ips) || 0; + const maxUniqueIps = Number.isFinite(Number(user.max_unique_ips)) ? Math.max(0, Number(user.max_unique_ips)) : 0; + return ` +
+
+ ${escapeHtml(t("tableUser"))} + +
+ + ${escapeHtml(pending ? t("applying") : (user.enabled ? t("enabled") : t("disabled")))} +
+
+
+ ${escapeHtml(t("tableSecret"))} + ${escapeHtml(user.secret)} +
+ +
+ ${escapeHtml(t("tableTraffic"))} +
+
+ + ${escapeHtml(trafficTotal)} + ${escapeHtml(activeIps ? `${activeIps} ${t("activeIps")}` : fmtDate(traffic.epoch))} + + +
+
+ ${escapeHtml(t("ipLimit"))} + + +
+
+
+
+ ${escapeHtml(t("tableActions"))} +
+ + +
+
+
+ `; }).join(""); +} + +function renderBackups(backups) { + const box = $("#backupsList"); + renderBackupSchedule(); + if (!backups.length) { + box.innerHTML = `
${escapeHtml(t("noBackups"))}
`; + return; + } + box.innerHTML = backups.map((item) => ` +
+
+ ${escapeHtml(item.name)} + ${escapeHtml(item.path)} · ${escapeHtml(fmtDate(item.mtime))} +
+
+ ${escapeHtml(fmtBytes(item.size))}${item.encrypted ? ` · ${escapeHtml(t("encrypted"))}` : ""} + +
+
+ `).join(""); +} + +function renderBackupSchedule() { + const schedule = state.backupSchedule || state.overview?.backup_schedule || { frequency: "off" }; + const frequency = schedule.frequency || "off"; + $$("[data-backup-schedule]").forEach((btn) => { + btn.classList.toggle("active", btn.dataset.backupSchedule === frequency); + }); + const next = schedule.next && schedule.next !== "n/a" ? schedule.next : ""; + $("#backupScheduleMeta").textContent = frequency === "off" + ? t("scheduleDisabled") + : t("scheduleNext").replace("{value}", next || (schedule.calendar || "--")); +} + +function renderEvents() { + const box = $("#events"); + if (!state.events.length) { + box.innerHTML = `
${escapeHtml(t("noEvents"))}
`; + return; + } + box.innerHTML = state.events.map((item) => ` +
+ ${escapeHtml(item.title)} + ${escapeHtml(item.detail || item.time.toLocaleTimeString())} +
+ `).join(""); +} + +function renderConfig() { + const cfg = state.overview?.config || {}; + const site = state.overview?.site_status || {}; + const items = [ + [t("configMode"), cfg.mode || "--"], + [t("configDomain"), cfg.domain || cfg.mask_host || "--"], + [t("configSiteStatus"), siteStatusText(site)], + [t("configTemplate"), cfg.template_id || cfg.template || "--"], + [t("configVersion"), state.overview?.version || "--"], + [t("bindAddress"), `${state.overview?.admin_bind?.host || "127.0.0.1"}:${state.overview?.admin_bind?.port || 1984}`], + ]; + $("#configList").innerHTML = items.map(([label, value]) => ` +
+ ${escapeHtml(label)} + ${escapeHtml(value)} +
+ `).join(""); +} + +async function refreshAll() { + if (state.refreshingAll) return; + state.refreshingAll = true; + const btn = $("#refreshBtn"); + btn.disabled = true; + try { + state.overview = await api("/api/overview"); + state.backupSchedule = state.overview.backup_schedule || state.backupSchedule; + updateLanguageFromOverview(state.overview); + state.users = await api("/api/users"); + ensureUserTrafficSelection(); + if (!state.stats) { + state.stats = { + current: state.overview.stats_current || {}, + history: state.overview.stats_history || [], + status: state.overview.stats_status || {}, + summary_rows: [], + }; + } else { + state.stats = { + ...state.stats, + current: state.overview.stats_current || state.stats.current || {}, + status: state.overview.stats_status || state.stats.status || {}, + }; + } + renderOverview(); + renderUsers(); + if (state.page === "traffic") { + await refreshStats(); + } else if (state.page === "keys") { + ensureUserTrafficSelection(); + await refreshUserTraffic(); + } + } catch (err) { + toast(err.message); + } finally { + btn.disabled = false; + state.refreshingAll = false; + updateAutoRefreshToggle(); + } +} + +async function refreshUsers() { + state.users = await api("/api/users"); + renderUsers(); +} + +async function refreshStats(options = {}) { + if (options.showLoading) { + state.trafficLoading = true; + renderStats(); + } + try { + const data = await api(`/api/stats?range=${encodeURIComponent(state.trafficRange)}`); + state.stats = data; + return data; + } finally { + state.trafficLoading = false; + renderStats(); + } +} + +async function refreshUserTraffic(options = {}) { + ensureUserTrafficSelection(); + if (!state.userTrafficUser) { + renderUserTraffic(); + return null; + } + if (options.showLoading) { + state.userTrafficLoading = true; + renderUserTraffic(); + } + try { + const data = await api(`/api/users/${encodeURIComponent(state.userTrafficUser)}/traffic?range=${encodeURIComponent(state.userTrafficRange)}`); + state.userTraffic = data; + return data; + } finally { + state.userTrafficLoading = false; + renderUserTraffic(); + } +} + +async function changeTrafficRange(range) { + const next = trafficRanges.includes(range) ? range : "1h"; + if (next === state.trafficRange && state.stats?.range === next) return; + const previous = state.trafficRange; + state.trafficRange = next; + try { + await refreshStats({ showLoading: true }); + } catch (err) { + state.trafficRange = previous; + state.trafficLoading = false; + renderStats(); + toast(err.message); + } +} + +async function changeUserTrafficRange(range) { + const next = trafficRanges.includes(range) ? range : "1h"; + if (next === state.userTrafficRange && state.userTraffic?.range === next) return; + const previous = state.userTrafficRange; + state.userTrafficRange = next; + try { + await refreshUserTraffic({ showLoading: true }); + } catch (err) { + state.userTrafficRange = previous; + state.userTrafficLoading = false; + renderUserTraffic(); + toast(err.message); + } +} + +async function addUser(name) { + const data = await api("/api/users", { + method: "POST", + body: JSON.stringify({ name }), + }); + addEvent(t("keyCreated"), data.name); + toast(t("keyCreated")); + await refreshAll(); +} + +async function deleteUser(name) { + await api(`/api/users/${encodeURIComponent(name)}`, { method: "DELETE" }); + addEvent(t("keyDeleted"), name); + toast(t("keyDeleted")); + await refreshAll(); +} + +async function setUserEnabled(name, enabled) { + const previousUsers = state.users.map((user) => ({ ...user })); + state.pendingUsers.add(name); + state.users = state.users.map((user) => user.name === name ? { ...user, enabled } : user); + renderUsers(); + try { + const data = await api(`/api/users/${encodeURIComponent(name)}/enabled`, { + method: "POST", + body: JSON.stringify({ enabled }), + }); + state.users = state.users.map((user) => user.name === name ? { ...user, enabled: data.enabled } : user); + const message = data.enabled ? t("keyEnabled") : t("keyDisabled"); + addEvent(message, name); + toast(t("changesApplyInBackground")); + try { + await refreshUsers(); + } catch (refreshErr) { + toast(refreshErr.message); + } + setTimeout(() => refreshAll().catch((err) => toast(err.message)), 1400); + } catch (err) { + state.users = previousUsers; + toast(err.message); + } finally { + state.pendingUsers.delete(name); + renderUsers(); + } +} + +async function setUserMaxUniqueIps(name, value) { + const limit = Number.parseInt(value, 10); + if (!Number.isFinite(limit) || limit < 0 || limit > 1000000) { + toast(t("ipLimitHint")); + return; + } + const form = $$("[data-ip-limit-form]").find((item) => item.dataset.ipLimitForm === name); + const controls = form ? Array.from(form.querySelectorAll("input, button")) : []; + controls.forEach((control) => { control.disabled = true; }); + try { + const data = await api(`/api/users/${encodeURIComponent(name)}/max-ips`, { + method: "POST", + body: JSON.stringify({ max_unique_ips: limit }), + }); + state.users = state.users.map((user) => user.name === name ? { ...user, max_unique_ips: data.max_unique_ips } : user); + renderUsers(); + addEvent(t("ipLimitSaved"), `${name}: ${data.max_unique_ips}`); + toast(t("changesApplyInBackground")); + setTimeout(() => refreshAll().catch((err) => toast(err.message)), 1400); + } catch (err) { + toast(err.message); + } finally { + controls.forEach((control) => { control.disabled = false; }); + } +} + +async function createBackup() { + const btn = $("#createBackupBtn"); + btn.disabled = true; + try { + const data = await api("/api/backups", { method: "POST", body: "{}" }); + addEvent(t("backupCreated"), data.path || ""); + toast(t("backupCreated")); + await refreshAll(); + } catch (err) { + toast(err.message); + } finally { + btn.disabled = false; + } +} + +async function setBackupSchedule(frequency) { + $$("[data-backup-schedule]").forEach((btn) => { btn.disabled = true; }); + try { + const data = await api("/api/backups/schedule", { + method: "POST", + body: JSON.stringify({ frequency }), + }); + state.backupSchedule = data.schedule || data; + renderBackupSchedule(); + addEvent(t("scheduleSaved"), frequency); + toast(t("scheduleSaved")); + } catch (err) { + toast(err.message); + } finally { + $$("[data-backup-schedule]").forEach((btn) => { btn.disabled = false; }); + } +} + +async function restoreBackup(name) { + const data = await api("/api/backups/restore", { + method: "POST", + body: JSON.stringify({ name }), + }); + addEvent(t("backupRestoreStarted"), data.name || name); + toast(t("backupRestoreStarted")); + setTimeout(() => refreshAll().catch((err) => toast(err.message)), 4000); +} + +function showUserQr(name) { + const user = state.users.find((item) => item.name === name); + if (!user) { + toast(t("qrUnavailable")); + return; + } + state.qrLink = user.link || ""; + $("#qrTitle").textContent = `${t("qrTitle")} · ${user.name}`; + $("#qrMeta").textContent = user.link || ""; + const img = $("#qrImage"); + img.alt = `${user.name} Telegram proxy QR`; + img.onerror = () => { + img.removeAttribute("src"); + toast(t("qrUnavailable")); + }; + img.src = `/api/users/${encodeURIComponent(user.name)}/qr?ts=${Date.now()}`; + $("#qrModal").hidden = false; +} + +async function loadLogs() { + const service = $("#logService").value; + const btn = $("#loadLogsBtn"); + btn.disabled = true; + $("#logsMeta").textContent = ""; + $("#logsBox").textContent = t("loading"); + try { + const payload = await api(`/api/logs?service=${encodeURIComponent(service)}`); + if ($("#logService").value === service) { + const structured = payload && typeof payload === "object"; + const text = typeof payload === "string" ? payload : (payload?.text || ""); + const lines = structured ? (payload.line_count ?? text.split("\n").filter(Boolean).length) : text.split("\n").filter(Boolean).length; + const stateText = structured ? (payload.ok ? "OK" : `exit ${payload.exit_code ?? "?"}`) : "OK"; + $("#logsMeta").textContent = `${service} · ${lines} ${t("logsLines")} · ${stateText}`; + $("#logsBox").textContent = text || t("logsNoData"); + } + } catch (err) { + $("#logsMeta").textContent = ""; + $("#logsBox").textContent = err.message; + } finally { + btn.disabled = false; + } +} + +async function restartService(name) { + await api(`/api/services/${encodeURIComponent(name)}/restart`, { method: "POST", body: "{}" }); + addEvent(t("serviceRestarted"), name); + toast(`${name} ${t("serviceRestarted").toLowerCase()}`); + await refreshAll(); +} + +async function repairStats() { + const btn = $("#repairStatsBtn"); + btn.disabled = true; + try { + await api("/api/stats/repair", { method: "POST", body: "{}" }); + addEvent(t("statsRepaired")); + toast(t("statsRepaired")); + await refreshAll(); + await refreshStats(); + } catch (err) { + toast(err.message); + } finally { + btn.disabled = false; + } +} + +async function collectStats() { + const btn = $("#collectStatsBtn"); + btn.disabled = true; + try { + await api("/api/stats/collect", { method: "POST", body: "{}" }); + addEvent(t("statsCollected")); + toast(t("statsCollected")); + await refreshAll(); + await refreshStats(); + } catch (err) { + toast(err.message); + } finally { + btn.disabled = false; + } +} + +async function copyText(value) { + try { + await navigator.clipboard.writeText(value); + toast(t("copied")); + } catch (_) { + const area = document.createElement("textarea"); + area.value = value; + area.setAttribute("readonly", ""); + area.style.position = "fixed"; + area.style.opacity = "0"; + document.body.appendChild(area); + area.select(); + const ok = document.execCommand("copy"); + area.remove(); + toast(ok ? t("copied") : t("copyFailed")); + } +} + +function maybeShowPromo() { + const key = "gotelegram-promo-last"; + const now = Math.floor(Date.now() / 1000); + const last = Number(localStorage.getItem(key) || 0); + if (now - last < 86400) return; + localStorage.setItem(key, String(now)); + $("#promoModal").hidden = false; +} + +document.addEventListener("click", async (eventObj) => { + const nav = eventObj.target.closest("[data-nav]"); + if (nav) { + setPage(nav.dataset.nav); + return; + } + + const button = eventObj.target.closest("button"); + if (button) { + if (button.id === "themeToggle") { + setTheme(state.theme === "dark" ? "light" : "dark"); + } else if (button.id === "menuBtn") { + $("#sidebar").classList.toggle("open"); + } else if (button.dataset.trafficRange) { + changeTrafficRange(button.dataset.trafficRange); + } else if (button.dataset.trafficView) { + state.trafficView = button.dataset.trafficView === "table" ? "table" : "chart"; + renderStats(); + } else if (button.dataset.userTraffic) { + selectUserTraffic(button.dataset.userTraffic, { scroll: true }); + } else if (button.dataset.userQr) { + showUserQr(button.dataset.userQr); + } else if (button.dataset.userTrafficRange) { + changeUserTrafficRange(button.dataset.userTrafficRange); + } else if (button.dataset.userTrafficView) { + state.userTrafficView = button.dataset.userTrafficView === "table" ? "table" : "chart"; + renderUserTraffic(); + } else if (button.dataset.backupSchedule) { + setBackupSchedule(button.dataset.backupSchedule); + } else if (button.dataset.restoreBackup) { + const name = button.dataset.restoreBackup; + if (confirm(`${t("confirmRestoreBackup")} ${name}?`)) restoreBackup(name).catch((err) => toast(err.message)); + } else if (button.dataset.copy) { + await copyText(button.dataset.copy); + } else if (button.dataset.delete) { + const name = button.dataset.delete; + if (confirm(`${t("confirmDelete")} ${name}?`)) deleteUser(name).catch((err) => toast(err.message)); + } else if (button.dataset.restart) { + const name = button.dataset.restart; + if (confirm(`${t("confirmRestart")} ${name}?`)) restartService(name).catch((err) => toast(err.message)); + } + return; + } + + if (eventObj.target.closest("input, select, textarea, label, form")) return; + const row = eventObj.target.closest("[data-select-user-traffic]"); + if (!row) return; + selectUserTraffic(row.dataset.selectUserTraffic, { scroll: true }); +}); + +document.addEventListener("change", (eventObj) => { + const input = eventObj.target.closest("[data-toggle-user]"); + if (!input) return; + input.disabled = true; + setUserEnabled(input.dataset.toggleUser, input.checked).catch((err) => { + input.checked = !input.checked; + input.disabled = false; + toast(err.message); + }); +}); + +$("#addUserForm").addEventListener("submit", (eventObj) => { + eventObj.preventDefault(); + const input = $("#userName"); + const name = input.value.trim(); + if (!/^[A-Za-z0-9_.-]{1,48}$/.test(name)) { + toast(t("invalidUser")); + return; + } + input.value = ""; + addUser(name).catch((err) => toast(err.message)); +}); + +document.addEventListener("submit", (eventObj) => { + const form = eventObj.target.closest("[data-ip-limit-form]"); + if (!form) return; + eventObj.preventDefault(); + const input = form.querySelector("[data-ip-limit-input]"); + setUserMaxUniqueIps(form.dataset.ipLimitForm, input?.value || "0"); +}); + +$("#refreshBtn").addEventListener("click", refreshAll); +$("#autoRefreshToggle").addEventListener("click", () => setAutoRefresh(!state.autoRefreshEnabled)); +$("#languageSelect").addEventListener("change", (eventObj) => setLanguage(eventObj.target.value)); +$("#promoClose").addEventListener("click", () => { + $("#promoModal").hidden = true; +}); +$("#qrClose").addEventListener("click", () => { + $("#qrModal").hidden = true; +}); +$("#qrCopyBtn").addEventListener("click", () => { + if (state.qrLink) copyText(state.qrLink); +}); +$("#createBackupBtn").addEventListener("click", createBackup); +$("#loadLogsBtn").addEventListener("click", loadLogs); +$("#repairStatsBtn").addEventListener("click", repairStats); +$("#collectStatsBtn").addEventListener("click", collectStats); +window.addEventListener("hashchange", () => setPage((location.hash || "#dashboard").slice(1), false)); + +setPage((location.hash || "#dashboard").slice(1), false); +setTheme(state.theme); +renderEvents(); +syncAutoRefreshTimer(); +refreshAll(); +loadLogs(); +maybeShowPromo(); diff --git a/admin-web/static/index.html b/admin-web/static/index.html new file mode 100644 index 0000000..fcff43d --- /dev/null +++ b/admin-web/static/index.html @@ -0,0 +1,398 @@ + + + + + + goTelegram Pro Admin + + + + +
+ + +
+
+ +
+

Local Admin

+

Dashboard

+ -- +
+
+ + + + +
+
+ +
+
+
+
+

goTelegram Pro

+

Port 443

+

Website, MTProxy and local admin status in one operational view.

+
+
+
+
+ 443 + public +
+ -- +
+
+
+
+ +
+
+ Mode + -- + -- + -- +
+
+ Keys + 0 + configured users +
+
+ Proxy Traffic + 0 B + 0 packets +
+
+ Site Traffic + 0 B + 0 packets +
+
+ +
+
+
+
+

Services

+

Service health?

+
+
+
+
+ +
+
+
+

Runtime

+

telemt summary?

+
+
+
+
+
+
+
+ +
+
+
+
+

Traffic

+

History

+
+
+ -- + + +
+
+
+
+ Collector + -- +
+
+ Last point + -- +
+
+ History rows + 0 +
+
+
+
+ + + + +
+
+ + +
+
+
+
+ + + + + + + + + + + +
PeriodProxy deltaSite deltaProxy totalSite total
+
+
+
+ +
+
+
+
+

Access

+

User keys

+
+
+ + +
+
+
+
+
+
+
+
+
+

Per user

+

User traffic

+
+
+ -- +
+
+
+
+ Total + -- +
+
+ Connections + -- +
+
+ Active IPs + -- +
+
+
+
+ + + + +
+
+ + +
+
+
+
+ + + + + + + + + +
PeriodTraffic deltaTotal
+
+
+
+ +
+
+
+
+
+

Snapshots

+

Backups

+
+ +
+
+
+ Automatic backups + Loading schedule... +
+
+ + + + +
+
+
+ Backup contents + telemt config, goTelegram settings, keys, disabled keys, site, templates, SSL certificates, bot, admin panel and traffic history. +
+
+
+ + +
+
+ +
+
+
+
+

Journal

+

Logs

+
+
+ + +
+
+
+

+          
+
+ +
+
+
+
+
+

Settings

+

Panel preferences

+
+
+
+
+ Panel language + -- +
+
+ Theme + -- +
+
+ Bind address + 127.0.0.1:1984 +
+
+
+ +
+
+
+

Config

+

Installation state

+
+
+
+
+
+
+
+
+
+ +
+ + + + + diff --git a/admin-web/static/styles.css b/admin-web/static/styles.css new file mode 100644 index 0000000..580772d --- /dev/null +++ b/admin-web/static/styles.css @@ -0,0 +1,1708 @@ +:root { + color-scheme: light; + --bg: #f4f7fb; + --panel: #ffffff; + --panel-soft: #f8fafd; + --panel-strong: #eef3fa; + --text: #111827; + --muted: #667085; + --line: #dde5ef; + --blue: #2563eb; + --green: #0f9f6e; + --amber: #c77700; + --red: #d92d20; + --violet: #7c3aed; + --sidebar: #111827; + --sidebar-text: #dbe5f2; + --sidebar-muted: #8fa1b8; + --button: #121926; + --button-text: #ffffff; + --shadow: 0 18px 50px rgba(15, 23, 42, .08); +} + +:root[data-theme="dark"] { + color-scheme: dark; + --bg: #070b12; + --panel: #0f1724; + --panel-soft: #111c2d; + --panel-strong: #172235; + --text: #e7edf7; + --muted: #98a7bd; + --line: #263348; + --blue: #60a5fa; + --green: #34d399; + --amber: #fbbf24; + --red: #f87171; + --violet: #a78bfa; + --sidebar: #050810; + --sidebar-text: #eef4ff; + --sidebar-muted: #8d9bb1; + --button: #e7edf7; + --button-text: #0b1220; + --shadow: 0 18px 55px rgba(0, 0, 0, .34); +} + +* { box-sizing: border-box; } + +html { min-width: 320px; } + +body { + margin: 0; + min-height: 100vh; + background: var(--bg); + color: var(--text); + font: 14px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +button, input, select { font: inherit; } + +button { + min-height: 40px; + border: 0; + border-radius: 8px; + padding: 10px 14px; + background: var(--button); + color: var(--button-text); + cursor: pointer; + transition: transform .16s ease, box-shadow .16s ease, background .16s ease, opacity .16s ease; +} + +button:hover { transform: translateY(-1px); box-shadow: 0 12px 28px rgba(15, 23, 42, .16); } +button:disabled { opacity: .5; cursor: not-allowed; transform: none; box-shadow: none; } +button.ghost { background: var(--panel-strong); color: var(--text); } +button.soft { background: color-mix(in srgb, var(--blue) 12%, transparent); color: var(--blue); } +button.danger { background: color-mix(in srgb, var(--red) 14%, transparent); color: var(--red); } +button.attention { background: var(--amber); color: #111827; } + +input, select { + min-height: 42px; + min-width: 0; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + color: var(--text); + padding: 0 12px; + outline: none; +} + +input:focus, select:focus { + border-color: var(--blue); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--blue) 16%, transparent); +} + +h1, h2, p { margin: 0; } + +h1 { + font-size: clamp(24px, 3vw, 34px); + line-height: 1.12; + letter-spacing: 0; +} + +h2 { + font-size: clamp(18px, 2vw, 22px); + line-height: 1.2; +} + +.app-shell { + display: grid; + grid-template-columns: 260px minmax(0, 1fr); + min-height: 100vh; +} + +.sidebar { + position: sticky; + top: 0; + height: 100vh; + display: flex; + flex-direction: column; + gap: 26px; + padding: 24px 18px; + background: var(--sidebar); + color: var(--sidebar-text); +} + +.brand { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.brand-mark { + display: grid; + place-items: center; + flex: 0 0 42px; + width: 42px; + height: 42px; + border-radius: 8px; + background: #29b57f; + color: white; + font-weight: 800; +} + +.brand strong { + letter-spacing: 0; +} + +.brand span { + display: block; + color: var(--sidebar-muted); + font-size: 12px; +} + +.nav-tabs { + display: grid; + gap: 6px; +} + +.nav-item { + width: 100%; + display: flex; + align-items: center; + gap: 10px; + justify-content: flex-start; + text-align: left; + background: transparent; + color: var(--sidebar-muted); + box-shadow: none; +} + +.nav-icon { + display: inline-grid; + place-items: center; + flex: 0 0 28px; + width: 28px; + height: 28px; + border-radius: 8px; + background: rgba(255, 255, 255, .07); + color: var(--sidebar-text); + font-weight: 800; +} + +.nav-item:hover, +.nav-item.active { + background: rgba(255, 255, 255, .08); + color: var(--sidebar-text); + box-shadow: none; +} + +.sidebar-foot { + margin-top: auto; + display: grid; + gap: 4px; + color: var(--sidebar-muted); + font-size: 12px; +} + +.workspace { + min-width: 0; +} + +.topbar { + position: sticky; + top: 0; + z-index: 5; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 22px 28px; + border-bottom: 1px solid var(--line); + background: color-mix(in srgb, var(--bg) 86%, transparent); + backdrop-filter: blur(16px); +} + +.title-block { + min-width: 0; +} + +.title-block small { + color: var(--muted); +} + +.top-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + flex-wrap: wrap; +} + +.auto-refresh-toggle { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 42px; + padding: 7px 10px; + border: 1px solid var(--line); + background: var(--panel-strong); + color: var(--text); +} + +.auto-refresh-toggle:hover { + transform: translateY(-1px); +} + +.auto-refresh-icon { + color: var(--blue); + font-size: 17px; + line-height: 1; +} + +.auto-refresh-track { + position: relative; + display: inline-flex; + align-items: center; + width: 38px; + height: 22px; + border-radius: 999px; + background: color-mix(in srgb, var(--muted) 28%, transparent); + transition: background .16s ease; +} + +.auto-refresh-track span { + position: absolute; + left: 3px; + width: 16px; + height: 16px; + border-radius: 999px; + background: var(--panel); + box-shadow: 0 2px 8px rgba(15, 23, 42, .18); + transition: transform .16s ease; +} + +.auto-refresh-toggle.active .auto-refresh-track { + background: color-mix(in srgb, var(--green) 58%, transparent); +} + +.auto-refresh-toggle.active .auto-refresh-track span { + transform: translateX(16px); +} + +.auto-refresh-state { + min-width: 22px; + color: var(--muted); + font-size: 12px; + font-weight: 800; + text-transform: uppercase; +} + +.language-select { + width: 78px; + min-width: 78px; + font-weight: 800; +} + +.icon-btn { + width: 42px; + padding: 0; +} + +.mobile-only { + display: none; +} + +.pill, +.status-pill { + display: inline-flex; + align-items: center; + min-height: 32px; + border-radius: 8px; + padding: 6px 10px; + background: var(--panel-strong); + color: var(--text); + font-weight: 700; + font-size: 12px; +} + +.content { + width: min(1480px, 100%); + padding: 28px; +} + +.page-panel { + display: none; +} + +.page-panel.active { + display: grid; + gap: 18px; +} + +.visual-overview { + position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(300px, 520px); + gap: 20px; + min-height: 170px; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 8px; + background: + linear-gradient(135deg, color-mix(in srgb, var(--green) 14%, transparent), transparent 42%), + linear-gradient(120deg, var(--panel), var(--panel-soft)); + box-shadow: var(--shadow); + padding: 22px; +} + +.visual-overview h2 { + margin-top: 4px; + font-size: clamp(24px, 3vw, 34px); +} + +.visual-overview p:not(.eyebrow) { + max-width: 620px; + margin-top: 8px; + color: var(--muted); +} + +.signal-map { + position: relative; + min-height: 128px; + border: 1px solid var(--line); + border-radius: 8px; + background: + linear-gradient(90deg, color-mix(in srgb, var(--line) 45%, transparent) 1px, transparent 1px), + linear-gradient(0deg, color-mix(in srgb, var(--line) 45%, transparent) 1px, transparent 1px); + background-size: 28px 28px; +} + +.signal-map::before, +.signal-map::after { + content: ""; + position: absolute; + inset: 50% 38px auto 38px; + height: 2px; + background: linear-gradient(90deg, var(--green), var(--blue), var(--violet)); +} + +.signal-map::after { + inset: 30px auto 30px 50%; + width: 2px; + height: auto; +} + +.node { + position: absolute; + z-index: 1; + display: grid; + place-items: center; + min-width: 82px; + min-height: 38px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + color: var(--text); + font-size: 12px; + font-weight: 900; + box-shadow: 0 12px 28px rgba(15, 23, 42, .12); +} + +.node-site { left: 24px; top: 18px; } +.node-proxy { right: 24px; top: 50%; transform: translateY(-50%); } +.node-admin { left: 50%; bottom: 18px; transform: translateX(-50%); } + +.port-map { + display: grid; + gap: 12px; + min-height: 128px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + padding: 14px; +} + +.port-map-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.port-badge { + display: grid; + gap: 4px; +} + +.port-badge span { + display: inline-grid; + place-items: center; + width: 54px; + height: 54px; + border-radius: 8px; + background: color-mix(in srgb, var(--blue) 14%, transparent); + color: var(--blue); + font-weight: 900; + font-size: 20px; +} + +.port-badge small { + color: var(--muted); + font-size: 11px; + font-weight: 800; + text-align: center; +} + +.port-status { + color: var(--muted); + font-size: 12px; + font-weight: 800; + text-align: right; +} + +.port-status.ok { color: var(--green); } +.port-status.warn { color: var(--amber); } +.port-status.error { color: var(--red); } + +.port-list { + display: grid; + gap: 8px; +} + +.port-section-label { + margin-top: 2px; + color: var(--muted); + font-size: 11px; + font-weight: 900; + text-transform: uppercase; +} + +.port-listener, +.port-empty { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel-soft); + padding: 10px 12px; +} + +.port-listener strong, +.port-listener span { + display: block; + min-width: 0; + overflow-wrap: anywhere; +} + +.port-listener span, +.port-listener small, +.port-empty { + color: var(--muted); + font-size: 12px; +} + +.port-listener.role-mtproxy { border-left: 4px solid var(--green); } +.port-listener.role-site { border-left: 4px solid var(--blue); } +.port-listener.role-xray { border-left: 4px solid var(--violet); } +.port-listener.role-amneziawg { border-left: 4px solid var(--amber); } + +.eyebrow { + color: var(--muted); + font-size: 12px; + text-transform: uppercase; + font-weight: 800; +} + +.panel, +.metric-card { + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + box-shadow: var(--shadow); +} + +.panel { + padding: 18px; + min-width: 0; +} + +.panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + margin-bottom: 16px; +} + +.with-help { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.info-hint { + display: inline-grid; + place-items: center; + width: 22px; + height: 22px; + border: 1px solid var(--line); + border-radius: 50%; + background: var(--panel-soft); + color: var(--muted); + font-size: 12px; + font-weight: 900; + cursor: help; +} + +.info-hint:focus { + outline: 3px solid color-mix(in srgb, var(--blue) 18%, transparent); +} + +.panel-actions, +.inline-form { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.metric-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; +} + +.metric-card { + position: relative; + min-height: 126px; + padding: 18px; + overflow: hidden; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.metric-card::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 4px; + background: var(--blue); +} + +.metric-card.accent-green::before { background: var(--green); } +.metric-card.accent-violet::before { background: var(--violet); } +.metric-card.accent-amber::before { background: var(--amber); } + +.metric-card span, +.metric-card small { + color: var(--muted); +} + +.metric-card strong { + min-width: 0; + overflow-wrap: anywhere; + font-size: clamp(24px, 3vw, 32px); + line-height: 1.05; +} + +.metric-status { + display: inline-flex; + align-items: center; + width: fit-content; + margin-top: 7px; + border-radius: 8px; + padding: 4px 8px; + background: var(--panel-strong); + font-size: 12px; + font-weight: 800; +} + +.metric-status.ok { + background: color-mix(in srgb, var(--green) 16%, transparent); + color: var(--green); +} + +.metric-status.warn { + background: color-mix(in srgb, var(--amber) 18%, transparent); + color: var(--amber); +} + +.metric-status.error { + background: color-mix(in srgb, var(--red) 16%, transparent); + color: var(--red); +} + +.grid-two { + display: grid; + grid-template-columns: minmax(0, 1.5fr) minmax(320px, .9fr); + gap: 18px; +} + +.service-grid { + display: grid; + grid-template-columns: repeat(5, minmax(150px, 1fr)); + gap: 12px; +} + +.service { + display: grid; + gap: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel-soft); + padding: 14px; +} + +.service strong { + display: block; + margin-bottom: 6px; +} + +.service span { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--muted); +} + +.service i { + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--muted); +} + +.status-running i { background: var(--green); } +.status-failed i { background: var(--red); } +.status-inactive i, +.status-stopped i, +.status-activating i, +.status-deactivating i { background: var(--amber); } +.status-not_installed i { background: var(--muted); } + +.runtime-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.runtime-grid article, +.traffic-summary article { + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel-soft); + padding: 12px; +} + +.runtime-grid span, +.traffic-summary span, +.settings-list span { + display: block; + color: var(--muted); + font-size: 12px; +} + +.runtime-grid strong, +.traffic-summary strong { + display: block; + margin-top: 3px; + overflow-wrap: anywhere; + font-size: 18px; +} + +.issue-list { + margin-top: 12px; + display: grid; + gap: 8px; +} + +.issue { + display: flex; + justify-content: space-between; + gap: 12px; + border-radius: 8px; + background: color-mix(in srgb, var(--amber) 12%, transparent); + color: var(--text); + padding: 10px 12px; +} + +.traffic-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 14px; +} + +.traffic-summary.compact { + grid-template-columns: repeat(3, minmax(140px, 1fr)); +} + +.health-ok { background: color-mix(in srgb, var(--green) 18%, transparent); color: var(--green); } +.health-error { background: color-mix(in srgb, var(--red) 18%, transparent); color: var(--red); } +.health-stale, +.health-stopped { background: color-mix(in srgb, var(--amber) 20%, transparent); color: var(--amber); } +.health-not_installed { background: var(--panel-strong); color: var(--muted); } + +.traffic-controls { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} + +.segmented { + display: inline-flex; + align-items: center; + gap: 4px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel-strong); + padding: 4px; +} + +.segmented button { + min-height: 32px; + border-radius: 6px; + background: transparent; + color: var(--muted); + box-shadow: none; + padding: 6px 10px; + font-weight: 800; +} + +.segmented button:hover { + box-shadow: none; +} + +.segmented button.active { + background: var(--panel); + color: var(--text); + box-shadow: 0 6px 18px rgba(15, 23, 42, .08); +} + +.traffic-chart { + width: 100%; + min-height: 320px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel-soft); + overflow: hidden; +} + +.traffic-chart.is-hidden, +.table-wrap.is-hidden { + display: none; +} + +.traffic-chart svg { + display: block; + width: 100%; + height: auto; + min-height: 300px; +} + +.traffic-chart .grid line { + stroke: var(--line); + stroke-width: 1; +} + +.traffic-chart .line { + fill: none; + stroke-width: 3; + stroke-linecap: round; + stroke-linejoin: round; +} + +.traffic-chart .proxy-line { stroke: var(--blue); } +.traffic-chart .site-line { stroke: var(--green); } +.traffic-chart .proxy-area { + fill: color-mix(in srgb, var(--blue) 10%, transparent); +} + +.traffic-chart .axis, +.traffic-chart .legend { + fill: var(--muted); + font: 13px system-ui, sans-serif; +} + +.empty-chart, +.empty { + min-height: 160px; + display: grid; + place-items: center; + align-content: center; + gap: 6px; + color: var(--muted); + text-align: center; + padding: 18px; +} + +.empty-chart strong { + color: var(--text); +} + +.table-wrap { + margin-top: 14px; + overflow-x: auto; + border: 1px solid var(--line); + border-radius: 8px; +} + +.keys-wrap { + margin-top: 14px; +} + +.keys-list { + display: grid; + gap: 12px; +} + +.key-card { + display: grid; + grid-template-columns: + minmax(150px, .85fr) + minmax(260px, 1.35fr) + minmax(200px, 1fr) + minmax(260px, 1.1fr) + minmax(210px, .9fr); + grid-template-areas: "user secret links traffic actions"; + gap: 12px; + align-items: stretch; + min-width: 0; + padding: 14px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + cursor: pointer; + transition: background .16s ease, box-shadow .16s ease, border-color .16s ease; +} + +.key-card:hover { + background: color-mix(in srgb, var(--blue) 7%, var(--panel)); +} + +.key-card.selected-row { + border-color: color-mix(in srgb, var(--blue) 34%, var(--line)); + background: color-mix(in srgb, var(--blue) 10%, var(--panel)); + box-shadow: inset 4px 0 0 var(--blue); +} + +.key-card-user, +.key-card-secret, +.key-card-links, +.key-card-traffic, +.key-card-actions { + display: grid; + align-content: center; + gap: 8px; + min-width: 0; +} + +.key-card-user { grid-area: user; } +.key-card-secret { grid-area: secret; } +.key-card-links { grid-area: links; } +.key-card-traffic { grid-area: traffic; } +.key-card-actions { grid-area: actions; } + +.key-card-secret code { + display: block; + max-width: 100%; + overflow-wrap: anywhere; + word-break: break-word; + white-space: normal; + font-size: 13px; + line-height: 1.35; +} + +.field-label { + color: var(--muted); + font-size: 12px; + font-weight: 800; + text-transform: uppercase; +} + +table { + width: 100%; + min-width: 720px; + border-collapse: collapse; + background: var(--panel); +} + +.keys-table { + min-width: 1180px; + table-layout: fixed; +} + +.keys-table th:nth-child(1), +.keys-table td:nth-child(1) { width: 11%; } +.keys-table th:nth-child(2), +.keys-table td:nth-child(2) { width: 15%; } +.keys-table th:nth-child(3), +.keys-table td:nth-child(3) { width: 27%; } +.keys-table th:nth-child(4), +.keys-table td:nth-child(4) { width: 16%; } +.keys-table th:nth-child(5), +.keys-table td:nth-child(5) { width: 18%; } +.keys-table th:nth-child(6), +.keys-table td:nth-child(6) { width: 13%; } + +th, +td { + padding: 14px 16px; + text-align: left; + border-bottom: 1px solid var(--line); + vertical-align: middle; +} + +th { + color: var(--muted); + font-size: 12px; + text-transform: uppercase; +} + +tr:last-child td { + border-bottom: 0; +} + +tr[data-select-user-traffic] { + cursor: pointer; + transition: background .16s ease, box-shadow .16s ease; +} + +tr[data-select-user-traffic]:hover td { + background: color-mix(in srgb, var(--blue) 7%, transparent); +} + +tr.selected-row td { + background: color-mix(in srgb, var(--blue) 10%, var(--panel)); +} + +tr.selected-row td:first-child { + box-shadow: inset 4px 0 0 var(--blue); +} + +.disabled-row { + opacity: .72; +} + +.pending-row { + outline: 2px solid color-mix(in srgb, var(--blue) 16%, transparent); +} + +td code { + display: inline-block; + max-width: 320px; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: middle; +} + +td small { + color: var(--muted); +} + +.key-name-button small { + color: var(--muted); +} + +.action-buttons { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 8px; + flex-wrap: nowrap; +} + +.keys-table button { + white-space: nowrap; +} + +.key-name-button { + min-height: auto; + padding: 0; + border-radius: 0; + background: transparent; + color: var(--text); + box-shadow: none; + display: inline-flex; + align-items: baseline; + gap: 5px; + text-align: left; +} + +.key-name-button:hover { + color: var(--blue); + transform: none; + box-shadow: none; +} + +.mini-actions { + justify-content: flex-start; + flex-wrap: nowrap; +} + +.key-card .mini-actions, +.key-card .action-buttons { + display: grid; + grid-template-columns: 1fr; + align-content: center; + align-items: stretch; + gap: 8px; + width: 100%; +} + +.key-card .mini-actions button, +.key-card .action-buttons button { + width: 100%; + min-height: 44px; + white-space: normal; + line-height: 1.2; +} + +.traffic-cell { + display: grid; + gap: 10px; + min-width: 0; +} + +.traffic-main { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + min-width: 0; +} + +.traffic-main span { + display: grid; + gap: 2px; + min-width: 0; +} + +.traffic-cell strong { + font-size: 14px; +} + +.traffic-cell .soft { + flex: 0 0 auto; +} + +.ip-limit-control { + display: grid; + grid-template-columns: auto minmax(54px, 72px) auto; + align-items: center; + gap: 6px; +} + +.ip-limit-control span { + color: var(--muted); + font-size: 12px; + font-weight: 800; + text-transform: uppercase; +} + +.ip-limit-control input { + width: 72px; + min-height: 36px; + padding: 0 8px; +} + +.ip-limit-control button { + min-height: 36px; + padding: 7px 10px; +} + +.user-traffic-panel { + margin-top: 18px; + scroll-margin-top: 112px; +} + +.status-control { + display: inline-flex; + align-items: center; + gap: 10px; +} + +.state-on { color: var(--green); } +.state-off { color: var(--amber); } + +.switch { + position: relative; + display: inline-flex; + width: 46px; + height: 26px; +} + +.switch input { + position: absolute; + opacity: 0; + width: 1px; + height: 1px; +} + +.switch span { + position: absolute; + inset: 0; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--panel-strong); + transition: background .16s ease, border-color .16s ease; +} + +.switch span::before { + content: ""; + position: absolute; + width: 20px; + height: 20px; + left: 2px; + top: 2px; + border-radius: 50%; + background: var(--panel); + box-shadow: 0 3px 9px rgba(15, 23, 42, .22); + transition: transform .16s ease; +} + +.switch input:checked + span { + border-color: color-mix(in srgb, var(--green) 70%, var(--line)); + background: color-mix(in srgb, var(--green) 64%, var(--panel)); +} + +.switch input:checked + span::before { + transform: translateX(20px); +} + +.switch input:disabled + span { + opacity: .55; + cursor: not-allowed; +} + +.empty-cell { + color: var(--muted); + text-align: center; +} + +.backup-list, +.events-list, +.settings-list { + display: grid; + gap: 10px; +} + +.mini-actions, +.backup-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.mini-actions { + justify-content: flex-start; + flex-wrap: nowrap; +} + +.backup-actions { + justify-content: flex-end; + flex-wrap: wrap; +} + +.keys-table .mini-actions, +.keys-table .action-buttons { + display: grid; + grid-template-columns: 1fr; + align-content: center; + align-items: stretch; + gap: 8px; + width: 100%; +} + +.keys-table .mini-actions button, +.keys-table .action-buttons button { + width: 100%; + min-height: 44px; + white-space: normal; + line-height: 1.2; +} + +.backup-schedule, +.backup-includes { + display: grid; + gap: 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel-soft); + padding: 12px; + margin-bottom: 12px; +} + +.backup-schedule { + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; +} + +.backup-schedule span, +.backup-includes span { + display: block; + color: var(--muted); + font-size: 12px; + margin-top: 2px; +} + +.backup-item, +.event, +.settings-list > div { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel-soft); + padding: 12px; +} + +.backup-item span, +.event small { + display: block; + color: var(--muted); + font-size: 12px; + overflow-wrap: anywhere; +} + +.logs-meta { + min-height: 24px; + margin: -2px 0 8px; + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.logs { + min-height: 460px; + max-height: calc(100vh - 260px); + overflow: auto; + margin: 0; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel-soft); + color: var(--text); + padding: 14px; + white-space: pre-wrap; + word-break: break-word; +} + +.qr-card { + width: min(440px, calc(100vw - 32px)); +} + +.qr-frame { + display: grid; + place-items: center; + margin: 12px 0; + padding: 14px; + border: 1px solid var(--line); + border-radius: 8px; + background: #fff; +} + +.qr-frame img { + display: block; + width: min(280px, 70vw); + aspect-ratio: 1; + object-fit: contain; +} + +.modal-note { + max-height: 92px; + overflow: auto; + color: var(--muted); + font-size: 12px; + line-height: 1.5; + overflow-wrap: anywhere; +} + +.toast { + position: fixed; + right: 22px; + bottom: 22px; + z-index: 20; + min-width: 240px; + max-width: min(420px, calc(100vw - 32px)); + padding: 13px 14px; + border-radius: 8px; + background: var(--button); + color: var(--button-text); + opacity: 0; + transform: translateY(10px); + pointer-events: none; + transition: opacity .18s ease, transform .18s ease; +} + +.toast.show { + opacity: 1; + transform: translateY(0); +} + +.promo-modal { + position: fixed; + inset: 0; + z-index: 30; + display: grid; + place-items: center; + padding: 18px; + background: rgba(5, 8, 16, .48); +} + +.promo-modal[hidden] { + display: none; +} + +.promo-card { + position: relative; + width: min(620px, 100%); + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + box-shadow: 0 22px 70px rgba(0, 0, 0, .28); + padding: 22px; +} + +.promo-card .icon-btn { + position: absolute; + top: 14px; + right: 14px; +} + +.promo-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin-top: 18px; +} + +.promo-grid a { + display: grid; + gap: 6px; + min-height: 98px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel-soft); + color: var(--text); + padding: 14px; + text-decoration: none; +} + +.promo-grid a:hover { + border-color: var(--green); +} + +.promo-grid span { + color: var(--muted); + font-size: 12px; +} + +@media (max-width: 1180px) { + .keys-table { + min-width: 0; + table-layout: auto; + } + + .keys-table, + .keys-table thead, + .keys-table tbody, + .keys-table tr, + .keys-table th, + .keys-table td { + display: block; + width: 100%; + } + + .keys-table thead { + display: none; + } + + .keys-table tr { + margin-bottom: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + overflow: hidden; + } + + .keys-table tr:last-child { + margin-bottom: 0; + } + + .keys-table td { + display: grid; + grid-template-columns: 132px minmax(0, 1fr); + gap: 12px; + width: 100% !important; + border-bottom: 1px solid var(--line); + } + + .keys-table td::before { + content: attr(data-label); + color: var(--muted); + font-size: 12px; + font-weight: 800; + text-transform: uppercase; + } + + .keys-table tr:last-child td, + .keys-table td:last-child { + border-bottom: 0; + } + + .keys-table td code { + max-width: 100%; + white-space: nowrap; + } + + .keys-table .mini-actions, + .keys-table .action-buttons { + justify-content: stretch; + flex-wrap: wrap; + } + + .keys-table .mini-actions button, + .keys-table .action-buttons button { + flex: 1 1 130px; + } +} + +@media (max-width: 1280px) { + .key-card { + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-areas: + "user traffic" + "secret links" + "actions actions"; + } + + .service-grid { + grid-template-columns: repeat(3, minmax(150px, 1fr)); + } + + .metric-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .grid-two { + grid-template-columns: 1fr; + } +} + +@media (max-width: 980px) { + .app-shell { + grid-template-columns: 1fr; + } + + .mobile-only { + display: inline-grid; + } + + .sidebar { + position: fixed; + inset: 0 auto 0 0; + z-index: 10; + width: min(300px, calc(100vw - 54px)); + transform: translateX(-105%); + transition: transform .2s ease; + } + + .sidebar.open { + transform: translateX(0); + } +} + +@media (max-width: 720px) { + .visual-overview, + .promo-grid { + grid-template-columns: 1fr; + } + + .topbar { + align-items: flex-start; + padding: 16px; + } + + .top-actions { + width: 100%; + justify-content: flex-start; + } + + .content { + padding: 16px; + } + + .panel { + padding: 14px; + } + + .panel-head { + align-items: flex-start; + flex-direction: column; + } + + .metric-grid, + .service-grid, + .runtime-grid, + .traffic-summary { + grid-template-columns: 1fr; + } + + .inline-form, + .panel-actions { + width: 100%; + } + + .traffic-controls, + .segmented { + width: 100%; + } + + .traffic-controls { + align-items: stretch; + flex-direction: column; + } + + .segmented { + justify-content: stretch; + } + + .segmented button { + flex: 1 1 0; + } + + .inline-form input, + .inline-form select, + .inline-form button, + .panel-actions button, + .panel-actions .status-pill { + width: 100%; + } + + .table-wrap { + overflow: visible; + border: 0; + } + + table, + .keys-table, + thead, + tbody, + tr, + th, + td { + display: block; + min-width: 0; + } + + thead { + display: none; + } + + tr { + margin-bottom: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + overflow: hidden; + } + + td { + display: grid; + grid-template-columns: 120px minmax(0, 1fr); + gap: 12px; + border-bottom: 1px solid var(--line); + } + + td::before { + content: attr(data-label); + color: var(--muted); + font-size: 12px; + font-weight: 800; + text-transform: uppercase; + } + + td.empty-cell { + display: block; + } + + td.empty-cell::before { + display: none; + } + + td code { + max-width: 100%; + white-space: nowrap; + } + + .action-buttons { + display: flex; + justify-content: stretch; + flex-wrap: wrap; + } + + .action-buttons button { + flex: 1 1 140px; + } + + .traffic-main, + .ip-limit-control { + align-items: stretch; + } + + .traffic-main { + flex-direction: column; + } + + .key-card { + grid-template-columns: 1fr; + grid-template-areas: + "user" + "secret" + "links" + "traffic" + "actions"; + } + + .ip-limit-control { + grid-template-columns: 1fr; + } + + .ip-limit-control input, + .ip-limit-control button { + width: 100%; + } + + .backup-item, + .backup-schedule, + .event, + .settings-list > div, + .port-listener, + .port-empty { + grid-template-columns: 1fr; + } + + .mini-actions, + .action-buttons, + .backup-actions { + justify-content: stretch; + } + + .mini-actions button, + .action-buttons button, + .backup-actions button { + flex: 1 1 130px; + } +} + +@media (max-width: 460px) { + .topbar { + display: grid; + grid-template-columns: auto 1fr; + } + + .top-actions { + grid-column: 1 / -1; + } + + td { + grid-template-columns: 1fr; + gap: 4px; + } +} diff --git a/bootstrap.sh b/bootstrap.sh new file mode 100644 index 0000000..c22a750 --- /dev/null +++ b/bootstrap.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# GoTelegram Pro — bootstrap installer from this repository. +set -euo pipefail + +GOTELEGRAM_BASE="${GOTELEGRAM_BASE:-https://raw.githubusercontent.com/andrey271192/gotelegram/main}" +INSTALL_DIR="${GOTELEGRAM_INSTALL_DIR:-/opt/gotelegram}" + +RED='\033[0;31m' +GREEN='\033[0;32m' +CYAN='\033[0;36m' +NC='\033[0m' + +if [ "$(id -u)" -ne 0 ]; then + echo -e " ${RED}✗${NC} Запустите от root" + exit 1 +fi + +for cmd in curl jq; do + command -v "$cmd" >/dev/null 2>&1 || { + apt-get update -qq + apt-get install -y -qq "$cmd" >/dev/null 2>&1 + } +done + +FILES=( + "install.sh" "install_gotelegram_bot.sh" "templates_catalog.json" + "lib/common.sh" "lib/telemt.sh" "lib/telemt_config.sh" "lib/backup.sh" + "lib/website.sh" "lib/templates_catalog.sh" "lib/stats.sh" "lib/i18n.sh" + "lib/lang/en.sh" "lib/lang/ru.sh" + "gotelegram-bot/bot.py" "gotelegram-bot/i18n.py" + "gotelegram-bot/lang/en.json" "gotelegram-bot/lang/ru.json" + "gotelegram-bot/config.example.env" "gotelegram-bot/requirements.txt" "gotelegram-bot/README.md" + "admin-web/server.py" "admin-web/static/index.html" "admin-web/static/styles.css" "admin-web/static/app.js" +) + +curl_headers=() +if [ -n "${GITHUB_TOKEN:-${GH_TOKEN:-}}" ]; then + curl_headers=(-H "Authorization: Bearer ${GITHUB_TOKEN:-${GH_TOKEN:-}}") +fi + +download_file() { + local remote_path="$1" + local local_path="$2" + local attempt http_code + + mkdir -p "$(dirname "$local_path")" + for attempt in 1 2 3; do + http_code=$(curl -sL "${curl_headers[@]}" -w "%{http_code}" -o "$local_path" "${GOTELEGRAM_BASE%/}/${remote_path}" 2>/dev/null || echo "000") + if [ "$http_code" = "200" ]; then + return 0 + fi + sleep 1 + done + + echo -e " ${RED}✗${NC} Ошибка загрузки ${remote_path} (HTTP ${http_code})" + return 1 +} + +echo -e " ${CYAN}↻${NC} Загрузка GoTelegram из ${GOTELEGRAM_BASE%/}..." +mkdir -p "${INSTALL_DIR}/lib/lang" "${INSTALL_DIR}/gotelegram-bot/lang" "${INSTALL_DIR}/admin-web/static" + +failed=0 +for f in "${FILES[@]}"; do + if download_file "$f" "${INSTALL_DIR}/${f}"; then + echo -e " ${GREEN}✓${NC} ${f}" + else + failed=$((failed + 1)) + fi +done +[ "$failed" -eq 0 ] || exit 1 + +chmod +x "${INSTALL_DIR}/install.sh" "${INSTALL_DIR}/install_gotelegram_bot.sh" +chmod +x "${INSTALL_DIR}"/lib/*.sh +chmod +x "${INSTALL_DIR}/admin-web/server.py" 2>/dev/null || true +sed -i 's/\r$//' "${INSTALL_DIR}/install.sh" "${INSTALL_DIR}/install_gotelegram_bot.sh" "${INSTALL_DIR}"/lib/*.sh "${INSTALL_DIR}"/lib/lang/*.sh 2>/dev/null || true +ln -sf "${INSTALL_DIR}/install.sh" /usr/local/bin/gotelegram + +exec bash "${INSTALL_DIR}/install.sh" "$@" diff --git a/gotelegram-bot/README.md b/gotelegram-bot/README.md new file mode 100644 index 0000000..b0326f4 --- /dev/null +++ b/gotelegram-bot/README.md @@ -0,0 +1,161 @@ +# goTelegram Pro v2.5.0 Bot + +Production-quality Telegram bot for managing MTProxy (telemt engine) on Linux servers. + +## Features + +- **Complete CLI Feature Parity** - All menu items from CLI version + - Install (Quick/Stealth modes) + - Status monitoring + - Proxy link generation + - Share with QR codes + - Service restart + - Logs viewing + - Mode/template changes + - Backup/restore + - telemt updates + - Website/SSL management + - Remove installation + - Promotional links + +- **Template Browsing** - Browse categories → templates → preview → install +- **Per-user MTProxy Keys** - Manage telemt `[access.users]` from inline bot menus +- **Per-user QR Import** - Show QR codes for every Telegram proxy key +- **Local Web Admin** - Shows SSH tunnel instructions for the 127.0.0.1:1984 dashboard +- **V1 Migration** - Detects old mtg Docker container and offers migration +- **Access Control** - ALLOWED_IDS from .env +- **Async/Await** - Full async support via python-telegram-bot v21+ +- **Inline Keyboards** - Modern UI with callback-based navigation +- **Shell Integration** - Executes system commands via asyncio subprocess +- **Error Handling** - Production-ready error handling + +## Installation + +Recommended installation path is the main CLI menu: + +```bash +gotelegram +``` + +Then choose `12) Telegram-bot` → install/update. On repeat goTelegram Pro bootstrap/update, an already installed bot is refreshed automatically: code, i18n files and requirements are copied to `/opt/gotelegram-bot`, `.env` is preserved, dependencies are checked and `gotelegram-bot` is restarted. + +### Prerequisites + +- Python 3.8+ +- Linux system with systemd +- telemt installed and running +- Telegram Bot Token from @BotFather + +### Setup + +1. Install dependencies: +```bash +pip install -r requirements.txt +``` + +2. Create .env file: +```bash +cp config.example.env .env +# Edit .env and set your BOT_TOKEN +nano .env +``` + +3. (Optional) Restrict access to specific users: +```bash +# Edit .env and uncomment ALLOWED_IDS +# ALLOWED_IDS=123456789,987654321 +``` + +### Running the Bot + +```bash +python3 bot.py +``` + +For systemd service: + +```bash +[Unit] +Description=goTelegram Pro Bot +After=network.target + +[Service] +Type=simple +WorkingDirectory=/opt/gotelegram-bot +ExecStart=/opt/gotelegram-bot/venv/bin/python /opt/gotelegram-bot/bot.py +Restart=always +RestartSec=5 +Environment=PATH=/opt/gotelegram-bot/venv/bin:/usr/bin + +[Install] +WantedBy=multi-user.target +``` + +## Configuration + +### .env Variables + +- `BOT_TOKEN` - Telegram bot token (required) +- `ALLOWED_IDS` - Comma-separated user IDs (optional, all users allowed if empty) +- `BOT_LANG` - Default language inherited from goTelegram Pro install language + +### System Paths + +- `GOTELEGRAM_CONFIG` - `/opt/gotelegram/config.json` +- `TELEMT_CONFIG` - `/etc/telemt/config.toml` +- `TELEMT_SERVICE` - `telemt` (systemd service name) +- `WEBSITE_ROOT` - `/var/www/gotelegram-site` +- `BACKUP_DIR` - `/opt/gotelegram/backups` +- `BACKUP_SCHEDULE_FILE` - `/opt/gotelegram/backup_schedule.json` +- `TEMPLATES_CATALOG` - `/opt/gotelegram/templates_catalog.json` + +## Architecture + +### Single File Design +All functionality in one `bot.py` for simplicity and ease of deployment. + +### Command Handlers +- `/start` - Main menu +- `/help` - Help text +- `/status` - Quick status +- `/logs` - Recent logs + +### Callback Handlers +Organized by feature: +- Installation (quick/stealth modes) +- Status monitoring +- Backup/restore +- Backup schedules: off, daily, weekly, monthly +- SSL management +- Updates +- Removal + +### Shell Integration +Async subprocess wrapper: +```python +code, stdout, stderr = await sh("command", "arg1", "arg2") +``` + +## Callback Data Convention + +- `menu_*` - Menu items +- `install_mode_*` - Install options +- `quick_dom_*` - Domain selection +- `stealth_cat_*` - Template categories +- `stealth_tpl_*` - Template selection +- `stealth_confirm_*` - Confirm installation +- `backup_*` - Backup operations +- `ssl_*` - SSL operations +- `restore_backup_*` - Restore operations + +## Credits + +- **telemt** - MTProxy engine foundation +- **HTML5UP** - Beautiful web templates +- **Learning Zone** - Educational resources +- **Start Bootstrap** - Bootstrap framework +- **Community** - Your feedback and support + +## License + +goTelegram Pro v2.5.0 - Open source community project diff --git a/gotelegram-bot/bot.py b/gotelegram-bot/bot.py new file mode 100644 index 0000000..bc95dd9 --- /dev/null +++ b/gotelegram-bot/bot.py @@ -0,0 +1,3361 @@ +#!/usr/bin/env python3 +""" +goTelegram Pro v2.5.0 Bot - MTProxy Management for Linux +Manages telemt engine via Telegram interface with full CLI feature parity +Uses python-telegram-bot v21+ +Supports EN/RU UI with per-user language preferences. +""" + +import asyncio +import csv +import fcntl +import hashlib +import html +import json +import logging +import os +import re +import shlex +import shutil +import subprocess +import sys +import time +import toml +from datetime import datetime +from io import StringIO +from pathlib import Path +from typing import Tuple, Optional, List, Dict, Any +from urllib.parse import quote + +from dotenv import load_dotenv +from telegram import ( + Update, + InlineKeyboardButton, + InlineKeyboardMarkup, + InputFile, +) +from telegram.ext import ( + Application, + CommandHandler, + CallbackQueryHandler, + ContextTypes, + MessageHandler, + filters, +) +from telegram.error import TelegramError, BadRequest + +# i18n — loaded from the bot directory next to this file +_BOT_DIR = Path(__file__).resolve().parent +if str(_BOT_DIR) not in sys.path: + sys.path.insert(0, str(_BOT_DIR)) +try: + from i18n import ( + t as _t, + tf as _tf, + get_user_lang, + set_user_lang, + get_language_name, + SUPPORTED_LANGS, + ) +except Exception as _i18n_err: # pragma: no cover — defensive fallback + logging.warning("i18n module not available: %s", _i18n_err) + + def _t(user_id, key, default=None): + return default if default is not None else key + + def _tf(user_id, key, *args, default=None): + template = default if default is not None else key + try: + return template % args if args else template + except Exception: + return template + + def get_user_lang(user_id): + return "en" + + def set_user_lang(user_id, code): + return False + + def get_language_name(code): + return code + + SUPPORTED_LANGS = ("en",) + + +def _uid(update: Optional[Update]) -> Optional[int]: + """Extract user id from an update (if any).""" + if update is None: + return None + user = getattr(update, "effective_user", None) + return user.id if user else None + +# Load environment variables +load_dotenv() + +# Logging configuration +logging.basicConfig( + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + level=logging.INFO, +) +logger = logging.getLogger(__name__) + +# ============================================================================ +# CONFIGURATION +# ============================================================================ + +GOTELEGRAM_VERSION = "2.5.0" +GOTELEGRAM_CONFIG = "/opt/gotelegram/config.json" +DISABLED_USERS_FILE = "/opt/gotelegram/disabled_users.json" +USER_STATS_HISTORY = "/opt/gotelegram/user_stats_history.csv" +USER_LOCK_FILE = "/run/gotelegram/admin-users.lock" +TELEMT_CONFIG = "/etc/telemt/config.toml" +TELEMT_SERVICE = "telemt" +WEBSITE_ROOT = "/var/www/gotelegram-site" +BACKUP_DIR = "/opt/gotelegram/backups" +BACKUP_SCHEDULE_FILE = "/opt/gotelegram/backup_schedule.json" +TEMPLATES_CATALOG = "/opt/gotelegram/templates_catalog.json" +INSTALL_SH = "/opt/gotelegram/install.sh" + +PROMO_LINK_1 = "https://vk.cc/ct29NQ" +PROMO_LINK_2 = "https://vk.cc/cUxAhj" +TIP_LINK = "https://pay.cloudtips.ru/p/7410814f" +YOUTUBE_LINK = os.getenv("GOTELEGRAM_YOUTUBE_LINK", "").strip() +PROMO_STAMP_FILE = "/opt/gotelegram/.promo_bot_last_shown" + +BOT_TOKEN = os.getenv("BOT_TOKEN") +ENV_FILE = "/opt/gotelegram-bot/.env" +ADMIN_WEB_SERVICE = "gotelegram-admin" +ADMIN_WEB_PORT = 1984 + + +def format_bytes_human(value: int) -> str: + value = max(0, int(value or 0)) + if value < 1024: + return f"{value} B" + if value < 1024 * 1024: + return f"{value / 1024:.1f} KB" + if value < 1024 * 1024 * 1024: + return f"{value / 1024 / 1024:.1f} MB" + return f"{value / 1024 / 1024 / 1024:.1f} GB" + +# ── Загрузка ALLOWED_IDS ──────────────────────────────────────────────────── +# Поддерживает запятую, пробел, или их комбинацию как разделитель +ALLOWED_IDS: set = set() +_WAITING_FOR_ADMIN = False # True если список пуст → ждём первого админа + + +def _load_allowed_ids() -> None: + """Загрузить ALLOWED_IDS из переменной окружения.""" + global ALLOWED_IDS, _WAITING_FOR_ADMIN + raw = os.getenv("ALLOWED_IDS", "") + ALLOWED_IDS = set() + # Разделители: запятая, пробел, или оба + for part in re.split(r'[,\s]+', raw): + part = part.strip() + if part: + try: + ALLOWED_IDS.add(int(part)) + except ValueError: + logging.warning(f"Invalid ALLOWED_IDS entry: {part}") + _WAITING_FOR_ADMIN = len(ALLOWED_IDS) == 0 + + +def _save_allowed_ids() -> None: + """Сохранить ALLOWED_IDS в .env файл и обновить os.environ.""" + global _WAITING_FOR_ADMIN + ids_str = ",".join(str(i) for i in sorted(ALLOWED_IDS)) + os.environ["ALLOWED_IDS"] = ids_str + _WAITING_FOR_ADMIN = len(ALLOWED_IDS) == 0 + + if not os.path.exists(ENV_FILE): + return + + try: + with open(ENV_FILE, "r") as f: + lines = f.readlines() + + found = False + new_lines = [] + for line in lines: + if line.strip().startswith("ALLOWED_IDS="): + if ids_str: + new_lines.append(f"ALLOWED_IDS={ids_str}\n") + # Если пусто — удаляем строку + found = True + else: + new_lines.append(line) + + if not found and ids_str: + new_lines.append(f"ALLOWED_IDS={ids_str}\n") + + with open(ENV_FILE, "w") as f: + f.writelines(new_lines) + + logger.info(f"ALLOWED_IDS updated in .env: {ids_str or '(empty)'}") + except OSError as e: + logger.error(f"Failed to update .env: {e}") + + +_load_allowed_ids() + +LITE_DOMAINS = [ + "google.com", + "microsoft.com", + "cloudflare.com", + "apple.com", + "amazon.com", + "github.com", + "stackoverflow.com", + "medium.com", + "wikipedia.org", + "coursera.org", + "udemy.com", + "habr.com", + "stepik.org", + "duolingo.com", + "khanacademy.org", + "bbc.com", + "reuters.com", + "nytimes.com", + "ted.com", + "zoom.us", +] + +# ============================================================================ +# UTILITY FUNCTIONS +# ============================================================================ + + +async def sh(*args, timeout: int = 60) -> Tuple[int, str, str]: + """Execute shell command asynchronously. + + Args: + *args: Command and arguments + timeout: Timeout in seconds + + Returns: + Tuple of (return_code, stdout, stderr) + """ + try: + process = await asyncio.create_subprocess_exec( + *args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await asyncio.wait_for( + process.communicate(), timeout=timeout + ) + return ( + process.returncode, + stdout.decode("utf-8", errors="replace"), + stderr.decode("utf-8", errors="replace"), + ) + except asyncio.TimeoutError: + try: + process.kill() + await process.wait() + except Exception: + pass + return (-1, "", f"Command timeout after {timeout}s") + except Exception as e: + return (-1, "", str(e)) + + +# Per-host mutex preventing concurrent install.sh --action invocations. Two +# admins hitting "change template" at the same second could race each other +# and corrupt /var/www/gotelegram-site. One global lock is fine — these are +# rare operations and should serialize cleanly. +_BOT_ACTION_LOCK = asyncio.Lock() + +# Allowed template-id shape: catalog ids are [a-zA-Z0-9_-], never longer than 64. +# This is a defense-in-depth check before we hand the value to subprocess. +_TPL_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$") + +# Allowed Lite mask domain shape — simple DNS hostname, up to 253 chars total. +# Each label 1–63 chars, labels separated by dots, alphanumerics + hyphens. +_DOMAIN_RE = re.compile( + r"^(?=.{1,253}$)(?:(?!-)[A-Za-z0-9-]{1,63}(? Dict: + """Invoke install.sh --action=X --json and parse the JSON result. + + Args: + action: action name (e.g. "change-template", "change-lite-domain") + timeout: seconds to wait for completion (long ops: template download can take time) + **params: arbitrary key→value pairs, each passed as --key=value + + Returns: + dict with at least {"status": "success|error", "message": "..."}. + Transport errors are mapped to {"status":"error","message":..., "code":"transport"} + """ + cmd = ["bash", INSTALL_SH, f"--action={action}", "--json"] + for k, v in params.items(): + if v is None: + continue + cmd.append(f"--{k.replace('_', '-')}={v}") + + code, stdout, stderr = await sh(*cmd, timeout=timeout) + stdout = (stdout or "").strip() + + # install.sh may print multiple log lines to stderr; the JSON is on stdout. + # Pick the last non-empty line that looks like JSON (robust to any stray output). + json_line = None + for line in reversed(stdout.splitlines()): + line = line.strip() + if line.startswith("{") and line.endswith("}"): + json_line = line + break + + if json_line: + try: + data = json.loads(json_line) + if isinstance(data, dict) and "status" in data: + return data + except json.JSONDecodeError as e: + logger.warning(f"run_bot_action: JSON parse failed: {e} | line={json_line!r}") + + # No JSON from install.sh — synthesize an error result + tail = (stderr or "")[-300:] if stderr else "" + logger.error( + f"run_bot_action({action}): no JSON output, rc={code}, " + f"stdout={stdout[-300:]!r}, stderr={tail!r}" + ) + return { + "status": "error", + "message": "install.sh did not return a JSON result", + "code": "transport", + "rc": str(code), + } + + +def load_json(path: str) -> Optional[Dict]: + """Load JSON file.""" + try: + with open(path, "r") as f: + return json.load(f) + except Exception as e: + logger.warning(f"Failed to load {path}: {e}") + return None + + +def load_toml(path: str) -> Optional[Dict]: + """Load TOML file.""" + try: + with open(path, "r") as f: + return toml.load(f) + except Exception as e: + logger.warning(f"Failed to load {path}: {e}") + return None + + +def save_json(path: str, data: Dict) -> bool: + """Save JSON file.""" + try: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as f: + json.dump(data, f, indent=2) + return True + except Exception as e: + logger.error(f"Failed to save {path}: {e}") + return False + + +def template_display_name(template_id: str) -> str: + """Resolve a template id to a human-friendly name from catalog/config.""" + if not template_id: + return "" + if template_id in ("deployed_site", "existing_site"): + return "Existing deployed site" + if template_id.startswith("custom_"): + config = load_json(GOTELEGRAM_CONFIG) or {} + source = config.get("template_source", "") + return f"{template_id} ({source})" if source else template_id + catalog = load_json(TEMPLATES_CATALOG) or {} + for cat in catalog.get("categories", []): + for tpl in cat.get("templates", []): + if tpl.get("id") == template_id: + return f"{tpl.get('name', template_id)} ({template_id})" + return template_id + + +def pro_template_map(context: ContextTypes.DEFAULT_TYPE) -> Dict[str, str]: + """Return the short callback key -> template id map for this chat.""" + mapping = context.user_data.setdefault("pro_template_map", {}) + if not isinstance(mapping, dict): + mapping = {} + context.user_data["pro_template_map"] = mapping + return mapping + + +def resolve_pro_template_id(context: ContextTypes.DEFAULT_TYPE, key_or_id: str) -> str: + """Resolve a short Telegram callback key back to the real template id.""" + mapped = pro_template_map(context).get(key_or_id) + if mapped: + return str(mapped) + + catalog = load_json(TEMPLATES_CATALOG) or {} + for cat in catalog.get("categories", []): + for tpl in cat.get("templates", []): + template_id = str(tpl.get("id", "")) + if hashlib.sha1(template_id.encode("utf-8")).hexdigest()[:12] == key_or_id: + return template_id + + return str(key_or_id) + + +def pro_template_key_for_id(context: ContextTypes.DEFAULT_TYPE, template_id: str) -> str: + """Store a template id behind a short key that fits Telegram callback limits.""" + mapping = pro_template_map(context) + template_id = str(template_id) + for key, stored_id in mapping.items(): + if stored_id == template_id: + return str(key) + key = hashlib.sha1(template_id.encode("utf-8")).hexdigest()[:12] + mapping[key] = template_id + return key + + +async def safe_edit_message( + query, + text: str, + reply_markup=None, + parse_mode=None, + disable_web_page_preview: Optional[bool] = None, +) -> bool: + """Safely edit message, handling cases where message was deleted or not modified. + + `disable_web_page_preview` is forwarded to edit_message_text when set; omitting + it keeps Telegram's default (enabled). + """ + kwargs = {"reply_markup": reply_markup, "parse_mode": parse_mode} + if disable_web_page_preview is not None: + kwargs["disable_web_page_preview"] = disable_web_page_preview + try: + await query.edit_message_text(text, **kwargs) + return True + except BadRequest as e: + err_msg = str(e).lower() + if "message is not modified" in err_msg: + return True # No change needed, not an error + if "message to edit not found" in err_msg or "message can't be edited" in err_msg: + logger.warning(f"Cannot edit message: {e}") + return False + raise # Re-raise unexpected BadRequest + + +async def _delete_message_after(message, delay: int = 30) -> None: + """Delete a Telegram message after `delay` seconds. Errors are swallowed + (message may already be deleted by the user). Used for ephemeral content + like promo blocks that should auto-cleanup.""" + try: + await asyncio.sleep(delay) + await message.delete() + except Exception as e: + logger.debug(f"_delete_message_after: {e}") + + +async def check_service_status(service: str) -> bool: + """Check if systemd service is running.""" + code, _, _ = await sh("systemctl", "is-active", service) + return code == 0 + + +async def get_telemt_version() -> str: + """Get telemt version.""" + for command in ("telemt", "/usr/local/bin/telemt", "/usr/bin/telemt"): + for args in (("--version",), ("-V",)): + code, stdout, _ = await sh(command, *args, timeout=5) + if code == 0 and stdout.strip(): + return stdout.strip().split()[-1] + return "unknown" + + +def is_docker_running() -> bool: + """Check if Docker daemon is running.""" + try: + subprocess.run( + ["docker", "ps"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=5, + ) + return True + except Exception: + return False + + +async def check_old_container() -> Optional[str]: + """Check for old mtg Docker container (v1 migration).""" + if not is_docker_running(): + return None + code, stdout, _ = await sh("docker", "ps", "-a", "--format", "{{.Names}}") + if code == 0 and "mtg" in stdout: + return "mtg" + return None + + +# ============================================================================ +# ACCESS CONTROL +# ============================================================================ + + +def is_user_allowed(user_id: int) -> bool: + """Check if user ID is in ALLOWED_IDS. If list is empty — waiting for admin.""" + if _WAITING_FOR_ADMIN: + return False # Никому не даём доступ пока не назначен админ + return user_id in ALLOWED_IDS + + +def add_admin(user_id: int) -> None: + """Добавить администратора и сохранить в .env.""" + ALLOWED_IDS.add(user_id) + _save_allowed_ids() + logger.info(f"Admin added: {user_id}") + + +def remove_admin(user_id: int) -> None: + """Убрать администратора и сохранить в .env.""" + ALLOWED_IDS.discard(user_id) + _save_allowed_ids() + logger.info(f"Admin removed: {user_id}") + + +async def require_auth(update: Update, context: ContextTypes.DEFAULT_TYPE) -> bool: + """Check authorization and send error if not allowed.""" + user_id = update.effective_user.id + + # Режим ожидания первого админа — обрабатывается в cmd_start + if _WAITING_FOR_ADMIN: + return False + + if not is_user_allowed(user_id): + if update.message: + await update.message.reply_text( + f"⛔ Доступ запрещён.\nВаш ID: {user_id}", + parse_mode="HTML", + ) + logger.warning(f"Unauthorized access attempt from user {user_id}") + return False + return True + + +# ============================================================================ +# MAIN MENU +# ============================================================================ + + +def get_main_menu(user_id: Optional[int] = None) -> InlineKeyboardMarkup: + """Generate main menu keyboard localized for the given user.""" + buttons = [ + [ + InlineKeyboardButton(_t(user_id, "menu_install"), callback_data="menu_install"), + InlineKeyboardButton(_t(user_id, "menu_status"), callback_data="menu_status"), + ], + [ + InlineKeyboardButton(_t(user_id, "menu_link"), callback_data="menu_link"), + InlineKeyboardButton(_t(user_id, "menu_share"), callback_data="menu_share"), + ], + [ + InlineKeyboardButton(_t(user_id, "menu_restart"), callback_data="menu_restart"), + InlineKeyboardButton(_t(user_id, "menu_logs"), callback_data="menu_logs"), + ], + [ + InlineKeyboardButton(_t(user_id, "menu_change"), callback_data="menu_change"), + InlineKeyboardButton(_t(user_id, "menu_backup"), callback_data="menu_backup"), + ], + [ + InlineKeyboardButton(_t(user_id, "menu_restore"), callback_data="menu_restore"), + InlineKeyboardButton(_t(user_id, "menu_update"), callback_data="menu_update"), + ], + [ + InlineKeyboardButton(_t(user_id, "menu_website"), callback_data="menu_website"), + InlineKeyboardButton(_t(user_id, "menu_promo"), callback_data="menu_promo"), + ], + [ + InlineKeyboardButton(_t(user_id, "menu_stats"), callback_data="menu_stats"), + InlineKeyboardButton(_t(user_id, "menu_users"), callback_data="menu_users"), + ], + [ + InlineKeyboardButton(_t(user_id, "menu_admin_web"), callback_data="menu_admin_web"), + InlineKeyboardButton(_t(user_id, "menu_admins"), callback_data="menu_admins"), + ], + [ + InlineKeyboardButton(_t(user_id, "menu_remove"), callback_data="menu_remove"), + InlineKeyboardButton(_t(user_id, "menu_credits"), callback_data="menu_credits"), + ], + [ + InlineKeyboardButton(_t(user_id, "menu_language"), callback_data="menu_lang"), + InlineKeyboardButton(_t(user_id, "menu_close"), callback_data="close_menu"), + ], + ] + return InlineKeyboardMarkup(buttons) + + +# ============================================================================ +# COMMANDS +# ============================================================================ + + +async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Start command - show main menu, promo once per day. + + Если ALLOWED_IDS пуст — режим авто-регистрации первого админа. + """ + user = update.effective_user + user_id = user.id + + # ── Режим ожидания первого админа ── + if _WAITING_FOR_ADMIN: + name = user.full_name or user.username or str(user_id) + title = _tf(user_id, "waiting_admin_title", html.escape(name)) + body = _tf(user_id, "waiting_admin_body", user_id) + text = f"{title}\n\n{body}" + keyboard = InlineKeyboardMarkup([ + [ + InlineKeyboardButton(_t(user_id, "btn_yes"), callback_data=f"admin_confirm_{user_id}"), + InlineKeyboardButton(_t(user_id, "btn_no"), callback_data="admin_cancel"), + ] + ]) + await update.message.reply_text(text, reply_markup=keyboard, parse_mode="HTML") + return + + # ── Проверка доступа ── + if not is_user_allowed(user_id): + await update.message.reply_text( + _tf(user_id, "access_denied", user_id), + parse_mode="HTML", + ) + return + + welcome = ( + f"{_tf(user_id, 'welcome_title', GOTELEGRAM_VERSION)}\n\n" + f"{_t(user_id, 'welcome_subtitle')}\n" + f"{_t(user_id, 'welcome_powered')}\n\n" + f"{_t(user_id, 'welcome_prompt')}" + ) + await update.message.reply_text( + welcome, reply_markup=get_main_menu(user_id), parse_mode="HTML" + ) + + # Промо раз в сутки — сообщение само удаляется через 30 секунд + if should_show_promo_bot(): + mark_promo_shown_bot() + promo_msg = await update.message.reply_text( + get_promo_text(), parse_mode="HTML", disable_web_page_preview=True + ) + asyncio.create_task(_delete_message_after(promo_msg, 30)) + + +async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Help command - show available commands.""" + if not await require_auth(update, context): + return + user_id = _uid(update) + help_text = ( + f"{_t(user_id, 'help_title')}\n\n" + f"{_t(user_id, 'help_lines')}" + ) + await update.message.reply_text(help_text, parse_mode="HTML") + + +async def cmd_lang(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Show language picker.""" + if not await require_auth(update, context): + return + user_id = _uid(update) + current = get_user_lang(user_id) + title = _t(user_id, "lang_title") + curr_line = _tf(user_id, "lang_current", get_language_name(current)) + prompt = _t(user_id, "lang_choose") + text = f"{title}\n\n{curr_line}\n\n{prompt}" + keyboard = InlineKeyboardMarkup([ + [ + InlineKeyboardButton("🇬🇧 English", callback_data="lang_set_en"), + InlineKeyboardButton("🇷🇺 Русский", callback_data="lang_set_ru"), + ], + [InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_main")], + ]) + await update.message.reply_text(text, reply_markup=keyboard, parse_mode="HTML") + + +async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Quick status check.""" + if not await require_auth(update, context): + return + user_id = _uid(update) + await update.message.reply_text(_t(user_id, "status_checking"), parse_mode="HTML") + status_text = await get_status_text(user_id) + await update.message.reply_text(status_text, parse_mode="HTML") + + +async def cmd_logs(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Show recent logs.""" + if not await require_auth(update, context): + return + user_id = _uid(update) + + code, stdout, stderr = await sh( + "journalctl", "-u", TELEMT_SERVICE, "-n", "20", "--no-pager" + ) + if code == 0: + log_text = stdout[-1500:] if len(stdout) > 1500 else stdout + await update.message.reply_text( + f"
{html.escape(log_text)}
", + parse_mode="HTML", + ) + else: + await update.message.reply_text(_t(user_id, "logs_failed")) + + +# ============================================================================ +# STATUS +# ============================================================================ + + +async def get_status_text(user_id: Optional[int] = None) -> str: + """Generate status report (localized).""" + lines = [f"{_t(user_id, 'status_title')}\n"] + + # Service status + is_running = await check_service_status(TELEMT_SERVICE) + running = _t(user_id, "status_running") if is_running else _t(user_id, "status_stopped") + lines.append(f"{_t(user_id, 'status_service')}: {running}") + + # Telemt version + version = await get_telemt_version() + lines.append(f"{_t(user_id, 'status_telemt')}: v{version}") + + # Config status + config = load_json(GOTELEGRAM_CONFIG) + if config: + lines.append(f"{_t(user_id, 'status_mode')}: {html.escape(str(config.get('mode', 'unknown')))}") + # install.sh/save_gotelegram_config uses "template_id" (not "template") + tpl = config.get("template_id") or config.get("template") + if tpl: + lines.append(f"{_t(user_id, 'status_template')}: {html.escape(template_display_name(str(tpl)))}") + if config.get("domain"): + lines.append(f"{_t(user_id, 'status_domain')}: {html.escape(str(config['domain']))}") + if config.get("port"): + lines.append(f"{_t(user_id, 'status_port')}: {html.escape(str(config['port']))}") + + # Telemt config (v3: [server] port = ..., [censorship] tls_domain = ...) + telemt_cfg = load_toml(TELEMT_CONFIG) + if telemt_cfg: + server_cfg = telemt_cfg.get("server", {}) + if "port" in server_cfg: + lines.append(f"{_t(user_id, 'status_listen_port')}: {server_cfg['port']}") + censor_cfg = telemt_cfg.get("censorship", {}) + if "tls_domain" in censor_cfg: + lines.append(f"{_t(user_id, 'status_tls_domain')}: {html.escape(str(censor_cfg['tls_domain']))}") + + # Backups + backup_count = 0 + try: + if os.path.exists(BACKUP_DIR): + backup_count = len([f for f in os.listdir(BACKUP_DIR) if f.endswith((".tar.gz", ".tar.gz.enc")) and not f.endswith(".sha256")]) + except Exception: + pass + lines.append(f"Backups: {backup_count}") + + # Old container check + old_container = await check_old_container() + if old_container: + lines.append(f"\n⚠️ Found old container: {html.escape(old_container)}") + lines.append("Run 'Install' to migrate") + + return "\n".join(lines) + + +async def get_traffic_stats() -> str: + """Get formatted traffic statistics.""" + await sh( + "bash", + "-lc", + "source /opt/gotelegram/lib/common.sh; " + "source /opt/gotelegram/lib/stats.sh; " + "stats_init >/dev/null 2>&1 || true; stats_collect >/dev/null 2>&1 || true", + timeout=15, + ) + + # Read current snapshot + current_file = "/run/gotelegram/stats_current.json" + history_file = "/opt/gotelegram/stats_history.csv" + + try: + with open(current_file, "r") as f: + current = json.load(f) + except Exception: + return "📊 Статистика\n\nДанные недоступны. Убедитесь что модуль статистики включён." + + # Read history + history = [] + try: + with open(history_file, "r") as f: + reader = csv.reader(f) + for row in reader: + if len(row) >= 3: + if not row[0].isdigit(): + continue + history.append({ + "ts": int(row[0]), + "proxy": int(row[1]), + "site": int(row[2]), + }) + except Exception: + pass + + now = int(time.time()) + + def format_bytes(b): + if b < 1024: + return f"{b} B" + if b < 1048576: + return f"{b/1024:.1f} KB" + if b < 1073741824: + return f"{b/1048576:.1f} MB" + return f"{b/1073741824:.1f} GB" + + def format_rate(bps): + if bps < 1024: + return f"{bps:.0f} B/s" + if bps < 1048576: + return f"{bps/1024:.1f} KB/s" + return f"{bps/1048576:.1f} MB/s" + + def calc_for_period(secs, key): + target_ts = now - secs + # Find closest snapshot to target_ts + closest = None + for h in history: + if h["ts"] <= target_ts: + if closest is None or h["ts"] > closest["ts"]: + closest = h + if closest is None: + return "—", "—" + + current_val = current.get(f"{key}_bytes", 0) + diff = current_val - closest[key] + if diff < 0: + diff = 0 + elapsed = now - closest["ts"] + if elapsed <= 0: + elapsed = 1 + rate = diff / elapsed + return format_bytes(diff), format_rate(rate) + + periods = [ + ("1 мин", 60), + ("5 мин", 300), + ("60 мин", 3600), + ("1 день", 86400), + ("7 дней", 604800), + ("30 дней", 2592000), + ("365 дней", 31536000), + ] + + lines = ["📊 Статистика трафика\n"] + + for label, key in [("Proxy (telemt)", "proxy"), ("Сайт (nginx)", "site")]: + lines.append(f"\n{label}:") + lines.append("
")
+        lines.append(f"{'Период':<10} │ {'Трафик':>10} │ {'Скорость':>10}")
+        lines.append("─" * 36)
+        for name, secs in periods:
+            total, rate = calc_for_period(secs, key)
+            lines.append(f"{name:<10} │ {total:>10} │ {rate:>10}")
+        lines.append("
") + + return "\n".join(lines) + + +async def cb_menu_stats(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Show traffic statistics.""" + query = update.callback_query + await query.answer() + + stats_text = await get_traffic_stats() + + keyboard = [ + [InlineKeyboardButton("🔄 Обновить", callback_data="menu_stats")], + [InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")], + ] + + await safe_edit_message( + query, + stats_text, + reply_markup=InlineKeyboardMarkup(keyboard), + parse_mode="HTML", + ) + + +async def cb_menu_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Status callback — show detailed proxy/server status.""" + query = update.callback_query + await query.answer() + + if not await require_auth(update, context): + return + + text = await get_status_text(_uid(update)) + keyboard = [ + [InlineKeyboardButton("🔄 Обновить", callback_data="menu_status")], + [InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")], + ] + await safe_edit_message( + query, + text, + reply_markup=InlineKeyboardMarkup(keyboard), + parse_mode="HTML", + ) + + +# ============================================================================ +# INSTALL +# ============================================================================ + + +def get_install_mode_menu(user_id: Optional[int] = None) -> InlineKeyboardMarkup: + """Install mode selection menu.""" + buttons = [ + [InlineKeyboardButton("⚡ Lite", callback_data="install_mode_lite")], + [InlineKeyboardButton("🛡 Pro", callback_data="install_mode_pro")], + [InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_main")], + ] + return InlineKeyboardMarkup(buttons) + + +async def cb_menu_install(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Install menu callback.""" + query = update.callback_query + await query.answer() + + # Check for old container + old_container = await check_old_container() + if old_container: + text = ( + f"⚠️ Migration from v1 detected\n\n" + f"Found Docker container: {html.escape(old_container)}\n\n" + f"Would you like to:\n" + f"1. Migrate from v1 (recommended)\n" + f"2. Fresh install (will remove old container)\n\n" + f"Select below or choose install mode" + ) + buttons = [ + [InlineKeyboardButton("🔄 Migrate from v1", callback_data="install_migrate")], + [InlineKeyboardButton("✨ Fresh Install", callback_data="install_mode_lite")], + [InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")], + ] + keyboard = InlineKeyboardMarkup(buttons) + else: + text = "Select installation mode:" + keyboard = get_install_mode_menu(_uid(update)) + + await safe_edit_message(query, + text, reply_markup=keyboard, parse_mode="HTML" + ) + + +async def cb_install_mode_lite(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Lite mode domain selection.""" + query = update.callback_query + await query.answer() + + # Show domains with pagination (4 per row, 2 rows) + buttons = [] + for i in range(0, len(LITE_DOMAINS), 2): + row = [] + for j in range(2): + if i + j < len(LITE_DOMAINS): + domain = LITE_DOMAINS[i + j] + row.append( + InlineKeyboardButton( + domain, callback_data=f"lite_dom_{i+j}" + ) + ) + buttons.append(row) + + buttons.append([InlineKeyboardButton("« Back", callback_data="menu_install")]) + + text = "Select a domain for Lite mode:" + keyboard = InlineKeyboardMarkup(buttons) + await safe_edit_message(query,text, reply_markup=keyboard) + + +async def cb_lite_domain(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Lite domain selection callback — real implementation (v2.4.2+). + + Branches on current mode: + * lite mode (active): invoke `install.sh --action=change-lite-domain` + which regenerates the telemt TOML with a new fake-TLS mask domain and + restarts the service. Preserves secret/port. + * any other mode: route to CLI. Fresh Lite install is interactive. + """ + query = update.callback_query + data = query.data + try: + domain_idx = int(data.split("_")[-1]) + domain = LITE_DOMAINS[domain_idx] + except (ValueError, IndexError): + await query.answer("Invalid domain selection") + return + + # Defense-in-depth: LITE_DOMAINS is trusted, but validate the shape anyway + # in case someone extends the list with garbage later. + if not _DOMAIN_RE.match(domain): + logger.warning(f"cb_lite_domain: rejecting malformed domain {domain!r}") + await query.answer("Invalid domain") + return + + await query.answer() + + config = load_json(GOTELEGRAM_CONFIG) or {} + current_mode = config.get("mode", "") + + if current_mode != "lite": + text = ( + "⚠️ Установка Lite из бота пока не поддерживается\n\n" + f"Выбранный домен: {html.escape(domain)}\n\n" + "Чтобы установить Lite, запустите на сервере:\n" + "gotelegram1) Прокси → 1) Установить/Обновить → Lite\n\n" + "Существующая конфигурация не была изменена." + ) + keyboard = InlineKeyboardMarkup( + [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]] + ) + await safe_edit_message(query, text, reply_markup=keyboard, parse_mode="HTML") + return + + # Lite active — switch fake-TLS mask domain in place + if _BOT_ACTION_LOCK.locked(): + await safe_edit_message( + query, + "⏳ Другая операция уже выполняется\n\n" + "Дождитесь завершения предыдущей смены шаблона/домена и повторите.", + reply_markup=InlineKeyboardMarkup( + [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]] + ), + parse_mode="HTML", + ) + return + + progress_text = ( + "⏳ Меняю маскировочный домен...\n\n" + f"Новый домен: {html.escape(domain)}\n\n" + "Перегенерирую конфиг telemt и перезапускаю сервис." + ) + await safe_edit_message(query, progress_text, parse_mode="HTML") + + async with _BOT_ACTION_LOCK: + result = await run_bot_action("change-lite-domain", timeout=30, domain=domain) + + if result.get("status") == "success": + text = ( + "✅ Маскировочный домен обновлён\n\n" + f"Новый домен: {html.escape(domain)}\n\n" + "telemt перезапущен. Важно: старые ссылки подключения больше " + "не будут работать — нужно заново раздать новые." + ) + else: + err_msg = result.get("message", "unknown error") + err_code = result.get("code", "") + text = ( + "❌ Не удалось сменить домен\n\n" + f"Домен: {html.escape(domain)}\n" + f"Причина: {html.escape(err_msg)}" + + (f" ({html.escape(err_code)})" if err_code else "") + + "\n\n" + "Существующая конфигурация не была изменена." + ) + + keyboard = InlineKeyboardMarkup( + [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]] + ) + await safe_edit_message(query, text, reply_markup=keyboard, parse_mode="HTML") + + +async def cb_install_mode_pro(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Pro mode - show template categories.""" + query = update.callback_query + await query.answer() + + catalog = load_json(TEMPLATES_CATALOG) + if not catalog or "categories" not in catalog: + await safe_edit_message(query, + "❌ Template catalog not found", + reply_markup=InlineKeyboardMarkup( + [[InlineKeyboardButton("« Back", callback_data="menu_install")]] + ), + ) + return + + user_id = _uid(update) + buttons = [] + # First item: custom git template (matches CLI behaviour) + buttons.append([InlineKeyboardButton( + _t(user_id, "cg_title"), callback_data="pro_custom_git" + )]) + for cat in catalog.get("categories", []): + buttons.append( + [ + InlineKeyboardButton( + f"📁 {cat['name']}", callback_data=f"pro_cat_{cat['id']}" + ) + ] + ) + buttons.append([InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_install")]) + + text = "Pro Mode — Select Template Category:" + keyboard = InlineKeyboardMarkup(buttons) + await safe_edit_message(query, text, reply_markup=keyboard) + + +# ── Custom git template input flow ────────────────────────────────────────── +_CUSTOM_GIT_WAITERS: Dict[int, bool] = {} +_CUSTOM_GIT_URL_RE = re.compile(r'^https://[A-Za-z0-9._~:/\-?#\[\]@!$&\'()*+,;=%]+(@[A-Za-z0-9._\-/]+)?$') +_CUSTOM_GIT_MAX_MB = 100 +_CUSTOM_GIT_CLONE_TIMEOUT = 90 + + +def _validate_custom_git_url(url: str) -> bool: + if not url or len(url) > 512: + return False + # Block shell metacharacters explicitly + for bad in (" ", "`", "$", "(", ")", "<", ">", "|", "\\", "\t", "\n", "\r", ";", "&", "'", '"'): + if bad in url: + return False + if not url.lower().startswith("https://"): + return False + # Reject embedded userinfo (https://user:pass@host/...) to prevent credential leakage. + # We look at the netloc — anything between https:// and the first '/'. + rest = url[len("https://"):] + netloc_end = rest.find("/") + netloc = rest if netloc_end == -1 else rest[:netloc_end] + if "@" in netloc: + return False + # Hostname sanity: no empty host, no whitespace already blocked above + if not netloc or netloc.startswith(":") or netloc.endswith(":"): + return False + return True + + +async def _download_custom_git_template(url_with_branch: str) -> Tuple[bool, str, str]: + """Clone a custom git repo and stage its static site under WEBSITE_ROOT. + + Returns (ok, tpl_id, message_key_or_path). + """ + # Parse @branch suffix (only when the branch appears on the last path segment) + branch = None + url = url_with_branch + if "@" in url_with_branch.rsplit("/", 1)[-1]: + base, _, maybe_branch = url_with_branch.rpartition("@") + if ( + base.lower().startswith("https://") + and maybe_branch # reject empty branch after `@` + and "/" not in maybe_branch + and re.match(r'^[A-Za-z0-9._/\-]+$', maybe_branch) + ): + url = base + branch = maybe_branch + elif not maybe_branch and base.lower().startswith("https://"): + # Trailing `@` with no branch — drop it so git doesn't treat it as userinfo + url = base + + tpl_id = "custom_" + hashlib.md5(url_with_branch.encode("utf-8")).hexdigest()[:10] + target_dir = f"/opt/gotelegram/custom_templates/{tpl_id}" + + # Clean previous copy + if os.path.isdir(target_dir): + shutil.rmtree(target_dir, ignore_errors=True) + + tmp_dir = f"/tmp/{tpl_id}_clone" + if os.path.isdir(tmp_dir): + shutil.rmtree(tmp_dir, ignore_errors=True) + + cmd = ["git", "clone", "--depth", "1"] + if branch: + cmd += ["--branch", branch] + cmd += [url, tmp_dir] + + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + try: + _, err = await asyncio.wait_for(proc.communicate(), timeout=_CUSTOM_GIT_CLONE_TIMEOUT) + except asyncio.TimeoutError: + try: + proc.kill() + except ProcessLookupError: + pass + return False, tpl_id, "cg_timeout" + if proc.returncode != 0: + return False, tpl_id, "cg_invalid" + except Exception as e: + logger.warning("custom git clone failed: %s", e) + return False, tpl_id, "cg_invalid" + + # Remove .git to enforce size guard and avoid leaking repo history + git_dir = os.path.join(tmp_dir, ".git") + if os.path.isdir(git_dir): + shutil.rmtree(git_dir, ignore_errors=True) + + # Size guard + total = 0 + for root, _dirs, files in os.walk(tmp_dir): + for f in files: + try: + total += os.path.getsize(os.path.join(root, f)) + except OSError: + pass + if total > _CUSTOM_GIT_MAX_MB * 1024 * 1024: + shutil.rmtree(tmp_dir, ignore_errors=True) + return False, tpl_id, "cg_too_big" + + # Locate index.html in priority order + found_root = None + for sub in ("", "dist", "public", "build", "_site", "site", "docs", "out", "www"): + cand = os.path.join(tmp_dir, sub) if sub else tmp_dir + if os.path.isfile(os.path.join(cand, "index.html")): + found_root = cand + break + if not found_root: + # Fallback: search maxdepth 4 + for root, _dirs, files in os.walk(tmp_dir): + depth = root[len(tmp_dir):].count(os.sep) + if depth > 4: + continue + if "index.html" in files: + found_root = root + break + if not found_root: + shutil.rmtree(tmp_dir, ignore_errors=True) + return False, tpl_id, "cg_no_index" + + # Stage final template dir + os.makedirs(os.path.dirname(target_dir), exist_ok=True) + shutil.copytree(found_root, target_dir) + try: + with open(os.path.join(target_dir, ".custom_git_source"), "w", encoding="utf-8") as f: + f.write(url_with_branch + "\n") + except OSError: + pass + shutil.rmtree(tmp_dir, ignore_errors=True) + return True, tpl_id, target_dir + + +async def cb_pro_category(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Show templates in category.""" + query = update.callback_query + data = query.data + cat_id = data.removeprefix("pro_cat_") + + await query.answer() + + catalog = load_json(TEMPLATES_CATALOG) + if not catalog: + await safe_edit_message(query,"❌ Template catalog not found") + return + + # Find category and templates + category = None + templates = [] + for cat in catalog.get("categories", []): + if cat["id"] == cat_id: + category = cat + templates = cat.get("templates", []) + break + + if not category: + await safe_edit_message(query,"❌ Category not found") + return + + buttons = [] + for tpl in templates: + key = pro_template_key_for_id(context, tpl["id"]) + buttons.append( + [ + InlineKeyboardButton( + f"🎨 {tpl['name']}", callback_data=f"pro_tpl_{key}" + ) + ] + ) + buttons.append([InlineKeyboardButton("« Back", callback_data="install_mode_pro")]) + + text = f"Select template from {html.escape(category['name'])}:" + keyboard = InlineKeyboardMarkup(buttons) + await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") + + +async def cb_pro_template(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Show template preview and confirm.""" + query = update.callback_query + data = query.data + tpl_key = data.removeprefix("pro_tpl_") + tpl_id = resolve_pro_template_id(context, tpl_key) + + await query.answer() + + catalog = load_json(TEMPLATES_CATALOG) + if not catalog: + await safe_edit_message(query,"❌ Template catalog not found") + return + + # Find template + template = None + for cat in catalog.get("categories", []): + for tpl in cat.get("templates", []): + if tpl["id"] == tpl_id: + template = tpl + break + if template: + break + + if not template: + await safe_edit_message(query,"❌ Template not found") + return + + tpl_name = html.escape(template.get('name', 'Unknown')) + tpl_desc = html.escape(template.get('description', 'N/A')) + text = ( + f"🎨 Template Preview\n\n" + f"Name: {tpl_name}\n" + f"Description: {tpl_desc}\n\n" + ) + if "preview_url" in template: + preview_url = html.escape(template['preview_url'], quote=True) + text += f'View Live Preview\n\n' + + text += "Confirm installation?" + + buttons = [ + [ + InlineKeyboardButton( + "✅ Install", callback_data=f"pro_confirm_{pro_template_key_for_id(context, tpl_id)}" + ) + ], + [InlineKeyboardButton("« Back", callback_data="install_mode_pro")], + ] + keyboard = InlineKeyboardMarkup(buttons) + await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") + + +async def cb_pro_confirm(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Confirm Pro template selection — real implementation (v2.4.2+). + + Branches on current mode: + * pro mode (active deployment): invoke `install.sh --action=change-template` + which downloads the new template and redeploys it to nginx. Reuses the + existing domain + SSL cert. + * any other mode (or no install at all): route to CLI. Fresh Pro install + still requires interactive flow (domain, email, DNS check) — not safe + to run headless from the bot. + + Historic context: v2.4.1 stub used to overwrite config.json with a fake + blob; that was replaced with a safe message in v2.4.1 hotfix; now in + v2.4.2 we wire the real change-template path through install.sh. + """ + query = update.callback_query + data = query.data + tpl_key = data.removeprefix("pro_confirm_") + tpl_id = resolve_pro_template_id(context, tpl_key) + + await query.answer() + + # Defense-in-depth: even though subprocess.exec uses list args (no shell), + # we still enforce the catalog id shape before handing it to install.sh. + if not _TPL_ID_RE.match(tpl_id): + logger.warning(f"cb_pro_confirm: rejecting malformed tpl_id {tpl_id!r}") + await safe_edit_message( + query, + "❌ Некорректный идентификатор шаблона\n\n" + "Выбран неподдерживаемый шаблон. Вернитесь в меню и попробуйте снова.", + reply_markup=InlineKeyboardMarkup( + [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]] + ), + parse_mode="HTML", + ) + return + + # Read current config to decide: in-place change-template vs fresh install + config = load_json(GOTELEGRAM_CONFIG) or {} + current_mode = config.get("mode", "") + + if current_mode != "pro": + # Fresh install / mode switch — still routes to CLI (needs domain, SSL) + text = ( + "⚠️ Установка Pro из бота пока не поддерживается\n\n" + f"Выбранный шаблон: {html.escape(tpl_id)}\n\n" + "Pro-режим требует ввода домена, email и проверки DNS. " + "Чтобы установить Pro, запустите на сервере:\n" + "gotelegram1) Прокси → 1) Установить/Обновить → Pro\n\n" + "Существующая конфигурация не была изменена." + ) + keyboard = InlineKeyboardMarkup( + [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]] + ) + await safe_edit_message(query, text, reply_markup=keyboard, parse_mode="HTML") + return + + # Pro mode is active — perform change-template in place + if _BOT_ACTION_LOCK.locked(): + await safe_edit_message( + query, + "⏳ Другая операция уже выполняется\n\n" + "Дождитесь завершения предыдущей смены шаблона/домена и повторите.", + reply_markup=InlineKeyboardMarkup( + [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]] + ), + parse_mode="HTML", + ) + return + + progress_text = ( + "⏳ Меняю шаблон сайта...\n\n" + f"Шаблон: {html.escape(tpl_id)}\n\n" + "Скачиваю репозиторий и разворачиваю в nginx. " + "Это может занять 30–90 секунд." + ) + await safe_edit_message(query, progress_text, parse_mode="HTML") + + # Template download + git clone can be slow — generous timeout. + # Mutex serializes with any concurrent change-lite-domain/change-template. + async with _BOT_ACTION_LOCK: + result = await run_bot_action("change-template", timeout=180, template=tpl_id) + + if result.get("status") == "success": + domain = result.get("domain", config.get("domain", "")) + text = ( + "✅ Шаблон обновлён\n\n" + f"Новый шаблон: {html.escape(tpl_id)}\n" + f"Сайт: https://{html.escape(domain)}\n\n" + "Прокси продолжает работать без перерыва." + ) + else: + err_msg = result.get("message", "unknown error") + err_code = result.get("code", "") + text = ( + "❌ Не удалось сменить шаблон\n\n" + f"Шаблон: {html.escape(tpl_id)}\n" + f"Причина: {html.escape(err_msg)}" + + (f" ({html.escape(err_code)})" if err_code else "") + + "\n\n" + "Существующая конфигурация не была изменена. " + "Попробуйте другой шаблон или запустите gotelegram из консоли." + ) + + keyboard = InlineKeyboardMarkup( + [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]] + ) + await safe_edit_message(query, text, reply_markup=keyboard, parse_mode="HTML", disable_web_page_preview=True) + + +# ============================================================================ +# PROXY LINK & SHARE +# ============================================================================ + +def quote_toml_key(name: str) -> str: + escaped = name.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + + +def ordered_user_lines(users: Dict[str, str]) -> List[str]: + names: List[str] = [] + if "main" in users: + names.append("main") + names.extend(sorted(name for name in users if name != "main")) + return [f'{quote_toml_key(name)} = "{users[name]}"' for name in names] + + +def ordered_user_int_lines(values: Dict[str, int]) -> List[str]: + positive: Dict[str, int] = {} + for name, value in values.items(): + name_s = str(name) + if not _USER_NAME_RE.match(name_s): + continue + try: + number = int(value) + except (TypeError, ValueError): + continue + if number > 0: + positive[name_s] = number + names: List[str] = [] + if "main" in positive: + names.append("main") + names.extend(sorted(name for name in positive if name != "main")) + return [f'{quote_toml_key(name)} = {positive[name]}' for name in names] + + +def load_telemt_users() -> Dict[str, str]: + """Return users from [access.users] in telemt config.""" + telemt_cfg = load_toml(TELEMT_CONFIG) or {} + users = telemt_cfg.get("access", {}).get("users", {}) + if not isinstance(users, dict): + return {} + return { + str(name): str(secret) + for name, secret in users.items() + if isinstance(name, str) and isinstance(secret, str) + } + + +def load_user_max_unique_ips() -> Dict[str, int]: + telemt_cfg = load_toml(TELEMT_CONFIG) or {} + limits = telemt_cfg.get("access", {}).get("user_max_unique_ips", {}) + if not isinstance(limits, dict): + return {} + clean: Dict[str, int] = {} + for name, value in limits.items(): + name_s = str(name) + if not _USER_NAME_RE.match(name_s): + continue + try: + clean[name_s] = max(0, int(value)) + except (TypeError, ValueError): + continue + return clean + + +def load_disabled_users() -> Dict[str, str]: + raw = load_json(DISABLED_USERS_FILE) or {} + if not isinstance(raw, dict): + return {} + users = raw.get("users") if isinstance(raw.get("users"), dict) else raw + if not isinstance(users, dict): + return {} + clean: Dict[str, str] = {} + for name, secret in users.items(): + if name in {"version", "updated_at"}: + continue + name_s = str(name).strip() + secret_s = str(secret or "").strip() + if _USER_NAME_RE.match(name_s) and secret_s: + clean[name_s] = secret_s + return clean + + +def save_disabled_users(users: Dict[str, str]) -> bool: + payload = { + "version": 1, + "updated_at": datetime.utcnow().isoformat() + "Z", + "users": {name: users[name] for name in sorted(users)}, + } + ok = save_json(DISABLED_USERS_FILE, payload) + if ok: + try: + os.chmod(DISABLED_USERS_FILE, 0o600) + except OSError: + pass + return ok + + +def load_user_records() -> Dict[str, Dict[str, Any]]: + records: Dict[str, Dict[str, Any]] = {} + limits = load_user_max_unique_ips() + for name, secret in load_disabled_users().items(): + records[name] = {"secret": secret, "enabled": False, "max_unique_ips": limits.get(name, 0)} + for name, secret in load_telemt_users().items(): + records[name] = {"secret": secret, "enabled": True, "max_unique_ips": limits.get(name, 0)} + return records + + +def save_toml_int_table(table: str, values: Dict[str, int]) -> bool: + try: + os.makedirs(os.path.dirname(TELEMT_CONFIG), exist_ok=True) + if os.path.exists(TELEMT_CONFIG): + with open(TELEMT_CONFIG, "r", encoding="utf-8", errors="ignore") as f: + lines = f.read().splitlines() + else: + lines = [] + rendered = ordered_user_int_lines(values) + header = f"[{table}]" + out: List[str] = [] + in_table = False + found = False + for raw in lines: + if raw.strip() == header: + found = True + in_table = True + if rendered: + out.append(raw) + out.extend(rendered) + continue + if in_table and raw.strip().startswith("["): + in_table = False + if in_table: + continue + out.append(raw) + if not found and rendered: + if out and out[-1].strip(): + out.append("") + out.append(header) + out.extend(rendered) + tmp = f"{TELEMT_CONFIG}.tmp" + with open(tmp, "w", encoding="utf-8") as f: + f.write("\n".join(out).rstrip() + "\n") + os.chmod(tmp, 0o600) + os.replace(tmp, TELEMT_CONFIG) + return True + except Exception as e: + logger.error(f"Failed to save telemt int table {table}: {e}") + return False + + +def save_user_max_unique_ips(values: Dict[str, int]) -> bool: + return save_toml_int_table("access.user_max_unique_ips", values) + + +def normalize_max_unique_ips(value: Any) -> int: + try: + number = int(value) + except (TypeError, ValueError): + raise ValueError("Лимит должен быть целым числом") + if number < 0 or number > MAX_UNIQUE_IP_LIMIT: + raise ValueError(f"Лимит должен быть от 0 до {MAX_UNIQUE_IP_LIMIT}") + return number + + +def save_telemt_users(users: Dict[str, str]) -> bool: + """Persist [access.users] while keeping the rest of the TOML structure.""" + try: + os.makedirs(os.path.dirname(TELEMT_CONFIG), exist_ok=True) + if os.path.exists(TELEMT_CONFIG): + with open(TELEMT_CONFIG, "r", encoding="utf-8", errors="ignore") as f: + lines = f.read().splitlines() + else: + lines = [] + rendered = ordered_user_lines(users) + out: List[str] = [] + in_users = False + found = False + for raw in lines: + if raw.strip() == "[access.users]": + found = True + in_users = True + out.append(raw) + out.extend(rendered) + continue + if in_users and raw.strip().startswith("["): + in_users = False + if in_users: + continue + out.append(raw) + if not found: + if out and out[-1].strip(): + out.append("") + out.append("[access.users]") + out.extend(rendered) + tmp = f"{TELEMT_CONFIG}.tmp" + with open(tmp, "w", encoding="utf-8") as f: + f.write("\n".join(out).rstrip() + "\n") + os.chmod(tmp, 0o600) + os.replace(tmp, TELEMT_CONFIG) + return True + except Exception as e: + logger.error(f"Failed to save telemt users: {e}") + return False + + +async def refresh_telemt_after_user_change() -> bool: + """Restart telemt after config user changes, coalescing rapid UI clicks.""" + global _LAST_TELEMT_RESTART + now = time.monotonic() + if _LAST_TELEMT_RESTART > 0 and now - _LAST_TELEMT_RESTART < TELEMT_RESTART_DEBOUNCE_SECONDS: + code, stdout, _ = await sh("systemctl", "is-active", TELEMT_SERVICE, timeout=5) + if code == 0 and stdout.strip() == "active": + return True + await sh("systemctl", "reset-failed", TELEMT_SERVICE, timeout=5) + _LAST_TELEMT_RESTART = now + code, _, _ = await sh("systemctl", "--no-block", "restart", TELEMT_SERVICE, timeout=5) + return code == 0 + + +async def telemt_api_get(path: str) -> Optional[Dict[str, Any]]: + """Read telemt local API if it is enabled in config.""" + code, stdout, _ = await sh( + "curl", + "-sS", + "--max-time", + "3", + f"http://127.0.0.1:9091{path}", + timeout=5, + ) + if code != 0 or not stdout.strip(): + return None + try: + data = json.loads(stdout) + return data if isinstance(data, dict) else None + except json.JSONDecodeError: + return None + + +def _extract_traffic_value(data: Any, keys: List[str]) -> int: + if isinstance(data, dict): + total = 0 + for key, value in data.items(): + if key in keys and isinstance(value, (int, float)): + total += int(value) + elif isinstance(value, (dict, list)): + total += _extract_traffic_value(value, keys) + return total + if isinstance(data, list): + return sum(_extract_traffic_value(item, keys) for item in data) + return 0 + + +def user_traffic_history_summary(name: str) -> str: + rows: List[Dict[str, int]] = [] + try: + with open(USER_STATS_HISTORY, "r", encoding="utf-8", errors="ignore") as f: + reader = csv.DictReader(f) + previous = None + for row in reader: + if row.get("user") != name: + continue + try: + item = { + "epoch": int(row.get("epoch") or 0), + "total_octets": int(row.get("total_octets") or 0), + } + except ValueError: + continue + item["total_delta"] = max(0, item["total_octets"] - previous["total_octets"]) if previous else 0 + rows.append(item) + previous = item + except Exception: + rows = [] + + if not rows: + return "\nИстория по ключу пока не накоплена." + + latest = max(row["epoch"] for row in rows) + periods = [("15 мин", 15 * 60), ("1 час", 60 * 60), ("24 часа", 24 * 60 * 60), ("Месяц", 30 * 24 * 60 * 60)] + lines = ["\nИстория трафика:", "
", f"{'Период':<8} │ {'Трафик':>10}", "─" * 23]
+    for label, seconds in periods:
+        window = [row for row in rows if row["epoch"] >= latest - seconds]
+        total = sum(max(0, row.get("total_delta", 0)) for row in window)
+        lines.append(f"{label:<8} │ {format_bytes_human(total):>10}")
+    lines.append("
") + return "\n".join(lines) + + +async def get_proxy_link_for_secret(secret: str) -> Optional[str]: + """Generate a fake-TLS proxy link for an arbitrary telemt user secret.""" + config = load_json(GOTELEGRAM_CONFIG) or {} + if not secret: + return None + + mode = config.get("mode", "lite") + domain = config.get("domain", "") + port = config.get("port", 443) + + if mode == "pro" and domain: + domain_hex = str(domain).encode().hex() + return f"tg://proxy?server={domain}&port={port}&secret=ee{secret}{domain_hex}" + + code, stdout, _ = await sh("curl", "-s", "-4", "--max-time", "5", "https://api.ipify.org") + server = stdout.strip() if code == 0 and stdout.strip() else "0.0.0.0" + mask_host = config.get("mask_host", "") + if mask_host: + domain_hex = str(mask_host).encode().hex() + return f"tg://proxy?server={server}&port={port}&secret=ee{secret}{domain_hex}" + return f"tg://proxy?server={server}&port={port}&secret={secret}" + + +async def get_proxy_link() -> Optional[str]: + """Generate proxy link from config. Pro-mode uses domain + fake-TLS secret.""" + config = load_json(GOTELEGRAM_CONFIG) + if not config: + return None + + # Get secret from telemt TOML config (v3 format: [access.users] main = "...") + secret = config.get("secret", "") + if not secret: + telemt_cfg = load_toml(TELEMT_CONFIG) + if telemt_cfg: + access = telemt_cfg.get("access", {}) + users = access.get("users", {}) + if isinstance(users, dict): + secret = users.get("main", "") + if not secret: + return None + + return await get_proxy_link_for_secret(secret) + + +async def cb_menu_link(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Generate and show proxy link.""" + query = update.callback_query + await query.answer() + + link = await get_proxy_link() + if not link: + text = "❌ Proxy not installed yet. Run install first." + else: + text = ( + f"🔗 Proxy Link\n\n" + f"{html.escape(link)}\n\n" + f"Open in Telegram to connect." + ) + + keyboard = InlineKeyboardMarkup( + [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]] + ) + await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") + + +async def cb_menu_share(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Share link as QR code.""" + query = update.callback_query + await query.answer() + + link = await get_proxy_link() + if not link: + text = "❌ Proxy not installed yet." + keyboard = InlineKeyboardMarkup( + [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]] + ) + await safe_edit_message(query,text, reply_markup=keyboard) + return + + # Try to generate QR code + qr_file = None + code, _, _ = await sh("which", "qrencode") + if code == 0: + qr_path = "/tmp/proxy_qr.png" + code, _, _ = await sh("qrencode", "-o", qr_path, link) + if code == 0 and os.path.exists(qr_path): + qr_file = qr_path + + if qr_file: + try: + with open(qr_file, "rb") as f: + await query.message.reply_photo( + photo=f, + caption=f"📤 Proxy QR Code\n\n{html.escape(link)}", + parse_mode="HTML", + ) + except Exception as e: + logger.error(f"Failed to send QR code: {e}") + await safe_edit_message(query, + f"🔗 Proxy Link\n\n{html.escape(link)}", + parse_mode="HTML", + ) + finally: + try: + os.remove(qr_file) + except OSError: + pass + else: + await safe_edit_message(query, + f"🔗 Proxy Link\n\n{html.escape(link)}", + reply_markup=InlineKeyboardMarkup( + [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]] + ), + parse_mode="HTML", + ) + + +# ============================================================================ +# TELEMT USERS +# ============================================================================ + + +def _users_keyboard(users: Dict[str, Dict[str, Any]], user_id: Optional[int]) -> InlineKeyboardMarkup: + rows = [] + for name in sorted(users, key=lambda item: (item != "main", item)): + enabled = bool(users[name].get("enabled")) + icon = "🟢" if enabled else "⏸" + rows.append([InlineKeyboardButton(f"{icon} {name}", callback_data=f"user_view_{name}")]) + rows.append([InlineKeyboardButton("➕ Добавить ключ", callback_data="user_add")]) + rows.append([ + InlineKeyboardButton(_t(user_id, "btn_refresh"), callback_data="menu_users"), + InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_main"), + ]) + return InlineKeyboardMarkup(rows) + + +async def cb_menu_users(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + query = update.callback_query + await query.answer() + user_id = _uid(update) + users = load_user_records() + + if users: + user_lines = "\n".join( + f"{'🟢' if users[name].get('enabled') else '⏸'} {html.escape(name)}" + for name in sorted(users, key=lambda item: (item != "main", item)) + ) + else: + user_lines = "Ключей пока нет" + + api_summary = await telemt_api_get("/v1/stats/summary") + api_note = "" + if api_summary and isinstance(api_summary.get("data"), dict): + data = api_summary["data"] + configured = data.get("configured_users") + active = data.get("active_connections") or data.get("connections_active") + bits = [] + if configured is not None: + bits.append(f"users: {configured}") + if active is not None: + bits.append(f"active: {active}") + if bits: + api_note = "\n\nAPI: " + ", ".join(bits) + + text = ( + "🔑 Ключи пользователей\n\n" + f"{user_lines}" + f"{api_note}\n\n" + "Нажмите на пользователя, чтобы увидеть ссылку, статистику и действия." + ) + await safe_edit_message(query, text, reply_markup=_users_keyboard(users, user_id), parse_mode="HTML") + + +async def _user_detail_text(name: str, secret: str, enabled: bool = True, max_unique_ips: int = 0) -> str: + link = await get_proxy_link_for_secret(secret) + api = await telemt_api_get(f"/v1/users/{quote(name, safe='')}") if enabled else None + details = "" + if api: + data = api.get("data", api) + total = int(data.get("total_octets") or 0) if isinstance(data, dict) else 0 + conns = int(data.get("current_connections") or 0) if isinstance(data, dict) else 0 + active_ips = int(data.get("active_unique_ips") or 0) if isinstance(data, dict) else 0 + recent_ips = int(data.get("recent_unique_ips") or 0) if isinstance(data, dict) else 0 + parts = [] + parts.append(f"Трафик всего: {format_bytes_human(total)}") + parts.append(f"Подключения: {conns}") + parts.append(f"Активные IP: {active_ips}") + if recent_ips: + parts.append(f"Недавние IP: {recent_ips}") + if parts: + details = "\n" + "\n".join(parts) + else: + compact = json.dumps(data, ensure_ascii=False)[:600] + details = f"\n
{html.escape(compact)}
" + elif enabled: + details = "\nRuntime API недоступен. Новые установки goTelegram Pro включают его автоматически." + else: + details = "\nКлюч отключён и сейчас не принимается telemt." + details += user_traffic_history_summary(name) + + link_line = html.escape(link) if link else "link unavailable" + status_line = "🟢 enabled" if enabled else "⏸ disabled" + limit_line = "0 (безлимит)" if not max_unique_ips else str(max_unique_ips) + return ( + f"👤 {html.escape(name)}\n\n" + f"Status: {status_line}\n" + f"Лимит IP: {html.escape(limit_line)}\n" + f"Secret: {html.escape(secret)}\n\n" + f"Ссылка:\n{link_line}\n" + f"{details}" + ) + + +async def cb_user_view(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + query = update.callback_query + await query.answer() + user_id = _uid(update) + name = query.data.removeprefix("user_view_") + users = load_user_records() + record = users.get(name) + if not record: + await safe_edit_message( + query, + "❌ Пользователь не найден.", + reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_users")]]), + ) + return + enabled = bool(record.get("enabled")) + secret = str(record.get("secret", "")) + max_unique_ips = int(record.get("max_unique_ips") or 0) + + buttons = [ + [InlineKeyboardButton("⏸ Отключить" if enabled else "▶️ Включить", callback_data=f"user_toggle_{name}")], + [InlineKeyboardButton("🌐 Лимит IP", callback_data=f"user_ip_limit_{name}")], + [InlineKeyboardButton("📷 QR", callback_data=f"user_qr_{name}")], + [InlineKeyboardButton("🗑 Удалить", callback_data=f"user_del_{name}")], + [InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_users")], + ] + if name == "main": + buttons = [ + [InlineKeyboardButton("🔒 Main key", callback_data=f"user_view_{name}")], + [InlineKeyboardButton("🌐 Лимит IP", callback_data=f"user_ip_limit_{name}")], + [InlineKeyboardButton("📷 QR", callback_data=f"user_qr_{name}")], + [InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_users")], + ] + await safe_edit_message( + query, + await _user_detail_text(name, secret, enabled, max_unique_ips), + reply_markup=InlineKeyboardMarkup(buttons), + parse_mode="HTML", + disable_web_page_preview=True, + ) + + +async def cb_user_qr(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + query = update.callback_query + await query.answer() + user_id = _uid(update) + name = query.data.removeprefix("user_qr_") + users = load_user_records() + record = users.get(name) + if not record: + await query.answer("Ключ не найден", show_alert=True) + return + link = await get_proxy_link_for_secret(str(record.get("secret", ""))) + if not link: + await query.answer("Ссылка недоступна", show_alert=True) + return + + qr_file = f"/tmp/gotelegram_user_qr_{hashlib.sha256(name.encode()).hexdigest()[:10]}.png" + code, _, _ = await sh("which", "qrencode") + if code == 0: + code, _, _ = await sh("qrencode", "-o", qr_file, link) + if code == 0 and os.path.exists(qr_file): + try: + with open(qr_file, "rb") as f: + await query.message.reply_photo( + photo=f, + caption=f"📷 QR: {html.escape(name)}\n\n{html.escape(link)}", + parse_mode="HTML", + ) + finally: + try: + os.remove(qr_file) + except OSError: + pass + else: + await safe_edit_message( + query, + f"🔗 {html.escape(name)}\n\n{html.escape(link)}", + reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton(_t(user_id, "btn_back"), callback_data=f"user_view_{name}")]]), + parse_mode="HTML", + disable_web_page_preview=True, + ) + + +async def cb_user_ip_limit(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + query = update.callback_query + await query.answer() + user_id = _uid(update) + name = query.data.removeprefix("user_ip_limit_") + users = load_user_records() + record = users.get(name) + if not record: + await query.answer("Ключ не найден", show_alert=True) + return + current = int(record.get("max_unique_ips") or 0) + context.user_data["awaiting_user_ip_limit"] = name + text = ( + f"🌐 Лимит IP: {html.escape(name)}\n\n" + f"Текущее значение: {current}\n" + "Отправьте число: 0 — безлимит, 1 — только один активный IP, " + "2 — два активных IP и так далее." + ) + await safe_edit_message( + query, + text, + reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton(_t(user_id, "btn_cancel"), callback_data=f"user_view_{name}")]]), + parse_mode="HTML", + ) + + +async def set_user_ip_limit_from_text(update: Update, context: ContextTypes.DEFAULT_TYPE, raw_value: str, name: str) -> None: + user_id = update.effective_user.id + try: + limit = normalize_max_unique_ips(raw_value.strip()) + except ValueError as exc: + await update.message.reply_text(f"❌ {html.escape(str(exc))}", parse_mode="HTML") + return + with FileLock(USER_LOCK_FILE): + records = load_user_records() + if name not in records: + await update.message.reply_text("❌ Ключ не найден.") + return + limits = load_user_max_unique_ips() + if limit > 0: + limits[name] = limit + else: + limits.pop(name, None) + saved = save_user_max_unique_ips(limits) + if not saved: + await update.message.reply_text("❌ Не удалось сохранить /etc/telemt/config.toml") + return + await refresh_telemt_after_user_change() + await update.message.reply_text( + f"✅ Лимит IP сохранён для {html.escape(name)}: {limit}", + reply_markup=_users_keyboard(load_user_records(), user_id), + parse_mode="HTML", + ) + + +async def cb_user_add(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + query = update.callback_query + await query.answer() + user_id = _uid(update) + context.user_data["awaiting_user_name"] = True + text = ( + "➕ Новый ключ\n\n" + "Отправьте имя пользователя: латиница, цифры, _ . -, до 48 символов.\n" + "Пример: ivan или family-1." + ) + await safe_edit_message( + query, + text, + reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton(_t(user_id, "btn_cancel"), callback_data="menu_users")]]), + parse_mode="HTML", + ) + + +async def cb_user_toggle(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + query = update.callback_query + await query.answer() + user_id = _uid(update) + name = query.data.removeprefix("user_toggle_") + if name == "main": + await query.answer("main нельзя отключить", show_alert=True) + return + with FileLock(USER_LOCK_FILE): + active = load_telemt_users() + disabled = load_disabled_users() + records = load_user_records() + record = records.get(name) + if not record: + await query.answer("Ключ не найден", show_alert=True) + return + enabled = not bool(record.get("enabled")) + secret = str(record.get("secret", "")) + if enabled: + disabled.pop(name, None) + active[name] = secret + else: + active.pop(name, None) + disabled[name] = secret + if enabled: + saved = save_telemt_users(active) and save_disabled_users(disabled) + else: + saved = save_disabled_users(disabled) and save_telemt_users(active) + if not saved: + await safe_edit_message(query, "❌ Не удалось сохранить состояние ключа") + return + await refresh_telemt_after_user_change() + await safe_edit_message( + query, + f"{'✅ Ключ включён' if enabled else '⏸ Ключ отключён'}: {html.escape(name)}", + reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton(_t(user_id, "btn_back"), callback_data=f"user_view_{name}")]]), + parse_mode="HTML", + ) + + +async def cb_user_delete(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + query = update.callback_query + await query.answer() + user_id = _uid(update) + name = query.data.removeprefix("user_del_") + if name == "main": + await query.answer("main нельзя удалить", show_alert=True) + return + text = f"Удалить ключ {html.escape(name)}?" + buttons = [ + [InlineKeyboardButton("✅ Удалить", callback_data=f"user_del_yes_{name}")], + [InlineKeyboardButton(_t(user_id, "btn_cancel"), callback_data=f"user_view_{name}")], + ] + await safe_edit_message(query, text, reply_markup=InlineKeyboardMarkup(buttons), parse_mode="HTML") + + +async def cb_user_delete_confirm(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + query = update.callback_query + await query.answer() + user_id = _uid(update) + name = query.data.removeprefix("user_del_yes_") + with FileLock(USER_LOCK_FILE): + active = load_telemt_users() + disabled = load_disabled_users() + records = load_user_records() + if name == "main" or name not in records: + await query.answer("Нельзя удалить этот ключ", show_alert=True) + return + active.pop(name, None) + disabled.pop(name, None) + limits = load_user_max_unique_ips() + limits.pop(name, None) + saved = save_telemt_users(active) and save_disabled_users(disabled) and save_user_max_unique_ips(limits) + if not saved: + await safe_edit_message(query, "❌ Не удалось сохранить config.toml") + return + await refresh_telemt_after_user_change() + await safe_edit_message( + query, + f"✅ Ключ {html.escape(name)} удалён.", + reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_users")]]), + parse_mode="HTML", + ) + + +async def create_user_from_text(update: Update, context: ContextTypes.DEFAULT_TYPE, name: str) -> None: + user_id = update.effective_user.id + if not _USER_NAME_RE.match(name): + await update.message.reply_text("❌ Некорректное имя. Используйте латиницу, цифры, _ . - и до 48 символов.") + return + with FileLock(USER_LOCK_FILE): + records = load_user_records() + if name in records: + await update.message.reply_text("❌ Такой пользователь уже есть.") + return + users = load_telemt_users() + secret = hashlib.sha256(f"{name}:{time.time()}:{os.urandom(16).hex()}".encode()).hexdigest()[:32] + users[name] = secret + saved = save_telemt_users(users) + if not saved: + await update.message.reply_text("❌ Не удалось сохранить /etc/telemt/config.toml") + return + await refresh_telemt_after_user_change() + link = await get_proxy_link_for_secret(secret) + await update.message.reply_text( + f"✅ Ключ создан\n\n" + f"Пользователь: {html.escape(name)}\n" + f"Secret: {secret}\n\n" + f"{html.escape(link or '')}", + reply_markup=_users_keyboard(load_user_records(), user_id), + parse_mode="HTML", + disable_web_page_preview=True, + ) + + +# ============================================================================ +# RESTART & LOGS +# ============================================================================ + + +async def cb_menu_restart(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Restart service.""" + query = update.callback_query + await query.answer() + + text = "⏳ Restarting telemt service..." + await safe_edit_message(query,text) + + code, _, stderr = await sh("systemctl", "restart", TELEMT_SERVICE) + if code == 0: + text = "✅ Service restarted successfully" + else: + text = f"❌ Failed to restart:\n{html.escape(stderr[:500])}" + + keyboard = InlineKeyboardMarkup( + [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]] + ) + await safe_edit_message(query, + text, reply_markup=keyboard, parse_mode="HTML" + ) + + +async def cb_menu_logs(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Show recent logs.""" + query = update.callback_query + await query.answer() + + code, stdout, _ = await sh( + "journalctl", "-u", TELEMT_SERVICE, "-n", "30", "--no-pager" + ) + + if code == 0: + log_text = stdout[-1000:] if len(stdout) > 1000 else stdout + text = f"📋 Recent Logs\n\n
{html.escape(log_text)}
" + else: + text = "❌ Failed to retrieve logs" + + keyboard = InlineKeyboardMarkup( + [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]] + ) + await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") + + +# ============================================================================ +# BACKUP & RESTORE +# ============================================================================ + +def list_backup_names(limit: int = 10) -> List[str]: + try: + if not os.path.exists(BACKUP_DIR): + return [] + names = [ + f for f in os.listdir(BACKUP_DIR) + if f.endswith((".tar.gz", ".tar.gz.enc")) and not f.endswith(".sha256") + ] + return sorted(names, reverse=True)[:limit] + except Exception: + return [] + + +def safe_backup_path(name: str) -> Optional[str]: + raw = os.path.basename(str(name or "").strip()) + if raw != name or not raw.endswith((".tar.gz", ".tar.gz.enc")) or raw.endswith(".sha256"): + return None + path = os.path.abspath(os.path.join(BACKUP_DIR, raw)) + base = os.path.abspath(BACKUP_DIR) + if os.path.dirname(path) != base or not os.path.exists(path): + return None + return path + + +def backup_schedule_state() -> Dict[str, Any]: + raw = load_json(BACKUP_SCHEDULE_FILE) or {} + if not isinstance(raw, dict): + raw = {} + frequency = str(raw.get("frequency") or "off") + if frequency not in {"off", "daily", "weekly", "monthly"}: + frequency = "off" + return { + "frequency": frequency, + "calendar": raw.get("calendar") or "", + "updated_at": raw.get("updated_at") or "", + } + + +async def run_full_backup() -> Tuple[bool, str]: + script = ( + "source /opt/gotelegram/lib/common.sh; " + "source /opt/gotelegram/lib/i18n.sh; " + "source /opt/gotelegram/lib/telemt.sh; " + "source /opt/gotelegram/lib/website.sh; " + "source /opt/gotelegram/lib/backup.sh; " + "load_language \"$(detect_language 2>/dev/null || echo en)\"; " + "create_backup \"\"; " + "cleanup_old_backups 30" + ) + code, stdout, stderr = await sh("bash", "-lc", script, timeout=240) + message = (stdout.strip().splitlines()[-1:] or stderr.strip().splitlines()[-1:] or [""])[0] + return code == 0, message + + +async def set_full_backup_schedule(frequency: str) -> Tuple[bool, str]: + if frequency not in {"off", "daily", "weekly", "monthly"}: + return False, "unsupported schedule" + script = ( + "source /opt/gotelegram/lib/common.sh; " + "source /opt/gotelegram/lib/i18n.sh; " + "source /opt/gotelegram/lib/backup.sh; " + "load_language \"$(detect_language 2>/dev/null || echo en)\"; " + f"set_backup_schedule {shlex.quote(frequency)}" + ) + code, stdout, stderr = await sh("bash", "-lc", script, timeout=120) + message = (stdout.strip().splitlines()[-1:] or stderr.strip().splitlines()[-1:] or [""])[0] + return code == 0, message + + +async def launch_full_restore(backup_path: str) -> None: + quoted_path = shlex.quote(backup_path) + script = ( + "sleep 1; " + "source /opt/gotelegram/lib/common.sh; " + "source /opt/gotelegram/lib/i18n.sh; " + "source /opt/gotelegram/lib/telemt.sh; " + "source /opt/gotelegram/lib/website.sh; " + "source /opt/gotelegram/lib/backup.sh; " + "load_language \"$(detect_language 2>/dev/null || echo en)\"; " + "create_backup \"\" >/dev/null 2>&1 || true; " + f"restore_backup {quoted_path} \"\" yes; " + "cleanup_old_backups 30" + ) + await asyncio.create_subprocess_exec( + "bash", + "-lc", + script, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + + +async def cb_menu_backup(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Backup menu.""" + query = update.callback_query + await query.answer() + user_id = _uid(update) + backups = list_backup_names() + schedule = backup_schedule_state() + labels = { + "off": "выключено" if get_user_lang(user_id) == "ru" else "off", + "daily": "каждый день" if get_user_lang(user_id) == "ru" else "daily", + "weekly": "каждую неделю" if get_user_lang(user_id) == "ru" else "weekly", + "monthly": "каждый месяц" if get_user_lang(user_id) == "ru" else "monthly", + } + + buttons = [ + [InlineKeyboardButton("💾 Создать сейчас" if get_user_lang(user_id) == "ru" else "💾 Create now", callback_data="backup_create")], + [ + InlineKeyboardButton("◯ Выкл" if get_user_lang(user_id) == "ru" else "◯ Off", callback_data="backup_schedule_off"), + InlineKeyboardButton("☀ День" if get_user_lang(user_id) == "ru" else "☀ Daily", callback_data="backup_schedule_daily"), + ], + [ + InlineKeyboardButton("◷ Неделя" if get_user_lang(user_id) == "ru" else "◷ Weekly", callback_data="backup_schedule_weekly"), + InlineKeyboardButton("◴ Месяц" if get_user_lang(user_id) == "ru" else "◴ Monthly", callback_data="backup_schedule_monthly"), + ], + ] + + if backups: + buttons.append([InlineKeyboardButton("📋 Список" if get_user_lang(user_id) == "ru" else "📋 List", callback_data="backup_list")]) + buttons.append([InlineKeyboardButton("↩️ Восстановить" if get_user_lang(user_id) == "ru" else "↩️ Restore", callback_data="menu_restore")]) + + buttons.append([InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_main")]) + + if get_user_lang(user_id) == "ru": + text = ( + "💾 Бекапы\n\n" + f"Файлов: {len(backups)}\n" + f"Расписание: {labels.get(schedule['frequency'], schedule['frequency'])}\n\n" + "В бекап входит: telemt config, настройки goTelegram, ключи, отключённые ключи, сайт, шаблоны, SSL, бот, админка и история трафика." + ) + else: + text = ( + "💾 Backups\n\n" + f"Files: {len(backups)}\n" + f"Schedule: {labels.get(schedule['frequency'], schedule['frequency'])}\n\n" + "Backups include telemt config, goTelegram settings, keys, disabled keys, site, templates, SSL, bot, admin panel and traffic history." + ) + keyboard = InlineKeyboardMarkup(buttons) + await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") + + +async def cb_backup_create(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Create backup.""" + query = update.callback_query + await query.answer() + user_id = _uid(update) + + await safe_edit_message(query, "⏳ Создаю полный бекап..." if get_user_lang(user_id) == "ru" else "⏳ Creating full backup...") + + ok, message = await run_full_backup() + if ok: + text = f"✅ Бекап создан:\n{html.escape(message)}" if get_user_lang(user_id) == "ru" else f"✅ Backup created:\n{html.escape(message)}" + else: + text = f"❌ Ошибка бекапа:\n{html.escape(message[:500])}" if get_user_lang(user_id) == "ru" else f"❌ Backup failed:\n{html.escape(message[:500])}" + + keyboard = InlineKeyboardMarkup( + [[InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_backup")]] + ) + await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") + + +async def cb_backup_schedule_set(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + query = update.callback_query + await query.answer() + user_id = _uid(update) + frequency = query.data.removeprefix("backup_schedule_") + await safe_edit_message(query, "⏳ Сохраняю расписание..." if get_user_lang(user_id) == "ru" else "⏳ Saving schedule...") + ok, message = await set_full_backup_schedule(frequency) + if ok: + text = "✅ Расписание обновлено." if get_user_lang(user_id) == "ru" else "✅ Backup schedule updated." + else: + text = f"❌ {html.escape(message[:500])}" + await safe_edit_message( + query, + text, + reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_backup")]]), + parse_mode="HTML", + ) + + +async def cb_backup_list(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """List backups.""" + query = update.callback_query + await query.answer() + user_id = _uid(update) + + backups = list_backup_names() + + if not backups: + text = "Бекапов нет" if get_user_lang(user_id) == "ru" else "No backups found" + else: + text = "📋 Доступные бекапы\n\n" if get_user_lang(user_id) == "ru" else "📋 Available Backups\n\n" + for backup in backups[:10]: + path = os.path.join(BACKUP_DIR, backup) + size = os.path.getsize(path) / (1024 * 1024) + text += f"{html.escape(backup)} ({size:.2f} MB)\n" + + keyboard = InlineKeyboardMarkup( + [[InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_backup")]] + ) + await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") + + +async def cb_menu_restore(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Restore menu.""" + query = update.callback_query + await query.answer() + user_id = _uid(update) + + backups = list_backup_names() + + if not backups: + text = "❌ Нет доступных бекапов" if get_user_lang(user_id) == "ru" else "❌ No backups available" + keyboard = InlineKeyboardMarkup( + [[InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_main")]] + ) + else: + text = "Выберите бекап для восстановления:" if get_user_lang(user_id) == "ru" else "Select backup to restore:" + buttons = [] + for i, backup in enumerate(backups[:10]): + buttons.append( + [ + InlineKeyboardButton( + backup, callback_data=f"restore_idx_{i}" + ) + ] + ) + buttons.append([InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_main")]) + keyboard = InlineKeyboardMarkup(buttons) + # Store backup list in user_data for retrieval + context.user_data["backup_list"] = backups[:10] + + await safe_edit_message(query,text, reply_markup=keyboard) + + +async def cb_restore_backup(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Confirm or execute backup restoration.""" + query = update.callback_query + data = query.data + user_id = _uid(update) + + try: + if data.startswith("restore_yes_"): + idx = int(data.removeprefix("restore_yes_")) + else: + idx = int(data.removeprefix("restore_idx_")) + except ValueError: + await query.answer("Invalid backup selection") + return + + backup_list = context.user_data.get("backup_list", []) + if idx < 0 or idx >= len(backup_list): + await query.answer("Backup not found") + return + + backup_name = backup_list[idx] + backup_path = os.path.join(BACKUP_DIR, backup_name) + + await query.answer() + if data.startswith("restore_idx_"): + text = ( + f"Восстановить {html.escape(backup_name)}?\n\n" + "Перед восстановлением будет создан свежий safety-бекап." + ) if get_user_lang(user_id) == "ru" else ( + f"Restore {html.escape(backup_name)}?\n\n" + "A fresh safety backup will be created before restoring." + ) + keyboard = InlineKeyboardMarkup([ + [InlineKeyboardButton("✅ Восстановить" if get_user_lang(user_id) == "ru" else "✅ Restore", callback_data=f"restore_yes_{idx}")], + [InlineKeyboardButton(_t(user_id, "btn_cancel"), callback_data="menu_restore")], + ]) + await safe_edit_message(query, text, reply_markup=keyboard, parse_mode="HTML") + return + + await safe_edit_message(query, f"⏳ Восстановление запущено: {html.escape(backup_name)}..." if get_user_lang(user_id) == "ru" else f"⏳ Restore started: {html.escape(backup_name)}...") + + safe_path = safe_backup_path(backup_name) + if not safe_path: + text = "❌ Файл бекапа не найден" if get_user_lang(user_id) == "ru" else "❌ Backup file not found" + elif safe_path.endswith(".enc"): + text = "❌ Зашифрованный бекап пока восстанавливается через CLI: gotelegram → Восстановить." if get_user_lang(user_id) == "ru" else "❌ Encrypted backups are restored from CLI for now: gotelegram → Restore." + else: + await launch_full_restore(safe_path) + text = ( + f"✅ Восстановление {html.escape(backup_name)} запущено в фоне.\n" + "Сервисы могут перезапуститься, через минуту откройте статус." + ) if get_user_lang(user_id) == "ru" else ( + f"✅ Restore for {html.escape(backup_name)} started in background.\n" + "Services may restart; check status in about a minute." + ) + + keyboard = InlineKeyboardMarkup( + [[InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_main")]] + ) + await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") + + +# ============================================================================ +# UPDATE & MODE/TEMPLATE CHANGE +# ============================================================================ + + +async def cb_menu_update(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Update telemt by re-running the install script's update logic.""" + query = update.callback_query + await query.answer() + + await safe_edit_message(query,"⏳ Checking for telemt updates...") + + current = await get_telemt_version() + + # Check latest release from GitHub + code, stdout, stderr = await sh( + "curl", "-s", "--max-time", "10", + "https://api.github.com/repos/telemt/telemt/releases/latest", + ) + + if code != 0 or not stdout.strip(): + text = "❌ Failed to check for updates" + else: + try: + release = json.loads(stdout) + latest = release.get("tag_name", "unknown") + if latest.lstrip("v") == current.lstrip("v"): + text = f"✅ telemt is already up to date ({html.escape(current)})" + else: + text = ( + f"ℹ️ Update available: {html.escape(current)} → {html.escape(latest)}\n\n" + f"Run the CLI installer to update:\n" + f"sudo bash install.sh → menu item 10" + ) + except json.JSONDecodeError: + text = "❌ Failed to parse release info" + + keyboard = InlineKeyboardMarkup( + [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]] + ) + await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") + + +async def cb_menu_change(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Change mode or template.""" + query = update.callback_query + await query.answer() + + buttons = [ + [InlineKeyboardButton("⚡ Switch to Lite Mode", callback_data="change_lite")], + [InlineKeyboardButton("🛡 Switch to Pro Mode", callback_data="change_pro")], + [InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")], + ] + keyboard = InlineKeyboardMarkup(buttons) + await safe_edit_message(query, + "Change mode or template:", reply_markup=keyboard + ) + + +async def cb_change_lite(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Switch to lite mode — show domain selection.""" + query = update.callback_query + await query.answer() + # Reuse the lite mode domain selection flow + await cb_install_mode_lite(update, context) + + +async def cb_change_pro(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Switch to pro mode — show template categories.""" + query = update.callback_query + await query.answer() + # Reuse the pro mode template selection flow + await cb_install_mode_pro(update, context) + + +async def cb_install_migrate(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Migrate from v1 (mtg Docker) to v2 (telemt).""" + query = update.callback_query + await query.answer() + + await safe_edit_message(query,"⏳ Migrating from v1...") + + # Stop old mtg container + code, _, stderr = await sh("docker", "stop", "mtproto-proxy", timeout=30) + if code != 0: + code, _, stderr = await sh("docker", "stop", "mtg", timeout=30) + + # Remove old container + await sh("docker", "rm", "mtproto-proxy", timeout=15) + await sh("docker", "rm", "mtg", timeout=15) + + text = ( + "✅ v1 container stopped and removed\n\n" + "Now select installation mode for v2:" + ) + keyboard = get_install_mode_menu(_uid(update)) + await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") + + +# ============================================================================ +# WEBSITE & SSL +# ============================================================================ + + +async def cb_menu_website(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Website and SSL management.""" + query = update.callback_query + await query.answer() + + buttons = [ + [InlineKeyboardButton("🔄 Renew SSL Certificate", callback_data="ssl_renew")], + [InlineKeyboardButton("📊 SSL Status", callback_data="ssl_status")], + [InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")], + ] + keyboard = InlineKeyboardMarkup(buttons) + await safe_edit_message(query, + "Website & SSL Management:", reply_markup=keyboard + ) + + +async def cb_ssl_renew(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Renew SSL certificate.""" + query = update.callback_query + await query.answer() + + await safe_edit_message(query,"⏳ Renewing SSL certificate...") + + code, stdout, stderr = await sh("certbot", "renew", timeout=120) + + if code == 0: + text = "✅ SSL certificate renewed successfully" + else: + text = f"❌ Renewal failed:\n{html.escape(stderr[:500])}" + + keyboard = InlineKeyboardMarkup( + [[InlineKeyboardButton("« Back", callback_data="menu_website")]] + ) + await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") + + +async def cb_ssl_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Show SSL status.""" + query = update.callback_query + await query.answer() + + code, stdout, _ = await sh("certbot", "certificates") + + if code == 0: + text = f"📊 SSL Certificates\n\n
{html.escape(stdout[:1000])}
" + else: + text = "❌ Failed to get SSL status" + + keyboard = InlineKeyboardMarkup( + [[InlineKeyboardButton("« Back", callback_data="menu_website")]] + ) + await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") + + +async def admin_web_host_hint() -> str: + config = load_json(GOTELEGRAM_CONFIG) or {} + domain = str(config.get("domain") or "") + if domain: + return domain + code, stdout, _ = await sh("curl", "-s", "-4", "--max-time", "5", "https://api.ipify.org", timeout=7) + return stdout.strip() if code == 0 and stdout.strip() else "SERVER_IP" + + +async def cb_menu_admin_web(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + query = update.callback_query + await query.answer() + user_id = _uid(update) + running = await check_service_status(ADMIN_WEB_SERVICE) + host = await admin_web_host_hint() + local_url = f"http://127.0.0.1:{ADMIN_WEB_PORT}/" + ssh_cmd = f"ssh -L {ADMIN_WEB_PORT}:127.0.0.1:{ADMIN_WEB_PORT} root@{host}" + + if get_user_lang(user_id) == "ru": + status = "запущена" if running else "не запущена" + text = ( + f"🖥 Web Admin\n\n" + f"Статус: {status}\n\n" + "Termius\n" + "1. Откройте сервер → Port Forwarding.\n" + f"2. Добавьте Local tunnel: 127.0.0.1:{ADMIN_WEB_PORT} → " + f"127.0.0.1:{ADMIN_WEB_PORT}.\n" + "3. Запустите tunnel и откройте в браузере:\n" + f"{html.escape(local_url)}\n\n" + "Обычный SSH\n" + f"{html.escape(ssh_cmd)}\n\n" + "Админка слушает только localhost на сервере и не публикуется наружу." + ) + else: + status = "running" if running else "not running" + text = ( + f"🖥 Web Admin\n\n" + f"Status: {status}\n\n" + "Termius\n" + "1. Open the server → Port Forwarding.\n" + f"2. Add a Local tunnel: 127.0.0.1:{ADMIN_WEB_PORT} → " + f"127.0.0.1:{ADMIN_WEB_PORT}.\n" + "3. Start the tunnel and open:\n" + f"{html.escape(local_url)}\n\n" + "Regular SSH\n" + f"{html.escape(ssh_cmd)}\n\n" + "The admin listens only on server localhost and is not exposed publicly." + ) + + await safe_edit_message( + query, + text, + reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_main")]]), + parse_mode="HTML", + disable_web_page_preview=True, + ) + + +# ============================================================================ +# ADMIN MANAGEMENT +# ============================================================================ + + +async def cb_menu_admins(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Показать список админов и кнопки управления.""" + query = update.callback_query + await query.answer() + + if ALLOWED_IDS: + ids_list = "\n".join(f" • {uid}" for uid in sorted(ALLOWED_IDS)) + text = f"👤 Администраторы\n\n{ids_list}\n" + else: + text = "👤 Администраторы\n\nСписок пуст — доступ для всех\n" + + text += ( + f"\nВсего: {len(ALLOWED_IDS)}\n\n" + "Чтобы добавить — перешлите любое сообщение от нового админа, " + "или отправьте команду:\n" + "/addadmin 123456789\n\n" + "Чтобы удалить:\n" + "/deladmin 123456789" + ) + + keyboard = InlineKeyboardMarkup([ + [InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")], + ]) + await safe_edit_message(query, text, reply_markup=keyboard, parse_mode="HTML") + + +async def cmd_addadmin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """/addadmin ID [ID2 ID3 ...] — добавить админа вручную.""" + if not is_user_allowed(update.effective_user.id): + await update.message.reply_text( + f"⛔ Доступ запрещён.\nВаш ID: {update.effective_user.id}", + parse_mode="HTML", + ) + return + + args = context.args or [] + if not args: + await update.message.reply_text( + "Использование: /addadmin ID [ID2 ID3 ...]\n" + "Пример: /addadmin 123456789 987654321", + parse_mode="HTML", + ) + return + + added = [] + errors = [] + for a in args: + a = a.strip().replace(",", "") + if not a: + continue + try: + uid = int(a) + add_admin(uid) + added.append(str(uid)) + except ValueError: + errors.append(a) + + parts = [] + if added: + parts.append(f"✅ Добавлены: {', '.join(added)}") + if errors: + parts.append(f"❌ Ошибки: {', '.join(errors)}") + + await update.message.reply_text("\n".join(parts), parse_mode="HTML") + + +async def cmd_deladmin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """/deladmin ID — удалить админа.""" + if not is_user_allowed(update.effective_user.id): + await update.message.reply_text( + f"⛔ Доступ запрещён.\nВаш ID: {update.effective_user.id}", + parse_mode="HTML", + ) + return + + args = context.args or [] + if not args: + await update.message.reply_text( + "Использование: /deladmin ID", + parse_mode="HTML", + ) + return + + removed = [] + for a in args: + a = a.strip().replace(",", "") + try: + uid = int(a) + if uid == update.effective_user.id: + await update.message.reply_text("⚠️ Нельзя удалить себя!") + continue + if uid in ALLOWED_IDS: + remove_admin(uid) + removed.append(str(uid)) + else: + await update.message.reply_text(f"ID {uid} не найден в списке") + except ValueError: + await update.message.reply_text(f"❌ Некорректный ID: {html.escape(a)}") + + if removed: + await update.message.reply_text(f"✅ Удалены: {', '.join(removed)}") + + +# ============================================================================ +# PROMO & CREDITS +# ============================================================================ + + +def get_promo_text() -> str: + """Return promo text with 2 hosters, optional YouTube link and donate.""" + text = ( + "💰 Хостинг #1 — скидка до 60%\n" + f"{PROMO_LINK_1}\n\n" + "Промокоды:\n" + " OFF60 — 60% на первый месяц\n" + " BONUS20 — 20% + 3% за 3 мес\n" + " BONUS6 — 15% + 5% за 6 мес\n\n" + "━━━━━━━━━━━━━━━━━━━━━━━━━\n\n" + "💰 Хостинг #2 — скидка до 60%\n" + f"{PROMO_LINK_2}\n\n" + "Промокод:\n" + " OFF60 — 60% на первый месяц\n\n" + "━━━━━━━━━━━━━━━━━━━━━━━━━\n\n" + "☕ Донат / Чаевые\n" + f"{TIP_LINK}" + ) + if YOUTUBE_LINK: + text += ( + "\n\n━━━━━━━━━━━━━━━━━━━━━━━━━\n\n" + "▶ YouTube-канал\n" + f"{html.escape(YOUTUBE_LINK)}" + ) + return text + + +def should_show_promo_bot() -> bool: + """Check if promo should be shown (once per 24h).""" + try: + if not os.path.exists(PROMO_STAMP_FILE): + return True + with open(PROMO_STAMP_FILE, "r") as f: + last_ts = int(f.read().strip()) + return (int(time.time()) - last_ts) >= 86400 + except (ValueError, OSError): + return True + + +def mark_promo_shown_bot() -> None: + """Mark promo as shown.""" + try: + os.makedirs(os.path.dirname(PROMO_STAMP_FILE), exist_ok=True) + with open(PROMO_STAMP_FILE, "w") as f: + f.write(str(int(time.time()))) + except OSError: + pass + + +async def cb_menu_promo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Promo information — shown as a separate ephemeral message that + auto-deletes after 30s so it does not clutter the chat. The main menu + message stays intact (we don't edit it in place).""" + query = update.callback_query + await query.answer() + + promo_msg = await query.message.reply_text( + get_promo_text(), parse_mode="HTML", disable_web_page_preview=True + ) + asyncio.create_task(_delete_message_after(promo_msg, 30)) + + +async def cb_menu_credits(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Credits and acknowledgements.""" + query = update.callback_query + await query.answer() + + text = ( + f"ℹ️ Credits & Acknowledgements\n\n" + f"goTelegram Pro v{GOTELEGRAM_VERSION}\n\n" + f"Built with love for the Telegram community\n\n" + f"Special thanks to:\n\n" + f"🙏 telemt - MTProxy engine\n" + f" High-performance proxy core\n\n" + f"🎨 HTML5UP - Beautiful web templates\n" + f" Responsive design & themes\n\n" + f"📚 Learning Zone - Educational resources\n" + f" Community learning support\n\n" + f"🚀 Start Bootstrap - Bootstrap templates\n" + f" Professional design framework\n\n" + f"💬 Community - Your feedback & support\n\n" + f"goTelegram Pro is open-source and community-driven" + ) + + keyboard = InlineKeyboardMarkup( + [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]] + ) + await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") + + +# ============================================================================ +# REMOVE +# ============================================================================ + + +async def cb_menu_remove(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Remove installation.""" + query = update.callback_query + await query.answer() + + text = ( + "⚠️ Remove goTelegram Pro\n\n" + "This will completely remove the installation.\n" + "Are you sure?" + ) + + buttons = [ + [InlineKeyboardButton("❌ Yes, Remove", callback_data="remove_confirm")], + [InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")], + ] + keyboard = InlineKeyboardMarkup(buttons) + await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") + + +async def cb_remove_confirm(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Confirm removal.""" + query = update.callback_query + await query.answer() + + await safe_edit_message(query,"⏳ Removing goTelegram Pro...") + + # Stop service + await sh("systemctl", "stop", TELEMT_SERVICE) + + # Remove directories + for path in ["/opt/gotelegram", WEBSITE_ROOT]: + await sh("rm", "-rf", path) + + text = "✅ goTelegram Pro removed successfully" + keyboard = InlineKeyboardMarkup( + [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]] + ) + await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") + + +# ============================================================================ +# CALLBACK ROUTING +# ============================================================================ + + +async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Route all callbacks.""" + query = update.callback_query + data = query.data + + # ── Авто-регистрация админа (до проверки доступа!) ── + if data.startswith("admin_confirm_"): + await query.answer() + try: + new_admin_id = int(data.split("_")[-1]) + except (ValueError, IndexError): + await safe_edit_message(query, "❌ Ошибка: некорректный ID") + return + # Безопасность: только тот кто нажал кнопку может стать админом + if update.effective_user.id != new_admin_id: + await query.answer("Эта кнопка не для вас", show_alert=True) + return + # Race condition: если кто-то уже стал админом + if not _WAITING_FOR_ADMIN: + await safe_edit_message(query, "ℹ️ Администратор уже назначен.") + return + add_admin(new_admin_id) + await safe_edit_message( + query, + f"✅ Вы назначены администратором!\n\n" + f"ID: {new_admin_id}\n\n" + f"Нажмите /start чтобы открыть меню.", + parse_mode="HTML", + ) + return + + if data == "admin_cancel": + await query.answer() + await safe_edit_message( + query, + "👋 Ок. Напишите /start когда будете готовы.", + ) + return + + # Access control + if not is_user_allowed(update.effective_user.id): + await query.answer("Доступ запрещён") + return + + user_id = update.effective_user.id + + # Main menu + if data == "menu_main": + await query.answer() + buttons = get_main_menu(user_id) + text = ( + f"{_tf(user_id, 'welcome_title', GOTELEGRAM_VERSION)}\n\n" + f"{_t(user_id, 'welcome_subtitle')}\n" + f"{_t(user_id, 'welcome_prompt')}" + ) + await safe_edit_message(query, text, reply_markup=buttons, parse_mode="HTML") + return + + if data == "close_menu": + await query.answer() + await query.delete_message() + return + + # Language picker + if data == "menu_lang": + await query.answer() + current = get_user_lang(user_id) + title = _t(user_id, "lang_title") + curr_line = _tf(user_id, "lang_current", get_language_name(current)) + prompt = _t(user_id, "lang_choose") + text = f"{title}\n\n{curr_line}\n\n{prompt}" + keyboard = InlineKeyboardMarkup([ + [ + InlineKeyboardButton("🇬🇧 English", callback_data="lang_set_en"), + InlineKeyboardButton("🇷🇺 Русский", callback_data="lang_set_ru"), + ], + [InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_main")], + ]) + await safe_edit_message(query, text, reply_markup=keyboard, parse_mode="HTML") + return + + if data.startswith("lang_set_"): + code = data.replace("lang_set_", "", 1) + if code in SUPPORTED_LANGS: + set_user_lang(user_id, code) + await query.answer(_tf(user_id, "lang_saved", get_language_name(code))) + # Re-render main menu in the new language + buttons = get_main_menu(user_id) + text = ( + f"{_tf(user_id, 'welcome_title', GOTELEGRAM_VERSION)}\n\n" + f"{_t(user_id, 'welcome_subtitle')}\n" + f"{_t(user_id, 'welcome_prompt')}" + ) + await safe_edit_message(query, text, reply_markup=buttons, parse_mode="HTML") + else: + await query.answer("Unsupported language") + return + + # Dispatch to handlers + handlers = { + "menu_install": cb_menu_install, + "menu_status": cb_menu_status, + "menu_link": cb_menu_link, + "menu_share": cb_menu_share, + "menu_restart": cb_menu_restart, + "menu_logs": cb_menu_logs, + "menu_backup": cb_menu_backup, + "menu_restore": cb_menu_restore, + "menu_update": cb_menu_update, + "menu_change": cb_menu_change, + "menu_website": cb_menu_website, + "menu_promo": cb_menu_promo, + "menu_credits": cb_menu_credits, + "menu_admin_web": cb_menu_admin_web, + "menu_admins": cb_menu_admins, + "menu_users": cb_menu_users, + "menu_remove": cb_menu_remove, + "install_mode_lite": cb_install_mode_lite, + "install_mode_pro": cb_install_mode_pro, + "backup_create": cb_backup_create, + "backup_list": cb_backup_list, + "ssl_renew": cb_ssl_renew, + "ssl_status": cb_ssl_status, + "remove_confirm": cb_remove_confirm, + "change_lite": cb_change_lite, + "change_pro": cb_change_pro, + "install_migrate": cb_install_migrate, + "menu_stats": cb_menu_stats, + "backup_schedule_off": cb_backup_schedule_set, + "backup_schedule_daily": cb_backup_schedule_set, + "backup_schedule_weekly": cb_backup_schedule_set, + "backup_schedule_monthly": cb_backup_schedule_set, + } + + # Custom git template URL prompt + if data == "pro_custom_git": + await query.answer() + _CUSTOM_GIT_WAITERS[user_id] = True + title = _t(user_id, "cg_title") + body = _t(user_id, "cg_ask_url") + await safe_edit_message( + query, + f"{title}\n\n{body}", + reply_markup=InlineKeyboardMarkup( + [[InlineKeyboardButton(_t(user_id, "btn_cancel"), callback_data="menu_main")]] + ), + parse_mode="HTML", + ) + return + + # Pattern-based handlers + if data.startswith("lite_dom_"): + await cb_lite_domain(update, context) + elif data == "user_add": + await cb_user_add(update, context) + elif data.startswith("user_view_"): + await cb_user_view(update, context) + elif data.startswith("user_qr_"): + await cb_user_qr(update, context) + elif data.startswith("user_ip_limit_"): + await cb_user_ip_limit(update, context) + elif data.startswith("user_toggle_"): + await cb_user_toggle(update, context) + elif data.startswith("user_del_yes_"): + await cb_user_delete_confirm(update, context) + elif data.startswith("user_del_"): + await cb_user_delete(update, context) + elif data.startswith("pro_cat_"): + await cb_pro_category(update, context) + elif data.startswith("pro_tpl_"): + await cb_pro_template(update, context) + elif data.startswith("pro_confirm_"): + await cb_pro_confirm(update, context) + elif data.startswith("restore_idx_") or data.startswith("restore_yes_"): + await cb_restore_backup(update, context) + elif data in handlers: + await handlers[data](update, context) + else: + await query.answer("Unknown action") + + +# ============================================================================ +# ERROR HANDLERS +# ============================================================================ + + +async def handle_text_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handle free-text input. Currently used for custom git template URLs.""" + if update.message is None or update.message.text is None: + return + if not is_user_allowed(update.effective_user.id): + return + user_id = update.effective_user.id + ip_limit_user = context.user_data.pop("awaiting_user_ip_limit", None) + if ip_limit_user: + await set_user_ip_limit_from_text(update, context, update.message.text.strip(), str(ip_limit_user)) + return + if context.user_data.pop("awaiting_user_name", False): + await create_user_from_text(update, context, update.message.text.strip()) + return + + # Only act when we're explicitly waiting for a custom-git URL + if not _CUSTOM_GIT_WAITERS.pop(user_id, False): + return + url = update.message.text.strip() + if not _validate_custom_git_url(url): + await update.message.reply_text(_t(user_id, "cg_invalid"), parse_mode="HTML") + return + await update.message.reply_text(_tf(user_id, "cg_cloning", html.escape(url)), parse_mode="HTML") + ok, tpl_id, info = await _download_custom_git_template(url) + if not ok: + await update.message.reply_text(_t(user_id, info), parse_mode="HTML") + return + # Success — record in goTelegram Pro config. Use "template_id" (canonical + # field name written by install.sh/save_gotelegram_config). + config = load_json(GOTELEGRAM_CONFIG) or {} + config["template_id"] = tpl_id + config["template_source"] = url + save_json(GOTELEGRAM_CONFIG, config) + if config.get("mode") == "pro" and os.path.isdir(info): + try: + os.makedirs(WEBSITE_ROOT, exist_ok=True) + for entry in os.listdir(WEBSITE_ROOT): + path = os.path.join(WEBSITE_ROOT, entry) + if os.path.isdir(path) and not os.path.islink(path): + shutil.rmtree(path) + else: + os.remove(path) + for entry in os.listdir(info): + src = os.path.join(info, entry) + dst = os.path.join(WEBSITE_ROOT, entry) + if os.path.isdir(src): + shutil.copytree(src, dst) + else: + shutil.copy2(src, dst) + except OSError as e: + logger.error("custom template deploy failed: %s", e) + await update.message.reply_text( + _tf(user_id, "cg_ok_fmt", html.escape(tpl_id)), + reply_markup=get_main_menu(user_id), + parse_mode="HTML", + ) + + +async def error_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Log errors caused by Updates.""" + logger.error(f"Exception while handling an update:", exc_info=context.error) + + +# ============================================================================ +# MAIN APPLICATION +# ============================================================================ + + +def main() -> None: + """Start the bot.""" + if not BOT_TOKEN: + logger.error("BOT_TOKEN not set in .env file") + return + + # Create the Application + application = Application.builder().token(BOT_TOKEN).build() + + # Command handlers + application.add_handler(CommandHandler("start", cmd_start)) + application.add_handler(CommandHandler("help", cmd_help)) + application.add_handler(CommandHandler("status", cmd_status)) + application.add_handler(CommandHandler("logs", cmd_logs)) + application.add_handler(CommandHandler("lang", cmd_lang)) + application.add_handler(CommandHandler("addadmin", cmd_addadmin)) + application.add_handler(CommandHandler("deladmin", cmd_deladmin)) + + # Callback query handler (buttons) + application.add_handler(CallbackQueryHandler(handle_callback)) + + # Text message handler (for custom git URL input) + application.add_handler(MessageHandler( + filters.TEXT & ~filters.COMMAND, handle_text_message + )) + + # Error handler + application.add_error_handler(error_handler) + + # Run the bot + logger.info(f"goTelegram Pro v{GOTELEGRAM_VERSION} bot starting...") + application.run_polling(allowed_updates=Update.ALL_TYPES) + + +if __name__ == "__main__": + main() diff --git a/gotelegram-bot/config.example.env b/gotelegram-bot/config.example.env new file mode 100644 index 0000000..a3d0948 --- /dev/null +++ b/gotelegram-bot/config.example.env @@ -0,0 +1,9 @@ +# goTelegram Pro v2.5.0 Bot Configuration +# Copy this to .env and fill in your values + +# Telegram Bot Token from @BotFather +BOT_TOKEN=your_bot_token_from_@BotFather + +# Comma-separated list of allowed Telegram user IDs +# Leave empty to allow all users +# ALLOWED_IDS=123456789,987654321 diff --git a/gotelegram-bot/i18n.py b/gotelegram-bot/i18n.py new file mode 100644 index 0000000..2e425be --- /dev/null +++ b/gotelegram-bot/i18n.py @@ -0,0 +1,167 @@ +""" +goTelegram Pro v2.5.0 Bot — i18n module +Provides per-user language preferences and a simple t()/tf() API. + +Usage: + from i18n import t, tf, set_user_lang, get_user_lang, get_language_name + + msg = t(user_id, "menu_status") + msg = tf(user_id, "backup_created_fmt", filename) + +Language files live next to this module in lang/.json. +Per-user choices are persisted to USER_LANG_FILE (one JSON dict: user_id -> code). +""" + +import json +import logging +import os +from pathlib import Path +from typing import Dict, Optional + +logger = logging.getLogger(__name__) + +# ── Paths ───────────────────────────────────────────────────────────────── +_MODULE_DIR = Path(__file__).resolve().parent +LANG_DIR = _MODULE_DIR / "lang" +USER_LANG_FILE = Path("/opt/gotelegram-bot/user_langs.json") +GOTELEGRAM_CONFIG = Path("/opt/gotelegram/config.json") +GOTELEGRAM_LANG_MARKER = Path("/opt/gotelegram/.language") + +# Supported codes; keep in sync with lang/*.json +SUPPORTED_LANGS = ("en", "ru") + + +def _detect_default_lang() -> str: + candidates = [] + try: + if GOTELEGRAM_CONFIG.exists(): + with open(GOTELEGRAM_CONFIG, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, dict): + candidates.extend([data.get("language"), data.get("lang")]) + except Exception as e: + logger.warning("failed to read goTelegram Pro language config: %s", e) + try: + if GOTELEGRAM_LANG_MARKER.exists(): + candidates.append(GOTELEGRAM_LANG_MARKER.read_text(encoding="utf-8").strip()[:2]) + except Exception as e: + logger.warning("failed to read goTelegram Pro language marker: %s", e) + candidates.append(os.getenv("BOT_LANG", "")) + for raw in candidates: + code = str(raw or "").strip().lower() + if code in SUPPORTED_LANGS: + return code + return "en" + + +DEFAULT_LANG = _detect_default_lang() + +LANG_NAMES = { + "en": "English", + "ru": "Русский", +} + +# ── Caches ──────────────────────────────────────────────────────────────── +_LANG_CACHE: Dict[str, Dict[str, str]] = {} +_USER_LANGS: Dict[int, str] = {} +_USER_LANGS_LOADED = False + + +def _load_lang_file(code: str) -> Dict[str, str]: + """Load lang/.json into the cache and return it.""" + if code in _LANG_CACHE: + return _LANG_CACHE[code] + path = LANG_DIR / f"{code}.json" + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + if not isinstance(data, dict): + raise ValueError("lang file must contain a top-level object") + _LANG_CACHE[code] = data + return data + except FileNotFoundError: + logger.warning("lang file not found: %s", path) + except Exception as e: + logger.warning("failed to load %s: %s", path, e) + _LANG_CACHE[code] = {} + return _LANG_CACHE[code] + + +def _load_user_langs() -> None: + """Load per-user language preferences from USER_LANG_FILE.""" + global _USER_LANGS, _USER_LANGS_LOADED + _USER_LANGS_LOADED = True + try: + if USER_LANG_FILE.exists(): + with open(USER_LANG_FILE, "r", encoding="utf-8") as f: + raw = json.load(f) + if isinstance(raw, dict): + _USER_LANGS = { + int(k): v for k, v in raw.items() + if isinstance(v, str) and v in SUPPORTED_LANGS + } + except Exception as e: + logger.warning("failed to load user_langs: %s", e) + _USER_LANGS = {} + + +def _save_user_langs() -> None: + """Persist per-user language preferences.""" + try: + USER_LANG_FILE.parent.mkdir(parents=True, exist_ok=True) + with open(USER_LANG_FILE, "w", encoding="utf-8") as f: + json.dump( + {str(k): v for k, v in _USER_LANGS.items()}, + f, ensure_ascii=False, indent=2, + ) + except Exception as e: + logger.warning("failed to save user_langs: %s", e) + + +# ── Public API ──────────────────────────────────────────────────────────── + +def get_user_lang(user_id: Optional[int]) -> str: + """Return the language code for the given user (or DEFAULT_LANG).""" + if not _USER_LANGS_LOADED: + _load_user_langs() + if user_id is None: + return _detect_default_lang() + return _USER_LANGS.get(int(user_id), _detect_default_lang()) + + +def set_user_lang(user_id: int, code: str) -> bool: + """Set the per-user language preference and persist it.""" + if not _USER_LANGS_LOADED: + _load_user_langs() + code = (code or "").strip().lower() + if code not in SUPPORTED_LANGS: + return False + _USER_LANGS[int(user_id)] = code + _save_user_langs() + return True + + +def get_language_name(code: str) -> str: + return LANG_NAMES.get(code, code) + + +def t(user_id: Optional[int], key: str, default: Optional[str] = None) -> str: + """Translate key for the given user. Falls back to English, then default/key.""" + code = get_user_lang(user_id) + table = _load_lang_file(code) + if key in table: + return table[key] + if code != "en": + en_table = _load_lang_file("en") + if key in en_table: + return en_table[key] + return default if default is not None else key + + +def tf(user_id: Optional[int], key: str, *args, default: Optional[str] = None) -> str: + """Format a translated string with positional args using %-formatting.""" + template = t(user_id, key, default=default) + try: + return template % args if args else template + except (TypeError, ValueError): + return template diff --git a/gotelegram-bot/lang/en.json b/gotelegram-bot/lang/en.json new file mode 100644 index 0000000..8466544 --- /dev/null +++ b/gotelegram-bot/lang/en.json @@ -0,0 +1,118 @@ +{ + "lang_english": "English", + "lang_russian": "Русский", + "lang_title": "🌐 Language", + "lang_current": "Current language: %s", + "lang_saved": "Language saved: %s", + "lang_choose": "Choose your language:", + + "welcome_title": "goTelegram Pro v%s", + "welcome_subtitle": "🤖 MTProxy Management Bot", + "welcome_powered": "Powered by telemt engine", + "welcome_prompt": "Select an action from the menu below:", + + "waiting_admin_title": "👋 Hi, %s!", + "waiting_admin_body": "The bot is not configured yet.\nYour Telegram ID: %s\n\nAssign you as administrator?", + "btn_yes": "✅ Yes", + "btn_no": "❌ No", + + "access_denied": "⛔ Access denied.\nYour ID: %s", + "help_title": "goTelegram Pro Bot — Commands", + "help_lines": "/start — Main menu\n/help — This help\n/status — Quick status\n/logs — Latest logs\n/lang — Change language\n/addadmin ID — Add admin\n/deladmin ID — Remove admin\n\nUse the menu buttons for other operations.", + + "menu_install": "⚙️ Install", + "menu_status": "📊 Status", + "menu_link": "🔗 Link", + "menu_share": "📤 Share", + "menu_restart": "🔄 Restart", + "menu_logs": "📋 Logs", + "menu_change": "⚡ Change Mode/Template", + "menu_backup": "💾 Backup", + "menu_restore": "↩️ Restore", + "menu_update": "📡 Update telemt", + "menu_website": "🌐 Website/SSL", + "menu_promo": "🎁 Promo", + "menu_stats": "📊 Traffic Stats", + "menu_users": "🔑 Keys", + "menu_admin_web": "🖥 Web Admin", + "menu_remove": "🗑️ Remove", + "menu_admins": "👤 Admins", + "menu_credits": "ℹ️ Credits", + "menu_language": "🌐 Language", + "menu_close": "❌ Close", + "btn_back": "⬅️ Back", + "btn_refresh": "🔄 Refresh", + "btn_cancel": "❌ Cancel", + "btn_confirm": "✅ Confirm", + + "status_checking": "⏳ Checking status...", + "status_title": "📊 Current Status", + "status_service": "Service", + "status_running": "✅ Running", + "status_stopped": "❌ Stopped", + "status_telemt": "Telemt", + "status_mode": "Mode", + "status_template": "Template", + "status_domain": "Domain", + "status_port": "Port", + "status_listen_port": "Listen Port", + "status_tls_domain": "TLS Domain", + + "logs_failed": "Failed to retrieve logs", + "link_fetching": "⏳ Fetching link...", + "link_unavailable": "Link unavailable (proxy not installed?)", + "share_title": "📤 Share Proxy", + "share_body": "Send this link to your client:", + + "restart_title": "🔄 Restart", + "restart_progress": "⏳ Restarting telemt...", + "restart_ok": "✅ telemt restarted", + "restart_fail": "❌ Restart failed", + + "install_title": "⚙️ Install / Update", + "install_pick_mode": "Select installation mode:", + "install_mode_lite": "🚀 Lite (quick, no site)", + "install_mode_pro": "🎨 Pro (stealth + website)", + + "backup_title": "💾 Backup", + "backup_creating": "⏳ Creating backup...", + "backup_created_fmt": "✅ Backup created: %s", + "backup_failed": "❌ Backup creation failed", + "backup_list_title": "Available backups:", + "backup_none": "No backups yet", + "backup_restore_title": "↩️ Restore backup", + "backup_restoring": "⏳ Restoring...", + "backup_restored": "✅ Backup restored", + + "update_title": "📡 Update telemt", + "update_progress": "⏳ Updating telemt binary...", + "update_ok": "✅ telemt updated", + "update_fail": "❌ Update failed", + + "website_title": "🌐 Website / SSL", + "ssl_renew_progress": "⏳ Renewing SSL...", + "ssl_renewed": "✅ SSL renewed", + "ssl_renew_fail": "❌ SSL renew failed", + "ssl_status_title": "🔒 SSL Status", + + "remove_title": "🗑️ Remove", + "remove_warn": "⚠️ This will stop and remove telemt, nginx site and configs. Continue?", + "remove_progress": "⏳ Removing...", + "remove_done": "✅ Removed", + + "admins_title": "👤 Administrators", + "admins_list": "Current admin IDs:", + "admins_empty": "No admins configured", + + "promo_title": "🎁 Promo", + "credits_title": "ℹ️ Credits", + + "cg_title": "🔗 Custom Git Template", + "cg_ask_url": "Send me the HTTPS git URL of a static site repository.\nOptionally append @branch.", + "cg_cloning": "⏳ Cloning %s ...", + "cg_invalid": "❌ Invalid URL. Only HTTPS git URLs are allowed.", + "cg_timeout": "❌ Clone timeout (repository too large or slow)", + "cg_too_big": "❌ Repository too large (>100MB)", + "cg_no_index": "❌ No index.html found in repository", + "cg_ok_fmt": "✅ Custom template downloaded: %s" +} diff --git a/gotelegram-bot/lang/ru.json b/gotelegram-bot/lang/ru.json new file mode 100644 index 0000000..60d24a5 --- /dev/null +++ b/gotelegram-bot/lang/ru.json @@ -0,0 +1,118 @@ +{ + "lang_english": "English", + "lang_russian": "Русский", + "lang_title": "🌐 Язык", + "lang_current": "Текущий язык: %s", + "lang_saved": "Язык сохранён: %s", + "lang_choose": "Выберите язык:", + + "welcome_title": "goTelegram Pro v%s", + "welcome_subtitle": "🤖 Бот управления MTProxy", + "welcome_powered": "На базе движка telemt", + "welcome_prompt": "Выберите действие в меню ниже:", + + "waiting_admin_title": "👋 Привет, %s!", + "waiting_admin_body": "Бот ещё не настроен.\nВаш Telegram ID: %s\n\nНазначить вас администратором?", + "btn_yes": "✅ Да", + "btn_no": "❌ Нет", + + "access_denied": "⛔ Доступ запрещён.\nВаш ID: %s", + "help_title": "goTelegram Pro Bot — Команды", + "help_lines": "/start — Главное меню\n/help — Эта справка\n/status — Быстрый статус\n/logs — Последние логи\n/lang — Сменить язык\n/addadmin ID — Добавить админа\n/deladmin ID — Удалить админа\n\nИспользуйте кнопки меню для остальных операций.", + + "menu_install": "⚙️ Установить", + "menu_status": "📊 Статус", + "menu_link": "🔗 Ссылка", + "menu_share": "📤 Поделиться", + "menu_restart": "🔄 Перезапуск", + "menu_logs": "📋 Логи", + "menu_change": "⚡ Сменить режим/шаблон", + "menu_backup": "💾 Бекап", + "menu_restore": "↩️ Восстановить", + "menu_update": "📡 Обновить telemt", + "menu_website": "🌐 Сайт/SSL", + "menu_promo": "🎁 Промо", + "menu_stats": "📊 Трафик", + "menu_users": "🔑 Ключи", + "menu_admin_web": "🖥 Веб-админка", + "menu_remove": "🗑️ Удалить", + "menu_admins": "👤 Админы", + "menu_credits": "ℹ️ О проекте", + "menu_language": "🌐 Язык", + "menu_close": "❌ Закрыть", + "btn_back": "⬅️ Назад", + "btn_refresh": "🔄 Обновить", + "btn_cancel": "❌ Отмена", + "btn_confirm": "✅ Подтвердить", + + "status_checking": "⏳ Проверяю статус...", + "status_title": "📊 Текущий статус", + "status_service": "Сервис", + "status_running": "✅ Работает", + "status_stopped": "❌ Остановлен", + "status_telemt": "Telemt", + "status_mode": "Режим", + "status_template": "Шаблон", + "status_domain": "Домен", + "status_port": "Порт", + "status_listen_port": "Порт прослушивания", + "status_tls_domain": "TLS домен", + + "logs_failed": "Не удалось получить логи", + "link_fetching": "⏳ Получаю ссылку...", + "link_unavailable": "Ссылка недоступна (прокси не установлен?)", + "share_title": "📤 Поделиться прокси", + "share_body": "Отправьте эту ссылку клиенту:", + + "restart_title": "🔄 Перезапуск", + "restart_progress": "⏳ Перезапускаю telemt...", + "restart_ok": "✅ telemt перезапущен", + "restart_fail": "❌ Ошибка перезапуска", + + "install_title": "⚙️ Установка / Обновление", + "install_pick_mode": "Выберите режим установки:", + "install_mode_lite": "🚀 Lite (быстро, без сайта)", + "install_mode_pro": "🎨 Pro (маскировка + сайт)", + + "backup_title": "💾 Бекап", + "backup_creating": "⏳ Создаю бекап...", + "backup_created_fmt": "✅ Бекап создан: %s", + "backup_failed": "❌ Не удалось создать бекап", + "backup_list_title": "Доступные бекапы:", + "backup_none": "Бекапов пока нет", + "backup_restore_title": "↩️ Восстановление бекапа", + "backup_restoring": "⏳ Восстанавливаю...", + "backup_restored": "✅ Бекап восстановлен", + + "update_title": "📡 Обновление telemt", + "update_progress": "⏳ Обновляю telemt...", + "update_ok": "✅ telemt обновлён", + "update_fail": "❌ Ошибка обновления", + + "website_title": "🌐 Сайт / SSL", + "ssl_renew_progress": "⏳ Обновляю SSL...", + "ssl_renewed": "✅ SSL обновлён", + "ssl_renew_fail": "❌ Ошибка обновления SSL", + "ssl_status_title": "🔒 Статус SSL", + + "remove_title": "🗑️ Удаление", + "remove_warn": "⚠️ Это остановит и удалит telemt, сайт nginx и конфиги. Продолжить?", + "remove_progress": "⏳ Удаляю...", + "remove_done": "✅ Удалено", + + "admins_title": "👤 Администраторы", + "admins_list": "Текущие ID админов:", + "admins_empty": "Админы не настроены", + + "promo_title": "🎁 Промо", + "credits_title": "ℹ️ О проекте", + + "cg_title": "🔗 Свой git-шаблон", + "cg_ask_url": "Отправьте HTTPS git-URL репозитория со статическим сайтом.\nПри желании добавьте @branch.", + "cg_cloning": "⏳ Клонирую %s ...", + "cg_invalid": "❌ Неверный URL. Разрешены только HTTPS git-URL.", + "cg_timeout": "❌ Таймаут клонирования (репозиторий слишком большой или медленный)", + "cg_too_big": "❌ Репозиторий слишком большой (>100МБ)", + "cg_no_index": "❌ В репозитории не найден index.html", + "cg_ok_fmt": "✅ Свой шаблон загружен: %s" +} diff --git a/gotelegram-bot/requirements.txt b/gotelegram-bot/requirements.txt new file mode 100644 index 0000000..46050d1 --- /dev/null +++ b/gotelegram-bot/requirements.txt @@ -0,0 +1,3 @@ +python-telegram-bot>=21.0 +python-dotenv>=1.0.0 +toml>=0.10.2 diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..d7c0188 --- /dev/null +++ b/install.sh @@ -0,0 +1,1939 @@ +#!/bin/bash +# ══════════════════════════════════════════════════════════════════════════════ +# goTelegram Pro v2.5.0 — MTProxy powered by telemt (Rust + Tokio) +# Anti-DPI • Fake TLS • TCP Splice • JA3/JA4 Resistance • i18n (EN/RU) +# +# Install: +# curl -sL URL/bootstrap.sh | sudo bash +# ══════════════════════════════════════════════════════════════════════════════ + +set -uo pipefail + +# Script path and libraries +SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" +LIB_DIR="$SCRIPT_DIR/lib" + +# Load libraries +source "$LIB_DIR/common.sh" +source "$LIB_DIR/i18n.sh" +source "$LIB_DIR/telemt.sh" +source "$LIB_DIR/telemt_config.sh" +source "$LIB_DIR/website.sh" +source "$LIB_DIR/templates_catalog.sh" +source "$LIB_DIR/backup.sh" +[ -f "$LIB_DIR/stats.sh" ] && source "$LIB_DIR/stats.sh" +[ -f "$LIB_DIR/shared443.sh" ] && source "$LIB_DIR/shared443.sh" + +# Load language (from config.json or marker file, default en) +load_language "$(detect_language)" + +# ── Главное меню (Compact Dashboard + 5 Top-Level Items) ────────────────────── +show_main_menu() { + local proxy_status bot_status nginx_st mode domain secret port ip link ssl_expiry + proxy_status=$(telemt_status) + bot_status=$(bot_service_status) + nginx_st=$(nginx_status 2>/dev/null || echo "stopped") + mode=$(config_get mode 2>/dev/null || echo "—") + domain=$(config_get domain 2>/dev/null || echo "") + secret=$(get_config_value secret 2>/dev/null || echo "") + port=$(get_config_value port 2>/dev/null || echo "443") + ip=$(get_server_ip 2>/dev/null || echo "N/A") + + local W=54 + local line; line=$(printf '━%.0s' $(seq 1 $W)) + local line2; line2=$(printf '─%.0s' $(seq 1 $W)) + + # ── Header (no right border — ANSI breaks alignment) ── + echo "" + echo -e " ${BOLD}${CYAN}━${line}━${NC}" + echo -e " ${BOLD}${WHITE} goTelegram Pro v${GOTELEGRAM_VERSION}${NC} ${DIM}— $(t dashboard_title)${NC}" + echo -e " ${BOLD}${CYAN}━${line}━${NC}" + + # ── Service health ── + echo "" + echo -e " ${DIM}${line2}${NC}" + + # Proxy + local proxy_icon proxy_color + case "$proxy_status" in + running) proxy_icon="●"; proxy_color="${GREEN}" ;; + stopped) proxy_icon="○"; proxy_color="${YELLOW}" ;; + *) proxy_icon="✗"; proxy_color="${RED}" ;; + esac + echo -e " ${proxy_color}${proxy_icon}${NC} $(t svc_proxy) ${proxy_color}${proxy_status}${NC} ${DIM}(telemt ${mode})${NC}" + + # nginx + local nginx_icon nginx_color + case "$nginx_st" in + running) nginx_icon="●"; nginx_color="${GREEN}" ;; + *) nginx_icon="✗"; nginx_color="${RED}" ;; + esac + echo -e " ${nginx_icon}${nginx_color}${NC} $(t svc_nginx) ${nginx_color}${nginx_st}${NC} ${DIM}(127.0.0.1:8443)${NC}" + + # Site (pro) + if [ "$mode" = "pro" ] && [ -n "$domain" ]; then + local site_icon site_color + if curl -sk --max-time 3 "https://${domain}/" -o /dev/null 2>/dev/null; then + site_icon="●"; site_color="${GREEN}" + else + site_icon="✗"; site_color="${RED}" + fi + echo -e " ${site_color}${site_icon}${NC} $(t svc_site) ${site_color}https://${domain}${NC}" + + ssl_expiry=$(get_ssl_expiry "$domain" 2>/dev/null || echo "N/A") + echo -e " ${GREEN}●${NC} $(t svc_ssl) ${DIM}$(tf ssl_until "$ssl_expiry")${NC}" + fi + + # Bot + case "$bot_status" in + running) echo -e " ${GREEN}●${NC} $(t svc_bot) ${GREEN}$(t running)${NC}" ;; + stopped) echo -e " ${YELLOW}○${NC} $(t svc_bot) ${YELLOW}$(t stopped)${NC}" ;; + esac + + echo -e " ${DIM}${line2}${NC}" + + # ── Network parameters ── + echo -e " ${WHITE}$(t net_ip)${NC} ${CYAN}${ip}${NC} ${WHITE}$(t net_port)${NC} ${CYAN}${port}${NC} ${WHITE}$(t net_mode)${NC} ${CYAN}${mode}${NC}" + if [ -n "$domain" ]; then + echo -e " ${WHITE}$(t net_domain)${NC} ${CYAN}${domain}${NC}" + fi + + echo -e " ${DIM}${line2}${NC}" + + # ── Proxy link + QR ── + local mask_host + mask_host=$(config_get mask_host 2>/dev/null || echo "") + if [ -n "$secret" ] && [ "$proxy_status" = "running" ]; then + if [ "$mode" = "pro" ] && [ -n "$domain" ]; then + link=$(generate_proxy_link "$domain" "$port" "$secret" "$domain") + else + link=$(generate_proxy_link "$ip" "$port" "$secret" "$mask_host") + fi + + echo -e " ${BOLD}${WHITE}$(t connection_link)${NC}" + echo -e " ${GREEN}${link}${NC}" + + if command -v qrencode &>/dev/null; then + echo "" + qrencode -t UTF8 -m 2 "$link" 2>/dev/null | while IFS= read -r qr_line; do + echo " ${qr_line}" + done + echo "" + fi + else + echo -e " ${DIM}$(t proxy_not_configured)${NC}" + echo "" + fi + + # ── Menu ── + echo -e " ${DIM}${line2}${NC}" + echo -e " ${CYAN}1${NC}) $(t menu_proxy)" + echo -e " ${CYAN}2${NC}) $(t menu_stats)" + echo -e " ${CYAN}3${NC}) $(t menu_manage)" + echo -e " ${CYAN}4${NC}) $(t menu_telegram_bot)" + echo -e " ${CYAN}5${NC}) $(t menu_about)" + echo -e " ${CYAN}0${NC}) ${DIM}$(t exit)${NC}" + echo -e " ${DIM}${line2}${NC}" + echo -e " ${DIM}$(t auto_refresh_30s)${NC}" + echo -ne " ${WHITE}▸ ${NC}" +} + +# ── Submenu: Proxy ────────────────────────────────────────────────────────── +submenu_proxy() { + while true; do + echo "" + echo -e " ${BOLD}${WHITE}$(t submenu_proxy_title)${NC}" + echo -e " ${DIM}$(printf '─%.0s' {1..54})${NC}" + echo -e " ${CYAN}1${NC}) $(t proxy_install_update)" + echo -e " ${CYAN}2${NC}) $(t proxy_status_detail)" + echo -e " ${CYAN}3${NC}) $(t proxy_copy_link)" + echo -e " ${CYAN}4${NC}) $(t proxy_share)" + echo -e " ${CYAN}5${NC}) $(t proxy_restart)" + echo -e " ${CYAN}6${NC}) $(t proxy_logs)" + echo -e " ${CYAN}7${NC}) $(t proxy_change_mode)" + echo -e " ${CYAN}0${NC}) $(t back)" + echo -e " ${DIM}$(printf '─%.0s' {1..54})${NC}" + echo -ne " ${WHITE}$(t choose):${NC} " + read -r ch + + case "$ch" in + 1) menu_install ;; + 2) menu_status ;; + 3) menu_link ;; + 4) menu_share ;; + 5) menu_restart ;; + 6) menu_logs ;; + 7) menu_change_mode ;; + 0) break ;; + *) log_error "$(t invalid_choice)" ;; + esac + + echo "" + echo -ne " ${DIM}$(t press_enter)${NC}" + read -r + done +} + +# ── Submenu: Management ───────────────────────────────────────────────────── +submenu_manage() { + while true; do + echo "" + echo -e " ${BOLD}${WHITE}$(t submenu_manage_title)${NC}" + echo -e " ${DIM}$(printf '─%.0s' {1..54})${NC}" + echo -e " ${CYAN}1${NC}) $(t manage_backup)" + echo -e " ${CYAN}2${NC}) $(t manage_restore)" + echo -e " ${CYAN}3${NC}) $(t manage_update_telemt)" + echo -e " ${CYAN}4${NC}) $(t manage_site_ssl)" + echo -e " ${CYAN}5${NC}) $(t manage_remove)" + echo -e " ${CYAN}6${NC}) $(t manage_language)" + echo -e " ${CYAN}0${NC}) $(t back)" + echo -e " ${DIM}$(printf '─%.0s' {1..54})${NC}" + echo -ne " ${WHITE}$(t choose):${NC} " + read -r ch + + case "$ch" in + 1) interactive_backup ;; + 2) interactive_restore ;; + 3) update_telemt ;; + 4) menu_website ;; + 5) menu_remove ;; + 6) menu_language ;; + 0) break ;; + *) log_error "$(t invalid_choice)" ;; + esac + + echo "" + echo -ne " ${DIM}$(t press_enter)${NC}" + read -r + done +} + +# ── Submenu: About ────────────────────────────────────────────────────────── +submenu_about() { + while true; do + echo "" + echo -e " ${BOLD}${WHITE}$(t submenu_about_title)${NC}" + echo -e " ${DIM}$(printf '─%.0s' {1..54})${NC}" + echo -e " ${CYAN}1${NC}) $(t about_version_info)" + echo -e " ${CYAN}2${NC}) $(t about_promo)" + echo -e " ${CYAN}0${NC}) $(t back)" + echo -e " ${DIM}$(printf '─%.0s' {1..54})${NC}" + echo -ne " ${WHITE}$(t choose):${NC} " + read -r ch + + case "$ch" in + 1) menu_version ;; + 2) menu_promo ;; + 0) break ;; + *) log_error "$(t invalid_choice)" ;; + esac + + echo "" + echo -ne " ${DIM}$(t press_enter)${NC}" + read -r + done +} + +# ── Version info ──────────────────────────────────────────────────────────── +menu_version() { + echo "" + echo -e " ${BOLD}${WHITE}$(t version_title)${NC}" + echo -e " ${DIM}$(printf '─%.0s' {1..54})${NC}" + echo -e " ${WHITE}$(t version_label)${NC} v${GOTELEGRAM_VERSION}" + echo -e " ${WHITE}$(t version_engine)${NC} telemt (Rust + Tokio)" + echo -e " ${WHITE}$(t version_tech)${NC} Anti-DPI, Fake TLS, TCP Splice" + echo -e " ${WHITE}$(t version_license)${NC} MIT" + echo -e " ${DIM}$(printf '─%.0s' {1..54})${NC}" +} + +# ── Upgrade migration ──────────────────────────────────────────────────────── +snapshot_preupgrade_state() { + local marker="$GOTELEGRAM_DIR/.preupgrade_${GOTELEGRAM_VERSION}_done" + [ -f "$marker" ] && return 0 + mkdir -p "$BACKUP_DIR" + + local ts tmp archive + ts=$(date +%Y%m%d_%H%M%S) + tmp="/tmp/gotelegram_preupgrade_${ts}" + archive="$BACKUP_DIR/preupgrade_${GOTELEGRAM_VERSION}_${ts}.tar.gz" + mkdir -p "$tmp" + + [ -f "$GOTELEGRAM_CONFIG" ] && mkdir -p "$tmp/opt/gotelegram" && cp "$GOTELEGRAM_CONFIG" "$tmp/opt/gotelegram/config.json" 2>/dev/null + [ -f "$TELEMT_CONFIG" ] && mkdir -p "$tmp/etc/telemt" && cp "$TELEMT_CONFIG" "$tmp/etc/telemt/config.toml" 2>/dev/null + [ -f "$NGINX_SITE_CONF" ] && mkdir -p "$tmp/etc/nginx/sites-available" && cp "$NGINX_SITE_CONF" "$tmp/etc/nginx/sites-available/gotelegram" 2>/dev/null + [ -d "$WEBSITE_ROOT" ] && mkdir -p "$tmp/var/www/gotelegram-site" && cp -a "$WEBSITE_ROOT/." "$tmp/var/www/gotelegram-site/" 2>/dev/null + [ -f "$BOT_DIR/.env" ] && mkdir -p "$tmp/opt/gotelegram-bot" && cp "$BOT_DIR/.env" "$tmp/opt/gotelegram-bot/.env" 2>/dev/null + + if tar czf "$archive" -C "$tmp" . 2>/dev/null; then + log_dim "Pre-upgrade snapshot: $archive" + touch "$marker" 2>/dev/null || true + fi + rm -rf "$tmp" +} + +read_config_or_default() { + local key="$1" fallback="$2" + config_get "$key" 2>/dev/null || echo "$fallback" +} + +detect_deployed_template_id() { + local tpl="" + if [ -f "$WEBSITE_ROOT/.gotelegram_template_id" ]; then + tpl=$(head -1 "$WEBSITE_ROOT/.gotelegram_template_id" 2>/dev/null || echo "") + [ -n "$tpl" ] && { echo "$tpl"; return 0; } + fi + if [ -d "$WEBSITE_ROOT" ] && [ -f "$WEBSITE_ROOT/index.html" ]; then + echo "deployed_site" + return 0 + fi + tpl=$(read_config_or_default template_id "") + [ -n "$tpl" ] && { echo "$tpl"; return 0; } + echo "" +} + +detect_template_source() { + local src + if [ -f "$WEBSITE_ROOT/.gotelegram_template_source" ]; then + src=$(head -1 "$WEBSITE_ROOT/.gotelegram_template_source" 2>/dev/null || echo "") + [ -n "$src" ] && { echo "$src"; return 0; } + fi + [ -d "$WEBSITE_ROOT" ] && [ -f "$WEBSITE_ROOT/index.html" ] && return 0 + read_config_or_default template_source "" +} + +write_normalized_gotelegram_config() { + local mode="$1" port="$2" secret="$3" mask_host="$4" domain="$5" tpl_id="$6" tpl_source="$7" + local lang installed_at stats_enabled tmp + lang=$(read_config_or_default language "$(get_language 2>/dev/null || echo en)") + installed_at=$(read_config_or_default installed_at "$(date -Iseconds)") + stats_enabled=$(read_config_or_default stats_enabled "") + tmp=$(mktemp) || return 1 + + jq -n \ + --arg version "$GOTELEGRAM_VERSION" \ + --arg engine "telemt" \ + --arg mode "$mode" \ + --argjson port "$port" \ + --arg secret "$secret" \ + --arg mask_host "$mask_host" \ + --arg domain "$domain" \ + --arg template_id "$tpl_id" \ + --arg template_source "$tpl_source" \ + --arg language "$lang" \ + --arg installed_at "$installed_at" \ + --arg updated_at "$(date -Iseconds)" \ + --arg stats_enabled "$stats_enabled" \ + '{ + version: $version, + engine: $engine, + mode: $mode, + port: $port, + secret: $secret, + mask_host: $mask_host, + domain: $domain, + template_id: $template_id, + language: $language, + installed_at: $installed_at, + updated_at: $updated_at + } + + (if $template_source != "" then {template_source: $template_source} else {} end) + + (if $stats_enabled == "true" then {stats_enabled: true} elif $stats_enabled == "false" then {stats_enabled: false} else {} end)' \ + > "$tmp" || { rm -f "$tmp"; return 1; } + + mkdir -p "$(dirname "$GOTELEGRAM_CONFIG")" + mv "$tmp" "$GOTELEGRAM_CONFIG" + chmod 600 "$GOTELEGRAM_CONFIG" +} + +auto_migrate_legacy_state() { + local marker="$GOTELEGRAM_DIR/.migrated_${GOTELEGRAM_VERSION}" + local current_version + current_version=$(read_config_or_default version "") + if [ -f "$marker" ] && [ "$current_version" = "$GOTELEGRAM_VERSION" ]; then + return 0 + fi + + [ -f "$TELEMT_CONFIG" ] || [ -f "$GOTELEGRAM_CONFIG" ] || [ -d "$WEBSITE_ROOT" ] || return 0 + + log_step "Миграция состояния goTelegram Pro" + snapshot_preupgrade_state + + local mode port secret mask_host domain mask_port tpl_id tpl_source users_block tls_emulation changed=0 users_block_needs_write=0 + users_block=$(get_telemt_users_block "$TELEMT_CONFIG" 2>/dev/null || true) + secret=$(get_config_value secret "$TELEMT_CONFIG" 2>/dev/null || echo "") + [ -z "$secret" ] && secret=$(read_config_or_default secret "") + [ -z "$secret" ] && secret=$(first_telemt_user_secret "$TELEMT_CONFIG" 2>/dev/null || echo "") + [ -z "$secret" ] && secret=$(generate_hex 32) + + if [ -n "$users_block" ] && ! telemt_users_block_has_main "$users_block"; then + users_block=$(printf 'main = "%s"\n%s\n' "$secret" "$users_block") + users_block_needs_write=1 + fi + if [ -z "$users_block" ]; then + users_block="main = \"$secret\"" + users_block_needs_write=1 + fi + + port=$(get_config_value port "$TELEMT_CONFIG" 2>/dev/null || echo "") + [ -z "$port" ] && port=$(read_config_or_default port "443") + [[ "$port" =~ ^[0-9]+$ ]] || port=443 + + mask_host=$(get_config_value mask_host "$TELEMT_CONFIG" 2>/dev/null || echo "") + [ -z "$mask_host" ] && mask_host=$(read_config_or_default mask_host "google.com") + domain=$(read_config_or_default domain "") + mask_port=$(get_config_value mask_port "$TELEMT_CONFIG" 2>/dev/null || echo "") + [ -z "$mask_port" ] && mask_port="443" + tls_emulation=$(toml_bool_value censorship tls_emulation "$TELEMT_CONFIG" 2>/dev/null || echo "") + + mode=$(read_config_or_default mode "") + if [ -z "$mode" ]; then + if [ -n "$domain" ] || [ "$tls_emulation" = "false" ] || grep -q 'dns_overrides' "$TELEMT_CONFIG" 2>/dev/null; then + mode="pro" + else + mode="lite" + fi + fi + if [ "$mode" = "pro" ]; then + [ -z "$domain" ] && domain="$mask_host" + [ -n "$domain" ] && mask_host="$domain" + [ "$mask_port" = "443" ] && mask_port="8443" + else + domain="" + mask_port="443" + fi + + tpl_id=$(detect_deployed_template_id) + tpl_source=$(detect_template_source || echo "") + if [ -d "$WEBSITE_ROOT" ] && [ -f "$WEBSITE_ROOT/index.html" ] && [ -n "$tpl_id" ]; then + echo "$tpl_id" > "$WEBSITE_ROOT/.gotelegram_template_id" 2>/dev/null || true + [ -n "$tpl_source" ] && echo "$tpl_source" > "$WEBSITE_ROOT/.gotelegram_template_source" 2>/dev/null || true + fi + + if [ -f "$TELEMT_CONFIG" ]; then + if ! grep -q '\[server.api\]' "$TELEMT_CONFIG" 2>/dev/null || \ + ! grep -q 'metrics_listen' "$TELEMT_CONFIG" 2>/dev/null || \ + ! grep -q "goTelegram Pro v${GOTELEGRAM_VERSION}" "$TELEMT_CONFIG" 2>/dev/null; then + generate_telemt_toml "$secret" "$port" "$mode" "$mask_host" "$mask_port" "$TELEMT_CONFIG" >&2 + replace_telemt_users_block "$users_block" "$TELEMT_CONFIG" + changed=1 + users_block_needs_write=0 + elif [ "$users_block_needs_write" = "1" ]; then + replace_telemt_users_block "$users_block" "$TELEMT_CONFIG" + changed=1 + fi + fi + + write_normalized_gotelegram_config "$mode" "$port" "$secret" "$mask_host" "$domain" "$tpl_id" "$tpl_source" || \ + log_warning "Не удалось нормализовать config.json" + + if [ "$changed" = "1" ] && systemctl is-active --quiet "$TELEMT_SERVICE" 2>/dev/null; then + log_info "Перезапускаю telemt, чтобы применить нормализованный конфиг..." + restart_telemt || log_warning "telemt не перезапустился после миграции; проверьте journalctl -u telemt" + fi + + touch "$marker" 2>/dev/null || true + log_success "Миграция завершена: ключи, режим, домен и сайт сохранены" +} + +# ── Install: mode selection ───────────────────────────────────────────────── +menu_install() { + # Check for v1 + if detect_v1_installation; then + echo "" + echo -e " ${YELLOW}$(t v1_detected)${NC}" + echo -e " ${DIM}$(tf v1_container "$V1_CONTAINER_NAME")${NC}" + echo "" + if ! migrate_v1_to_v2; then + return + fi + fi + + echo "" + echo -e " ${BOLD}${WHITE}$(t install_select_mode)${NC}" + echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" + echo -e " ${CYAN}1)${NC} ${GREEN}$(t install_lite_title)${NC}" + echo -e " ${DIM}$(t install_lite_desc1)${NC}" + echo -e " ${DIM}$(t install_lite_desc2)${NC}" + echo "" + echo -e " ${CYAN}2)${NC} ${MAGENTA}$(t install_pro_title)${NC}" + echo -e " ${DIM}$(t install_pro_desc1)${NC}" + echo -e " ${DIM}$(t install_pro_desc2)${NC}" + echo -e " ${DIM}$(t install_pro_desc3)${NC}" + echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" + echo -ne " ${WHITE}$(t install_mode_choice)${NC} " + read -r mode_choice + mode_choice="${mode_choice:-}" + + case "$mode_choice" in + 1) install_lite_mode ;; + 2) install_pro_mode ;; + *) log_error "$(tf install_bad_choice "${mode_choice:-}")" ;; + esac +} + +# ── Lite mode ─────────────────────────────────────────────────────────────── +install_lite_mode() { + log_step "$(t install_lite_step)" + + # Domain selection + local domain + domain=$(select_quick_domain) + [ $? -ne 0 ] && return + + # Port selection + local port + port=$(select_port) + [ $? -ne 0 ] && return + if [ "$port" = "443" ]; then + warn_3xui_443_conflict || true + fi + + # Generate secret + local secret + secret=$(generate_hex 32) + + # Confirm + local ip + ip=$(get_server_ip) + echo "" + echo -e " ${BOLD}${WHITE}$(t install_config_title)${NC}" + echo -e " $(t install_cfg_ip) ${CYAN}${ip}${NC}" + echo -e " $(t install_cfg_port) ${CYAN}${port}${NC}" + echo -e " $(t install_cfg_mask) ${CYAN}${domain}${NC}" + echo -e " $(t install_cfg_mode) ${GREEN}Lite${NC}" + echo "" + + if ! confirm "$(t install_confirm_proxy)"; then + return + fi + + # Install + ensure_deps + install_telemt_full || return + + # Generate telemt config + generate_telemt_toml "$secret" "$port" "lite" "$domain" "443" + + # Validate + validate_telemt_config || return + + # Start + start_telemt || return + + # Save goTelegram Pro config + save_gotelegram_config "telemt" "lite" "$port" "$secret" "$domain" "" "" + + # Credits + show_credits + + # Result + show_proxy_info + log_success "$(tf install_done "$GOTELEGRAM_VERSION" "Lite")" +} + +# ── Pro mode ──────────────────────────────────────────────────────────────── +install_pro_mode() { + log_step "$(t install_pro_step)" + + warn_3xui_443_conflict || true + + # Enter domain + echo "" + echo -ne " ${WHITE}$(t install_enter_domain)${NC} " + read -r user_domain + + if [ -z "$user_domain" ] || ! validate_domain "$user_domain"; then + log_error "$(tf install_bad_domain "${user_domain:-}")" + return + fi + + # Check DNS + local resolved_ip server_ip + resolved_ip=$(dig +short "$user_domain" A 2>/dev/null | head -1) + server_ip=$(get_server_ip) + + if [ -n "$resolved_ip" ] && [ "$resolved_ip" != "$server_ip" ]; then + log_warning "$(tf install_dns_mismatch "$user_domain" "$resolved_ip" "$server_ip")" + if ! confirm "$(t install_continue_anyway)"; then + return + fi + fi + + # Email for Let's Encrypt + echo -ne " ${WHITE}$(t install_enter_email)${NC} " + read -r ssl_email + + # Template selection + local template_dir + template_dir=$(interactive_template_selection) + [ $? -ne 0 ] && return + + # Pro architecture: + # telemt listens on 0.0.0.0:443 (accepts ALL connections) + # nginx listens on 127.0.0.1:8443 with SSL (serves website) + # MTProxy client → :443 → telemt (proxies) + # Regular browser → :443 → telemt → 127.0.0.1:8443 → nginx (website) + # ISP only sees HTTPS on 443 to domain + local nginx_internal_port=8443 + echo "" + echo -e " ${DIM}$(t install_arch_desc1)${NC}" + echo -e " ${DIM}$(tf install_arch_desc2 "$nginx_internal_port")${NC}" + echo -e " ${DIM}$(tf install_arch_desc3 "$user_domain")${NC}" + + # Generate fake-TLS secret (ee + secret + hex domain) + # ee prefix tells Telegram client to masquerade traffic as TLS to domain + local raw_secret + raw_secret=$(generate_hex 32) + local domain_hex + domain_hex=$(printf '%s' "$user_domain" | xxd -p | tr -d '\n') + local faketls_secret="ee${raw_secret}${domain_hex}" + + # Confirmation + echo "" + echo -e " ${BOLD}${WHITE}$(t install_config_title)${NC}" + echo -e " $(t install_cfg_domain) ${CYAN}${user_domain}${NC}" + echo -e " $(t install_cfg_port) ${CYAN}443 (telemt + nginx)${NC}" + echo -e " $(t install_cfg_mode) ${MAGENTA}Pro (fake-TLS)${NC}" + echo "" + + if ! confirm "$(t install_confirm_proxy_site)"; then + return + fi + + # Install + ensure_deps + install_telemt_full || return + + # telemt config: listen 443, masquerade to local nginx via dns_override + generate_telemt_toml "$raw_secret" "443" "pro" "$user_domain" "$nginx_internal_port" + + # Website setup (nginx on internal port + certbot + template) + setup_pro_mode "$user_domain" "$template_dir" "$nginx_internal_port" "$ssl_email" || return + + # Stop nginx on 443 before starting telemt (telemt will take 443) + # nginx already reconfigured to internal port + systemctl restart nginx 2>/dev/null + + # Start telemt + start_telemt || return + + # Save config + local tpl_id + tpl_id=$(basename "$template_dir") + save_gotelegram_config "telemt" "pro" "443" "$raw_secret" "$user_domain" "$user_domain" "$tpl_id" + + # Result — use domain and fake-TLS link + show_proxy_info_pro "$user_domain" "$faketls_secret" + echo -e " ${WHITE}$(t svc_site):${NC} ${GREEN}https://${user_domain}${NC}" + log_success "$(tf install_done "$GOTELEGRAM_VERSION" "Pro")" +} + +# ── Статус ─────────────────────────────────────────────────────────────────── +menu_status() { + show_proxy_info + + # Extras for pro + local mode + mode=$(config_get mode 2>/dev/null) + if [ "$mode" = "pro" ]; then + local domain + domain=$(config_get domain 2>/dev/null) + if [ -n "$domain" ]; then + local ssl_expiry + ssl_expiry=$(get_ssl_expiry "$domain") + local nginx_st + nginx_st=$(nginx_status) + echo -e " ${WHITE}$(t svc_nginx):${NC} ${nginx_st}" + echo -e " ${WHITE}$(t website_ssl_until)${NC} ${ssl_expiry}" + echo -e " ${WHITE}$(t svc_site):${NC} https://${domain}" + echo "" + fi + fi +} + +# ── Ссылка ─────────────────────────────────────────────────────────────────── +menu_link() { + local secret port ip link mode domain mask_host + secret=$(get_config_value secret) + port=$(get_config_value port) + ip=$(get_server_ip) + mode=$(config_get mode 2>/dev/null || echo "lite") + domain=$(config_get domain 2>/dev/null || echo "") + mask_host=$(config_get mask_host 2>/dev/null || echo "") + + if [ "$mode" = "pro" ] && [ -n "$domain" ]; then + link=$(generate_proxy_link "$domain" "$port" "$secret" "$domain") + else + link=$(generate_proxy_link "$ip" "$port" "$secret" "$mask_host") + fi + + echo "" + echo -e " ${BOLD}${WHITE}$(t link_title)${NC}" + echo "" + echo -e " ${GREEN}${link}${NC}" + echo "" + + if command -v qrencode &>/dev/null; then + qrencode -t UTF8 -m 2 "$link" 2>/dev/null + fi +} + +# ── Поделиться ─────────────────────────────────────────────────────────────── +menu_share() { + local secret port ip link mode domain mask_host server_display + secret=$(get_config_value secret) + port=$(get_config_value port) + ip=$(get_server_ip) + mode=$(config_get mode 2>/dev/null || echo "lite") + domain=$(config_get domain 2>/dev/null || echo "") + mask_host=$(config_get mask_host 2>/dev/null || echo "") + + if [ "$mode" = "pro" ] && [ -n "$domain" ]; then + link=$(generate_proxy_link "$domain" "$port" "$secret" "$domain") + server_display="$domain" + else + link=$(generate_proxy_link "$ip" "$port" "$secret" "$mask_host") + server_display="$ip" + fi + + echo "" + echo -e " ${BOLD}$(t share_title)${NC}" + echo "" + printf "$(t share_line1)\n" "$GOTELEGRAM_VERSION" + echo "" + printf "$(t share_server)\n" "$server_display" + printf "$(t share_port)\n" "$port" + echo "" + echo "$(t share_connect_cta)" + echo "$link" + echo "" + echo "$(t share_footer)" + echo "" +} + +# ── Перезапуск ─────────────────────────────────────────────────────────────── +menu_restart() { + restart_telemt + local mode + mode=$(config_get mode 2>/dev/null) + if [ "$mode" = "pro" ]; then + restart_nginx + fi +} + +# ── Logs ──────────────────────────────────────────────────────────────────── +menu_logs() { + echo "" + echo -e " ${BOLD}${WHITE}$(tf logs_telemt_title 40)${NC}" + echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" + telemt_logs 40 + echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" +} + +# ── Change mode / template ────────────────────────────────────────────────── +update_current_template_id() { + local template_dir="$1" + local tpl_id + tpl_id=$(basename "$template_dir") + [ -z "$tpl_id" ] && return 0 + + if [ -f "$template_dir/.custom_git_source" ]; then + local source_url + source_url=$(head -1 "$template_dir/.custom_git_source" 2>/dev/null || echo "") + bot_update_config_field "template_source" "$source_url" || true + fi + bot_update_config_field "template_id" "$tpl_id" || \ + log_warning "Не удалось обновить template_id в config.json" +} + +menu_change_mode() { + local current_mode + current_mode=$(config_get mode 2>/dev/null) + echo "" + echo -e " ${WHITE}$(t change_current_mode)${NC} ${CYAN}${current_mode}${NC}" + echo "" + echo -e " ${CYAN}1${NC}) $(t change_template)" + echo -e " ${CYAN}2${NC}) $(t change_mode_switch)" + echo -e " ${CYAN}0${NC}) $(t back)" + echo -ne " ${WHITE}$(t choose):${NC} " + read -r ch + + case "$ch" in + 1) + if [ "$current_mode" != "pro" ]; then + log_error "$(t change_only_pro)" + return + fi + local template_dir + template_dir=$(interactive_template_selection) + [ $? -ne 0 ] && return + switch_template "$template_dir" + update_current_template_id "$template_dir" + ;; + 2) + log_warning "$(t change_requires_reinstall)" + if confirm "$(t change_reinstall_confirm)"; then + menu_install + fi + ;; + esac +} + +# ── Website management ───────────────────────────────────────────────────── +menu_website() { + local mode + mode=$(config_get mode 2>/dev/null) + + if [ "$mode" != "pro" ]; then + log_info "$(t website_only_pro)" + return + fi + + local domain + domain=$(config_get domain 2>/dev/null) + + echo "" + echo -e " ${BOLD}${WHITE}$(t website_title)${NC}" + echo -e " $(t website_domain) ${CYAN}${domain}${NC}" + echo -e " $(t website_ssl_until) $(get_ssl_expiry "$domain")" + echo "" + echo -e " ${CYAN}1${NC}) $(t website_renew_ssl)" + echo -e " ${CYAN}2${NC}) $(t website_restart_nginx)" + echo -e " ${CYAN}3${NC}) $(t website_change_template)" + echo -e " ${CYAN}0${NC}) $(t back)" + echo -ne " ${WHITE}$(t choose):${NC} " + read -r ch + + case "$ch" in + 1) renew_ssl_certificate ;; + 2) restart_nginx ;; + 3) + local template_dir + template_dir=$(interactive_template_selection) + [ $? -ne 0 ] && return + switch_template "$template_dir" + update_current_template_id "$template_dir" + ;; + esac +} + +# ── Remove ───────────────────────────────────────────────────────────────── +menu_remove() { + echo "" + echo -e " ${BOLD}${RED}$(t remove_title)${NC}" + echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" + echo -e " ${CYAN}1${NC}) $(t remove_proxy_only)" + echo -e " ${CYAN}2${NC}) $(t remove_bot_only)" + echo -e " ${CYAN}3${NC}) $(t remove_all)" + echo -e " ${CYAN}0${NC}) $(t back)" + echo -ne " ${WHITE}$(t choose):${NC} " + read -r rm_choice + + case "$rm_choice" in + 1) + log_warning "$(t remove_warn_proxy)" + if ! confirm "$(t remove_confirm_proxy)"; then return; fi + if confirm "$(t remove_backup_before)"; then + interactive_backup + fi + remove_telemt + local mode + mode=$(config_get mode 2>/dev/null) + if [ "$mode" = "pro" ]; then + remove_pro_mode + fi + rm -f "$GOTELEGRAM_CONFIG" + log_success "$(t remove_proxy_done)" + ;; + 2) + bot_remove + ;; + 3) + log_warning "$(t remove_warn_all)" + if ! confirm "$(t remove_confirm_all)"; then return; fi + if confirm "$(t remove_backup_before)"; then + interactive_backup + fi + # Proxy + remove_telemt + local mode + mode=$(config_get mode 2>/dev/null) + if [ "$mode" = "pro" ]; then + remove_pro_mode + fi + rm -f "$GOTELEGRAM_CONFIG" + # Bot + if [ "$(bot_service_status)" != "not_installed" ]; then + systemctl stop "$BOT_SERVICE" 2>/dev/null + systemctl disable "$BOT_SERVICE" 2>/dev/null + rm -f "/etc/systemd/system/${BOT_SERVICE}.service" + systemctl daemon-reload + rm -rf "$BOT_DIR" + fi + remove_admin_web + log_success "$(t remove_all_done)" + ;; + esac +} + +# ── Telegram-бот ──────────────────────────────────────────────────────────── +BOT_DIR="/opt/gotelegram-bot" +BOT_SERVICE="gotelegram-bot" + +admin_web_service_status() { + if ! systemctl list-unit-files "$ADMIN_WEB_SERVICE.service" &>/dev/null 2>&1; then + echo "not_installed" + elif systemctl is-active "$ADMIN_WEB_SERVICE" &>/dev/null 2>&1; then + echo "running" + else + echo "stopped" + fi +} + +install_admin_web() { + local src_dir="$SCRIPT_DIR/admin-web" + [ -d "$src_dir" ] || { log_warning "admin-web files not found: $src_dir"; return 1; } + command -v python3 &>/dev/null || { log_warning "python3 not found; web admin skipped"; return 1; } + + mkdir -p "$ADMIN_WEB_DIR/static" + cp "$src_dir/server.py" "$ADMIN_WEB_DIR/server.py" + cp -a "$src_dir/static/." "$ADMIN_WEB_DIR/static/" + chmod 700 "$ADMIN_WEB_DIR" + chmod 755 "$ADMIN_WEB_DIR/server.py" "$ADMIN_WEB_DIR/static" + rm -f "$ADMIN_WEB_DIR/token" 2>/dev/null || true + + local python_bin + python_bin=$(command -v python3) + cat > "/etc/systemd/system/${ADMIN_WEB_SERVICE}.service" << SVCEOF +[Unit] +Description=goTelegram Pro v${GOTELEGRAM_VERSION} Local Web Admin +After=network.target + +[Service] +Type=simple +WorkingDirectory=$ADMIN_WEB_DIR +ExecStart=$python_bin $ADMIN_WEB_DIR/server.py +Restart=always +RestartSec=5 +Environment=GOTELEGRAM_ADMIN_HOST=$ADMIN_WEB_HOST +Environment=GOTELEGRAM_ADMIN_PORT=$ADMIN_WEB_PORT + +[Install] +WantedBy=multi-user.target +SVCEOF + + systemctl daemon-reload + systemctl enable "$ADMIN_WEB_SERVICE" &>/dev/null + systemctl restart "$ADMIN_WEB_SERVICE" 2>/dev/null || systemctl start "$ADMIN_WEB_SERVICE" + if type install_stats_collector &>/dev/null; then + install_stats_collector >/dev/null 2>&1 || log_warning "stats collector was not started; open Web Admin traffic page and use Repair" + fi + log_success "Web admin installed: ${ADMIN_WEB_HOST}:${ADMIN_WEB_PORT}" +} + +auto_install_admin_web_if_possible() { + [ -d "$SCRIPT_DIR/admin-web" ] || return 0 + command -v python3 &>/dev/null || return 0 + if [ "$(admin_web_service_status)" != "not_installed" ] && \ + [ -f "$ADMIN_WEB_DIR/server.py" ] && \ + cmp -s "$SCRIPT_DIR/admin-web/server.py" "$ADMIN_WEB_DIR/server.py" && \ + cmp -s "$SCRIPT_DIR/admin-web/static/index.html" "$ADMIN_WEB_DIR/static/index.html" && \ + cmp -s "$SCRIPT_DIR/admin-web/static/app.js" "$ADMIN_WEB_DIR/static/app.js" && \ + cmp -s "$SCRIPT_DIR/admin-web/static/styles.css" "$ADMIN_WEB_DIR/static/styles.css"; then + return 0 + fi + install_admin_web >/dev/null 2>&1 || true +} + +remove_admin_web() { + systemctl stop "$ADMIN_WEB_SERVICE" 2>/dev/null + systemctl disable "$ADMIN_WEB_SERVICE" 2>/dev/null + rm -f "/etc/systemd/system/${ADMIN_WEB_SERVICE}.service" + systemctl daemon-reload 2>/dev/null + rm -rf "$ADMIN_WEB_DIR" +} + +bot_service_status() { + if ! systemctl list-unit-files "$BOT_SERVICE.service" &>/dev/null 2>&1; then + echo "not_installed" + elif systemctl is-active "$BOT_SERVICE" &>/dev/null 2>&1; then + echo "running" + else + echo "stopped" + fi +} + +auto_update_bot_if_possible() { + [ -d "$SCRIPT_DIR/gotelegram-bot" ] || return 0 + [ "$(bot_service_status)" = "not_installed" ] && return 0 + [ -f "$BOT_DIR/.env" ] || return 0 + + local needs_update=0 + [ -f "$SCRIPT_DIR/gotelegram-bot/bot.py" ] && \ + ! cmp -s "$SCRIPT_DIR/gotelegram-bot/bot.py" "$BOT_DIR/bot.py" && needs_update=1 + [ -f "$SCRIPT_DIR/gotelegram-bot/i18n.py" ] && \ + ! cmp -s "$SCRIPT_DIR/gotelegram-bot/i18n.py" "$BOT_DIR/i18n.py" && needs_update=1 + [ -f "$SCRIPT_DIR/gotelegram-bot/requirements.txt" ] && \ + ! cmp -s "$SCRIPT_DIR/gotelegram-bot/requirements.txt" "$BOT_DIR/requirements.txt" && needs_update=1 + + local lang_file lang_name + for lang_file in "$SCRIPT_DIR"/gotelegram-bot/lang/*.json; do + [ -e "$lang_file" ] || continue + lang_name=$(basename "$lang_file") + ! cmp -s "$lang_file" "$BOT_DIR/lang/$lang_name" && needs_update=1 + done + + [ "$needs_update" = "1" ] || return 0 + bot_install >/dev/null 2>&1 || \ + log_warning "Telegram bot auto-update failed; run menu 12 → Telegram-bot → Install/update" +} + +menu_bot() { + local st + st=$(bot_service_status) + + echo "" + echo -e " ${BOLD}${WHITE}$(t bot_title)${NC}" + echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" + + case "$st" in + running) + echo -e " $(t bot_status_colon) ${GREEN}$(t bot_status_running)${NC}" + echo "" + echo -e " ${CYAN}1${NC}) $(t bot_menu_status)" + echo -e " ${CYAN}2${NC}) $(t bot_menu_logs)" + echo -e " ${CYAN}3${NC}) $(t bot_menu_restart)" + echo -e " ${CYAN}4${NC}) $(t bot_menu_stop)" + echo -e " ${CYAN}5${NC}) $(t bot_menu_settings)" + echo -e " ${CYAN}6${NC}) $(t bot_menu_remove)" + ;; + stopped) + echo -e " $(t bot_status_colon) ${YELLOW}$(t bot_status_stopped)${NC}" + echo "" + echo -e " ${CYAN}1${NC}) $(t bot_menu_status)" + echo -e " ${CYAN}2${NC}) $(t bot_menu_logs)" + echo -e " ${CYAN}3${NC}) $(t bot_menu_start)" + echo -e " ${CYAN}5${NC}) $(t bot_menu_settings)" + echo -e " ${CYAN}6${NC}) $(t bot_menu_remove)" + ;; + *) + echo -e " $(t bot_status_colon) ${RED}$(t bot_status_not_installed)${NC}" + echo "" + echo -e " ${DIM}$(t bot_intro1)${NC}" + echo -e " ${DIM}$(t bot_intro2)${NC}" + echo "" + echo -e " ${CYAN}1${NC}) $(t bot_menu_install)" + ;; + esac + + echo -e " ${CYAN}0${NC}) $(t back)" + echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" + echo -ne " ${WHITE}$(t choose):${NC} " + read -r ch + + case "$st" in + running) + case "$ch" in + 1) bot_show_status ;; + 2) bot_show_logs ;; + 3) systemctl restart "$BOT_SERVICE" && log_success "$(t bot_restarted)" ;; + 4) systemctl stop "$BOT_SERVICE" && log_info "$(t bot_stopped)" ;; + 5) bot_edit_config ;; + 6) bot_remove ;; + esac + ;; + stopped) + case "$ch" in + 1) bot_show_status ;; + 2) bot_show_logs ;; + 3) systemctl start "$BOT_SERVICE" && log_success "$(t bot_started)" ;; + 5) bot_edit_config ;; + 6) bot_remove ;; + esac + ;; + *) + case "$ch" in + 1) bot_install ;; + esac + ;; + esac +} + +bot_install() { + log_step "$(t bot_install_step)" + + # Python + venv + pip (always ensure — python3 can be present without venv/pip) + local need_py=0 + command -v python3 &>/dev/null || need_py=1 + # python3-venv not having its own command; probe by trying 'python3 -m venv --help' + if ! python3 -m venv --help &>/dev/null; then need_py=1; fi + # pip check + if ! python3 -m pip --version &>/dev/null; then need_py=1; fi + + if [ "$need_py" = "1" ]; then + log_info "$(t bot_install_python)" + if command -v apt-get &>/dev/null; then + # Detect Python version for versioned venv package (Debian 12 / Ubuntu 24.04 need python3.12-venv) + local py_ver="" + if command -v python3 &>/dev/null; then + py_ver=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null) + fi + + apt_update + + # Build package list with versioned venv fallback + local pkg_list=(python3 python3-venv python3-pip) + [ -n "$py_ver" ] && pkg_list+=("python${py_ver}-venv") + # python3-full optional + if ! apt_install "${pkg_list[@]}" python3-full; then + log_warning "python3-full unavailable, installing core packages only..." + apt_install "${pkg_list[@]}" || { + log_error "Failed to install Python packages. Run manually: apt install ${pkg_list[*]}" + return 1 + } + fi + elif command -v dnf &>/dev/null; then + dnf install -y -q python3 python3-pip + elif command -v yum &>/dev/null; then + yum install -y -q python3 python3-pip + fi + fi + + # Copy bot files + mkdir -p "$BOT_DIR" + if [ -f "$SCRIPT_DIR/gotelegram-bot/bot.py" ]; then + cp "$SCRIPT_DIR/gotelegram-bot/bot.py" "$BOT_DIR/" + cp "$SCRIPT_DIR/gotelegram-bot/requirements.txt" "$BOT_DIR/" + [ -f "$SCRIPT_DIR/gotelegram-bot/config.example.env" ] && \ + cp "$SCRIPT_DIR/gotelegram-bot/config.example.env" "$BOT_DIR/" + # Copy i18n language files for bot + if [ -d "$SCRIPT_DIR/gotelegram-bot/lang" ]; then + mkdir -p "$BOT_DIR/lang" + cp -f "$SCRIPT_DIR/gotelegram-bot/lang/"*.json "$BOT_DIR/lang/" 2>/dev/null + fi + [ -f "$SCRIPT_DIR/gotelegram-bot/i18n.py" ] && \ + cp "$SCRIPT_DIR/gotelegram-bot/i18n.py" "$BOT_DIR/" + else + log_error "$(tf bot_files_not_found "$SCRIPT_DIR/gotelegram-bot/")" + return 1 + fi + + # Templates catalog — skip if source and dest are the same file (symlink install case) + if [ -f "$SCRIPT_DIR/templates_catalog.json" ]; then + local src_tc="$SCRIPT_DIR/templates_catalog.json" + local dst_tc="$GOTELEGRAM_DIR/templates_catalog.json" + if [ "$(readlink -f "$src_tc" 2>/dev/null)" != "$(readlink -f "$dst_tc" 2>/dev/null)" ]; then + cp "$src_tc" "$dst_tc" + fi + fi + + # Venv — create, and verify pip exists (python3-venv can silently create broken venv) + if [ ! -d "$BOT_DIR/venv" ] || [ ! -x "$BOT_DIR/venv/bin/pip" ]; then + log_info "$(t bot_create_venv)" + rm -rf "$BOT_DIR/venv" + if ! python3 -m venv "$BOT_DIR/venv" 2>/tmp/venv_err; then + log_error "venv creation failed:" + cat /tmp/venv_err >&2 2>/dev/null + # Try to fix by installing versioned python3.X-venv package + if command -v apt-get &>/dev/null; then + local py_ver + py_ver=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null) + log_info "reinstalling python${py_ver}-venv..." + apt_install python3-venv python3-pip "python${py_ver}-venv" python3-full || \ + apt_install python3-venv python3-pip "python${py_ver}-venv" || true + rm -rf "$BOT_DIR/venv" + python3 -m venv "$BOT_DIR/venv" || { log_error "venv still broken, aborting. Manual fix: apt install python${py_ver}-venv python3-pip"; return 1; } + else + return 1 + fi + fi + fi + + if [ ! -x "$BOT_DIR/venv/bin/pip" ]; then + log_info "bootstrapping pip via ensurepip..." + "$BOT_DIR/venv/bin/python" -m ensurepip --upgrade 2>/dev/null || true + fi + + if [ ! -x "$BOT_DIR/venv/bin/pip" ]; then + log_error "pip missing in venv — install python3-venv manually: apt install python3-venv python3-pip" + return 1 + fi + + log_info "$(t bot_install_deps)" + if ! "$BOT_DIR/venv/bin/pip" install -r "$BOT_DIR/requirements.txt" -q 2>/tmp/pip_err; then + log_error "pip install failed:" + tail -n 5 /tmp/pip_err >&2 + return 1 + fi + + # Sanity check: verify critical imports succeed + if ! "$BOT_DIR/venv/bin/python" -c "import telegram, toml, dotenv" 2>/tmp/imp_err; then + log_error "dependency import check failed:" + cat /tmp/imp_err >&2 + return 1 + fi + + # Configuration + if [ ! -f "$BOT_DIR/.env" ]; then + echo "" + echo -e " ${YELLOW}$(t bot_enter_token)${NC}" + local token="" + while [ -z "$token" ]; do + echo -ne " ${WHITE}$(t bot_token)${NC} " + read -r token + token=$(echo "$token" | tr -d '[:space:]') + [ -z "$token" ] && log_error "$(t bot_token_empty)" + done + + echo "" + echo -e " ${WHITE}$(t bot_add_admin_how)${NC}" + echo -e " ${CYAN}1${NC}) $(t bot_admin_auto)" + echo -e " ${CYAN}2${NC}) $(t bot_admin_manual)" + echo -ne " ${WHITE}$(t choose) [1]:${NC} " + read -r admin_mode + admin_mode="${admin_mode:-1}" + + local admin_ids="" + if [ "$admin_mode" = "2" ]; then + echo -ne " ${WHITE}$(t bot_admin_ids_prompt)${NC} " + read -r admin_ids + admin_ids=$(echo "$admin_ids" | tr ' ' ',' | sed 's/,,*/,/g; s/^,//; s/,$//') + fi + + # Propagate selected language to bot so UI matches + local bot_lang + bot_lang=$(get_language 2>/dev/null || echo en) + { + echo "BOT_TOKEN=$token" + [ -n "$admin_ids" ] && echo "ALLOWED_IDS=$admin_ids" + echo "BOT_LANG=$bot_lang" + } > "$BOT_DIR/.env" + chmod 600 "$BOT_DIR/.env" + log_success "$(t bot_env_created)" + else + log_info "$(t bot_env_exists)" + fi + + # Systemd + cat > "/etc/systemd/system/${BOT_SERVICE}.service" << SVCEOF +[Unit] +Description=goTelegram Pro v${GOTELEGRAM_VERSION} Telegram Bot +After=network.target + +[Service] +Type=simple +WorkingDirectory=$BOT_DIR +ExecStart=$BOT_DIR/venv/bin/python $BOT_DIR/bot.py +Restart=always +RestartSec=5 +Environment=PATH=$BOT_DIR/venv/bin:/usr/bin + +[Install] +WantedBy=multi-user.target +SVCEOF + + systemctl daemon-reload + systemctl enable "$BOT_SERVICE" &>/dev/null + systemctl restart "$BOT_SERVICE" 2>/dev/null || systemctl start "$BOT_SERVICE" + + install_admin_web || log_warning "Web admin could not be installed" + + # If auto mode — wait until bot captures first admin + local has_ids + has_ids=$(grep "^ALLOWED_IDS=" "$BOT_DIR/.env" 2>/dev/null | cut -d= -f2) + if [ -z "$has_ids" ]; then + echo "" + # Simple bullet-style block (no box — printf %-Ns breaks on UTF-8 multibyte chars) + echo -e " ${YELLOW}▸${NC} ${BOLD}$(t bot_wait_admin_title)${NC}" + echo "" + echo -e " $(t bot_wait_admin_msg1) ${CYAN}/start${NC}" + echo -e " $(t bot_wait_admin_msg2)" + echo "" + echo -e " ${DIM}$(t bot_wait_admin_skip)${NC}" + echo "" + + local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏') + local i=0 + local waited=0 + local max_wait=300 # 5 min max + + # Catch Ctrl+C to skip waiting without killing the script + local interrupted=0 + trap 'interrupted=1' INT + + while [ $waited -lt $max_wait ] && [ $interrupted -eq 0 ]; do + printf "\r ${CYAN}${frames[$i]}${NC} $(tf bot_wait_spinner "$waited") " >&2 + i=$(( (i+1) % ${#frames[@]} )) + sleep 1 + waited=$((waited + 1)) + + # Check if ALLOWED_IDS has appeared + has_ids=$(grep "^ALLOWED_IDS=" "$BOT_DIR/.env" 2>/dev/null | cut -d= -f2) + if [ -n "$has_ids" ]; then + break + fi + done + + trap - INT + printf "\r\033[K" >&2 # clear spinner line + + if [ -n "$has_ids" ]; then + echo "" + log_success "$(t bot_admin_assigned)" + echo -e " ${WHITE}ID:${NC} ${GREEN}${has_ids}${NC}" + elif [ $interrupted -eq 1 ]; then + echo "" + log_warning "$(t bot_wait_skipped)" + else + echo "" + log_warning "$(t bot_wait_timeout)" + fi + fi + + echo "" + log_success "$(t bot_installed)" + echo -e " ${DIM}systemctl status $BOT_SERVICE${NC}" + echo -e " ${DIM}journalctl -u $BOT_SERVICE -f${NC}" +} + +bot_show_status() { + echo "" + echo -e " ${BOLD}${WHITE}$(t bot_status_title)${NC}" + echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" + systemctl status "$BOT_SERVICE" --no-pager -l 2>/dev/null | head -15 | while IFS= read -r line; do + echo " $line" + done + echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" + + if [ -f "$BOT_DIR/.env" ]; then + local has_token has_ids + has_token=$(grep -c "BOT_TOKEN=" "$BOT_DIR/.env" 2>/dev/null || echo 0) + has_ids=$(grep "ALLOWED_IDS=" "$BOT_DIR/.env" 2>/dev/null | cut -d= -f2) + if [ "${has_token:-0}" -gt 0 ]; then + echo -e " $(t bot_token) ${GREEN}✓ $(t bot_token_configured)${NC}" + fi + if [ -n "$has_ids" ]; then + echo -e " $(t bot_access_colon) $(tf bot_access_ids_fmt "$has_ids")" + else + echo -e " $(t bot_access_colon) ${YELLOW}$(t bot_access_open)${NC}" + fi + fi +} + +bot_show_logs() { + echo "" + echo -e " ${BOLD}${WHITE}$(t bot_logs_title)${NC}" + echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" + journalctl -u "$BOT_SERVICE" --no-pager -n 30 2>/dev/null | while IFS= read -r line; do + echo " $line" + done + echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" +} + +bot_edit_config() { + echo "" + echo -e " ${BOLD}${WHITE}$(t bot_settings_title)${NC}" + echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" + + if [ -f "$BOT_DIR/.env" ]; then + echo -e " ${DIM}$(t bot_current_env)${NC}" + while IFS= read -r line; do + # Mask token for security + if [[ "$line" == BOT_TOKEN=* ]]; then + local tok="${line#BOT_TOKEN=}" + echo -e " BOT_TOKEN=${tok:0:10}...${tok: -5}" + else + echo " $line" + fi + done < "$BOT_DIR/.env" + fi + + echo "" + echo -e " ${CYAN}1${NC}) $(t bot_change_token)" + echo -e " ${CYAN}2${NC}) $(t bot_change_allowed)" + echo -e " ${CYAN}0${NC}) $(t back)" + echo -ne " ${WHITE}$(t choose):${NC} " + read -r ch + + case "$ch" in + 1) + echo -ne " ${WHITE}$(t bot_new_token)${NC} " + read -r new_token + new_token=$(echo "$new_token" | tr -d '[:space:]') + if [ -n "$new_token" ]; then + sed -i "s|^BOT_TOKEN=.*|BOT_TOKEN=$new_token|" "$BOT_DIR/.env" + systemctl restart "$BOT_SERVICE" + log_success "$(t bot_token_updated)" + else + log_error "$(t bot_token_empty_err)" + fi + ;; + 2) + echo -ne " ${WHITE}$(t bot_allowed_prompt)${NC} " + read -r new_ids + # Normalize: spaces and commas → commas, strip extras + new_ids=$(echo "$new_ids" | tr ' ' ',' | sed 's/,,*/,/g; s/^,//; s/,$//') + if grep -q "^ALLOWED_IDS=" "$BOT_DIR/.env" 2>/dev/null; then + if [ -n "$new_ids" ]; then + sed -i "s|^ALLOWED_IDS=.*|ALLOWED_IDS=$new_ids|" "$BOT_DIR/.env" + else + sed -i '/^ALLOWED_IDS=/d' "$BOT_DIR/.env" + fi + else + [ -n "$new_ids" ] && echo "ALLOWED_IDS=$new_ids" >> "$BOT_DIR/.env" + fi + systemctl restart "$BOT_SERVICE" + log_success "$(t bot_access_updated)" + ;; + esac +} + +bot_remove() { + echo "" + log_warning "$(t bot_remove_warn)" + if ! confirm "$(t bot_remove_confirm)"; then + return + fi + + systemctl stop "$BOT_SERVICE" 2>/dev/null + systemctl disable "$BOT_SERVICE" 2>/dev/null + rm -f "/etc/systemd/system/${BOT_SERVICE}.service" + systemctl daemon-reload + rm -rf "$BOT_DIR" + log_success "$(t bot_removed)" +} + +# ── Promo ──────────────────────────────────────────────────────────────────── +_promo_block() { + # Print a promo section without width-fragile box borders (i18n safe) + local line2; line2=$(printf '─%.0s' {1..54}) + local youtube_link="${GOTELEGRAM_YOUTUBE_LINK:-}" + echo "" + echo -e " ${DIM}${line2}${NC}" + echo -e " ${BOLD}${YELLOW}$(t promo_host1_title)${NC}" + echo -e " $(t promo_link_label) ${CYAN}https://vk.cc/ct29NQ${NC}" + echo -e " ${WHITE}OFF60${NC} — $(tf promo_off60)" + echo -e " ${WHITE}BONUS20${NC} — $(tf promo_ant20)" + echo -e " ${WHITE}BONUS6${NC} — $(tf promo_ant6)" + echo -e " ${DIM}${line2}${NC}" + echo -e " ${BOLD}${YELLOW}$(t promo_host2_title)${NC}" + echo -e " $(t promo_link_label) ${CYAN}https://vk.cc/cUxAhj${NC}" + echo -e " ${WHITE}OFF60${NC} — $(tf promo_off60)" + echo -e " ${DIM}${line2}${NC}" + echo -e " ${BOLD}${YELLOW}$(t promo_tips_title)${NC}" + echo -e " ${CYAN}https://pay.cloudtips.ru/p/7410814f${NC}" + if [ -n "$youtube_link" ]; then + echo -e " ${DIM}${line2}${NC}" + echo -e " ${BOLD}${YELLOW}$(t promo_youtube_title)${NC}" + echo -e " $(t promo_link_label) ${CYAN}${youtube_link}${NC}" + fi + echo -e " ${DIM}${line2}${NC}" + echo "" +} + +menu_promo() { + _promo_block +} + +# ── Проверка: показывать ли промо (раз в сутки) ──────────────────────────── +should_show_promo() { + local stamp_file="$GOTELEGRAM_DIR/.promo_last_shown" + if [ ! -f "$stamp_file" ]; then + return 0 # никогда не показывали + fi + local last_shown now diff + last_shown=$(cat "$stamp_file" 2>/dev/null || echo "0") + last_shown="${last_shown//[^0-9]/}" + last_shown="${last_shown:-0}" + now=$(date +%s) + diff=$(( now - last_shown )) + # 86400 = 24 часа + [ "$diff" -ge 86400 ] +} + +mark_promo_shown() { + mkdir -p "$GOTELEGRAM_DIR" + date +%s > "$GOTELEGRAM_DIR/.promo_last_shown" +} + +_promo_qr() { + local label="$1" url="$2" + [ -n "$url" ] || return 0 + echo -e " ${DIM}${label}${NC}" + qrencode -t UTF8 -m 1 "$url" 2>/dev/null | while IFS= read -r qr_line; do + echo " $qr_line" + done +} + +# ── Promo with QR + delay (on install + once per day) ─────────────────── +show_promo_with_qr() { + _promo_block + + if command -v qrencode &>/dev/null; then + _promo_qr "$(t promo_qr_host1)" "https://vk.cc/ct29NQ" + _promo_qr "$(t promo_qr_host2)" "https://vk.cc/cUxAhj" + _promo_qr "$(t promo_qr_tips)" "https://pay.cloudtips.ru/p/7410814f" + _promo_qr "$(t promo_qr_youtube)" "${GOTELEGRAM_YOUTUBE_LINK:-}" + fi + + mark_promo_shown + + # 5-second countdown + for i in 5 4 3 2 1; do + echo -ne "\r ${DIM}$(tf promo_menu_in "$i")${NC} " + sleep 1 + done + echo -ne "\r \r" +} + +# ── First-run: pick language ───────────────────────────────────────────────── +first_run_language_picker() { + # Show picker only if language not yet saved + local marker="${GOTELEGRAM_DIR:-/opt/gotelegram}/.language" + local cfg_lang="" + if [ -f "$GOTELEGRAM_CONFIG" ] && command -v jq >/dev/null 2>&1; then + cfg_lang=$(jq -r '.language // empty' "$GOTELEGRAM_CONFIG" 2>/dev/null) + fi + if [ -f "$marker" ] || [ -n "$cfg_lang" ]; then + return 0 + fi + + local chosen + chosen=$(pick_language_interactive) + save_language "$chosen" + load_language "$chosen" +} + +# ── Change language on demand ──────────────────────────────────────────────── +menu_language() { + echo "" + echo -e " ${BOLD}${WHITE}$(t lang_change_prompt)${NC}" + echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" + echo -e " ${CYAN}1${NC}) English" + echo -e " ${CYAN}2${NC}) Русский" + echo -e " ${CYAN}0${NC}) $(t back)" + echo -ne " ${WHITE}$(t choose):${NC} " + read -r ch + case "$ch" in + 1) save_language "en"; load_language "en"; log_success "$(tf lang_saved English)" ;; + 2) save_language "ru"; load_language "ru"; log_success "$(tf lang_saved Русский)" ;; + esac +} + +# ══════════════════════════════════════════════════════════════════════════════ +# Non-interactive action dispatcher (bot / CI / scripting interface) +# ══════════════════════════════════════════════════════════════════════════════ +# Usage examples: +# gotelegram --action=change-template --template=th_ariclaw --json +# gotelegram --action=change-lite-domain --domain=google.com --json +# +# Rules for action handlers: +# - Only JSON may be written to stdout (the caller parses it). +# - All human-oriented logging must go to stderr (log_* already do that). +# - Exit code 0 on success, non-zero on failure (caller still parses JSON). +# ══════════════════════════════════════════════════════════════════════════════ + +bot_emit_json() { + # bot_emit_json [key=value ...] + local status="$1"; shift + local message="$1"; shift + local extra="" kv k v + for kv in "$@"; do + k="${kv%%=*}" + v="${kv#*=}" + # escape backslashes and double quotes in value + v="${v//\\/\\\\}" + v="${v//\"/\\\"}" + extra="${extra},\"${k}\":\"${v}\"" + done + # escape message + local msg_esc="${message//\\/\\\\}" + msg_esc="${msg_esc//\"/\\\"}" + printf '{"status":"%s","message":"%s"%s}\n' "$status" "$msg_esc" "$extra" +} + +# Update a single key in config.json without rewriting the whole file. +# Uses `date -Iseconds` rather than jq's `now | todate` — the latter requires +# jq 1.6+ which is not available on Debian 10 or older CentOS. +bot_update_config_field() { + local key="$1" + local value="$2" + if [ ! -f "$GOTELEGRAM_CONFIG" ]; then + return 1 + fi + local tmp now + tmp=$(mktemp) || return 1 + now=$(date -Iseconds 2>/dev/null || date +%Y-%m-%dT%H:%M:%S%z) + if jq --arg k "$key" --arg v "$value" --arg t "$now" \ + '.[$k] = $v | .updated_at = $t' \ + "$GOTELEGRAM_CONFIG" > "$tmp" 2>/dev/null; then + mv "$tmp" "$GOTELEGRAM_CONFIG" + chmod 600 "$GOTELEGRAM_CONFIG" + return 0 + fi + rm -f "$tmp" + return 1 +} + +# ── Action: change-template (pro mode only) ────────────────────────────────── +bot_action_change_template() { + local tpl_id="$1" + local json_out="${2:-0}" + + if [ -z "$tpl_id" ]; then + [ "$json_out" = "1" ] && bot_emit_json "error" "template id is required" "code=missing_arg" + log_error "change-template: --template is required" + return 2 + fi + + # Must be in pro mode + local mode + mode=$(config_get mode 2>/dev/null || echo "") + if [ "$mode" != "pro" ]; then + [ "$json_out" = "1" ] && bot_emit_json "error" "change-template requires pro mode (current: ${mode:-none})" "code=wrong_mode" + log_error "change-template: current mode is '${mode:-none}', requires 'pro'" + return 3 + fi + + # Validate template id exists in catalog + if ! get_template_info "$tpl_id" >/dev/null 2>&1; then + [ "$json_out" = "1" ] && bot_emit_json "error" "unknown template: $tpl_id" "code=unknown_template" + log_error "change-template: template not found in catalog: $tpl_id" + return 4 + fi + + # Make sure git (and other deps) are present. download_template uses git + # clone under the hood — on a minimal host (bootstrap-only install) git may + # not be installed yet, and the clone would fail silently. + ensure_deps >&2 + + log_info "change-template: downloading $tpl_id..." + local template_dir + template_dir=$(download_template "$tpl_id") + if [ $? -ne 0 ] || [ -z "$template_dir" ] || [ ! -d "$template_dir" ] || [ ! -f "$template_dir/index.html" ]; then + [ "$json_out" = "1" ] && bot_emit_json "error" "download failed for $tpl_id" "code=download_failed" + log_error "change-template: download_template failed for $tpl_id" + return 5 + fi + + log_info "change-template: deploying to nginx..." + if ! deploy_template_to_nginx "$template_dir" >&2; then + [ "$json_out" = "1" ] && bot_emit_json "error" "deploy failed" "code=deploy_failed" + return 6 + fi + + # Reload nginx (no full restart needed for static files — but be safe) + systemctl reload nginx 2>/dev/null || systemctl restart nginx 2>/dev/null + + # Update config.json template_id field + bot_update_config_field "template_id" "$tpl_id" || \ + log_warning "change-template: could not update config.json template_id" + + local domain + domain=$(config_get domain 2>/dev/null || echo "") + log_success "change-template: $tpl_id deployed" + + if [ "$json_out" = "1" ]; then + bot_emit_json "success" "template changed to $tpl_id" \ + "template=$tpl_id" "domain=$domain" "mode=pro" + fi + return 0 +} + +# ── Action: change-lite-domain ─────────────────────────────────────────────── +# Regenerates telemt TOML with a new fake-TLS mask domain. Lite mode only. +bot_action_change_lite_domain() { + local new_domain="$1" + local json_out="${2:-0}" + + if [ -z "$new_domain" ]; then + [ "$json_out" = "1" ] && bot_emit_json "error" "domain is required" "code=missing_arg" + log_error "change-lite-domain: --domain is required" + return 2 + fi + + if ! validate_domain "$new_domain" 2>/dev/null; then + [ "$json_out" = "1" ] && bot_emit_json "error" "invalid domain: $new_domain" "code=invalid_domain" + log_error "change-lite-domain: invalid domain: $new_domain" + return 3 + fi + + local mode + mode=$(config_get mode 2>/dev/null || echo "") + if [ "$mode" != "lite" ]; then + [ "$json_out" = "1" ] && bot_emit_json "error" "change-lite-domain requires lite mode (current: ${mode:-none})" "code=wrong_mode" + log_error "change-lite-domain: current mode is '${mode:-none}', requires 'lite'" + return 4 + fi + + local secret port + secret=$(get_config_value secret 2>/dev/null || echo "") + port=$(get_config_value port 2>/dev/null || echo "443") + + if [ -z "$secret" ]; then + [ "$json_out" = "1" ] && bot_emit_json "error" "no secret in config" "code=no_secret" + log_error "change-lite-domain: no secret in config.json" + return 5 + fi + + log_info "change-lite-domain: regenerating telemt TOML..." + generate_telemt_toml "$secret" "$port" "lite" "$new_domain" "443" >&2 || { + [ "$json_out" = "1" ] && bot_emit_json "error" "config generation failed" "code=gen_failed" + return 6 + } + + validate_telemt_config >&2 || { + [ "$json_out" = "1" ] && bot_emit_json "error" "config validation failed" "code=validate_failed" + return 7 + } + + restart_telemt >&2 || { + [ "$json_out" = "1" ] && bot_emit_json "error" "telemt restart failed" "code=restart_failed" + return 8 + } + + # Update both domain and mask_host fields in config.json + bot_update_config_field "mask_host" "$new_domain" || \ + log_warning "change-lite-domain: could not update mask_host" + bot_update_config_field "domain" "$new_domain" || \ + log_warning "change-lite-domain: could not update domain" + + log_success "change-lite-domain: switched to $new_domain" + + if [ "$json_out" = "1" ]; then + bot_emit_json "success" "lite mask domain changed to $new_domain" \ + "domain=$new_domain" "mode=lite" "port=$port" + fi + return 0 +} + +# Main dispatcher — called from main() when --action=X is present. +# Uses a file lock (flock) so concurrent CLI invocations (from multiple bot +# users, or from bot + manual CLI) serialize cleanly. Without this, two +# parallel `change-lite-domain` calls raced on the jq-rewrite of config.json +# and one process would see a truncated file ("no secret in config"). +bot_action_dispatch() { + local lock_file="/var/lock/gotelegram-bot-action.lock" + # Make sure /var/lock exists (it does on Debian/Ubuntu; be defensive for minimal images) + [ -d /var/lock ] || mkdir -p /var/lock 2>/dev/null || true + + if command -v flock >/dev/null 2>&1; then + # Wait up to 30 seconds for the lock — bot actions are fast (<5s + # typical), so 30s is plenty for legitimate serialization but short + # enough to surface a stuck process. + ( + flock -w 30 9 || { + # If we time out, emit JSON error for the bot parent. + local json_out=0 a + for a in "$@"; do + [ "$a" = "--json" ] && json_out=1 + done + if [ "$json_out" = "1" ]; then + bot_emit_json "error" "another action in progress (lock timeout)" "code=lock_timeout" + fi + exit 75 # EX_TEMPFAIL + } + _bot_action_dispatch_locked "$@" + ) 9>"$lock_file" + return $? + else + # No flock installed — run unlocked with a warning. ensure_deps/check_deps + # normally ensures util-linux is present, so this branch is defensive. + log_warning "flock not available — bot actions not serialized" + _bot_action_dispatch_locked "$@" + return $? + fi +} + +_bot_action_dispatch_locked() { + local action="" tpl_id="" domain="" json_out=0 arg + for arg in "$@"; do + case "$arg" in + --action=*) action="${arg#--action=}" ;; + --template=*) tpl_id="${arg#--template=}" ;; + --domain=*) domain="${arg#--domain=}" ;; + --json) json_out=1 ;; + esac + done + + case "$action" in + change-template) + bot_action_change_template "$tpl_id" "$json_out" + return $? + ;; + change-lite-domain) + bot_action_change_lite_domain "$domain" "$json_out" + return $? + ;; + "") + log_error "no --action specified" + return 64 + ;; + *) + [ "$json_out" = "1" ] && bot_emit_json "error" "unknown action: $action" "code=unknown_action" + log_error "unknown action: $action" + return 64 + ;; + esac +} + +# ── Точка входа / Entry point ─────────────────────────────────────────────── +main() { + # Non-interactive action mode: if --action=X is in args, dispatch and exit. + # Must run BEFORE interactive banner/menus so the bot gets clean JSON. + local a has_action=0 + for a in "$@"; do + case "$a" in --action=*) has_action=1; break ;; esac + done + if [ "$has_action" = "1" ]; then + check_root + init_dirs + # Для bot-экшенов тоже нужны зависимости (git для change-template), но + # без шумного apt-get update если всё уже на месте. + if ! check_deps_present; then + ensure_deps >&2 || exit 1 + fi + auto_migrate_legacy_state >&2 || true + bot_action_dispatch "$@" + exit $? + fi + + check_root + init_dirs + + # Первый запуск: если критические зависимости отсутствуют — ставим их ДО + # того как пользователь дойдёт до меню. На последующих запусках это просто + # дёшево проверяет command -v по всем командам и ничего не делает. + if ! check_deps_present; then + log_step "Первый запуск: проверяю зависимости..." + ensure_deps || { + log_error "Не удалось установить зависимости. См. сообщения выше." + exit 1 + } + fi + + auto_migrate_legacy_state || true + auto_update_bot_if_possible || true + auto_install_admin_web_if_possible || true + + # First-run language picker (before banner so banner appears in chosen lang) + first_run_language_picker + + show_banner + + # Pre-flight + check_os + check_disk_space 500 + + # Promo once per day + if should_show_promo; then + show_promo_with_qr + fi + + while true; do + clear + show_main_menu + # Auto-refresh: 30 sec timeout + if read -t 30 -r choice; then + case "$choice" in + 1) submenu_proxy ;; + 2) submenu_stats ;; + 3) submenu_manage ;; + 4) menu_bot ;; + 5) submenu_about ;; + 0|q|exit) echo ""; log_info "$(t bye)"; exit 0 ;; + *) log_error "$(t invalid_choice)" ;; + esac + + # Pause after submenu (except stats — it has its own loop) + if [ "$choice" != "2" ]; then + echo "" + echo -ne " ${DIM}$(t press_enter_to_return)${NC}" + read -r + fi + fi + # If read timed out, loop refreshes the dashboard + done +} + +# ── Статистика (авто-обновление 1 сек, без мерцания) ─────────────────────── +submenu_stats() { + # Инициализируем статистику при первом входе + if type stats_init &>/dev/null; then + stats_init 2>/dev/null + fi + + local line2; line2=$(printf '─%.0s' {1..54}) + local first_draw=1 + + # Скрываем курсор для плавного обновления + tput civis 2>/dev/null + + # Восстанавливаем курсор при выходе из функции + trap 'tput cnorm 2>/dev/null; trap - RETURN' RETURN + + while true; do + if [ "$first_draw" -eq 1 ]; then + clear + first_draw=0 + else + # Перемещаем курсор в начало экрана вместо clear — нет мерцания + tput cup 0 0 2>/dev/null || printf '\033[H' + fi + + # Draw the whole screen over the previous content + echo -e "\033[J" # erase from cursor to end (removes trails) + echo -e " ${BOLD}${WHITE}$(t stats_title)${NC}" + echo -e " ${DIM}${line2}${NC}" + + if type show_traffic_stats &>/dev/null; then + show_traffic_stats + else + echo -e " ${DIM}$(t stats_module_missing)${NC}" + echo -e " ${DIM}$(t stats_file_missing)${NC}" + echo "" + fi + + echo -e " ${DIM}${line2}${NC}" + local stats_on + stats_on=$(t stats_on) + if type toggle_stats &>/dev/null; then + local cfg_val + cfg_val=$(config_get stats_enabled 2>/dev/null || echo "true") + [ "$cfg_val" = "false" ] && stats_on=$(t stats_off) + fi + echo -e " ${CYAN}1${NC}) $(tf stats_toggle "$stats_on")" + echo -e " ${CYAN}2${NC}) $(t stats_install_collector)" + echo -e " ${CYAN}0${NC}) ${DIM}$(t back)${NC}" + echo -e " ${DIM}${line2}${NC}" + echo -e " ${DIM}$(t stats_auto_refresh)${NC}" + + # Show cursor for input, then hide again + tput cnorm 2>/dev/null + echo -ne " ${WHITE}▸ ${NC}" + + if read -t 3 -r ch; then + tput civis 2>/dev/null + case "$ch" in + 1) + if type toggle_stats &>/dev/null; then + toggle_stats + echo -ne " ${DIM}$(t press_enter)${NC}"; read -r + first_draw=1 # full redraw after action + fi + ;; + 2) + if type install_stats_collector &>/dev/null; then + install_stats_collector + echo -ne " ${DIM}$(t press_enter)${NC}"; read -r + first_draw=1 + fi + ;; + 0|"") return ;; + esac + fi + tput civis 2>/dev/null + done +} + +main "$@" diff --git a/install_gotelegram_bot.sh b/install_gotelegram_bot.sh new file mode 100644 index 0000000..02b0415 --- /dev/null +++ b/install_gotelegram_bot.sh @@ -0,0 +1,173 @@ +#!/bin/bash +# GoTelegram v2.5.0 — Установка Telegram-бота +# Создаёт venv, ставит зависимости, настраивает systemd + +set -e +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +BOT_DIR="/opt/gotelegram-bot" +SERVICE_NAME="gotelegram-bot" +GOTELEGRAM_DIR="/opt/gotelegram" +ADMIN_WEB_DIR="/opt/gotelegram-admin" +ADMIN_WEB_SERVICE="gotelegram-admin" +ADMIN_WEB_PORT="1984" + +if [ "$EUID" -ne 0 ]; then + echo -e "${RED}Запустите с sudo.${NC}" + exit 1 +fi + +echo -e "${CYAN}╔═══════════════════════════════════════════╗${NC}" +echo -e "${CYAN}║${NC} ${GREEN}GoTelegram v2.5.0 — Установка бота${NC} ${CYAN}║${NC}" +echo -e "${CYAN}╚═══════════════════════════════════════════╝${NC}" +echo "" + +# ── Python ─────────────────────────────────────────────────────────────────── +if ! command -v python3 &>/dev/null; then + echo -e "${YELLOW}[*] Установка python3...${NC}" + if command -v apt-get &>/dev/null; then + apt-get update -qq && apt-get install -y -qq python3 python3-pip python3-venv + elif command -v dnf &>/dev/null; then + dnf install -y -q python3 python3-pip + elif command -v yum &>/dev/null; then + yum install -y -q python3 python3-pip + fi +fi + +# ── Каталог бота ───────────────────────────────────────────────────────────── +mkdir -p "$BOT_DIR" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +[ -f "$SCRIPT_DIR/lib/common.sh" ] && source "$SCRIPT_DIR/lib/common.sh" || true +[ -f "$SCRIPT_DIR/lib/stats.sh" ] && source "$SCRIPT_DIR/lib/stats.sh" || true + +if [ -f "$SCRIPT_DIR/gotelegram-bot/bot.py" ]; then + echo -e "${GREEN}[*] Копирование файлов бота...${NC}" + cp "$SCRIPT_DIR/gotelegram-bot/bot.py" "$BOT_DIR/" + cp "$SCRIPT_DIR/gotelegram-bot/requirements.txt" "$BOT_DIR/" + [ -f "$SCRIPT_DIR/gotelegram-bot/config.example.env" ] && cp "$SCRIPT_DIR/gotelegram-bot/config.example.env" "$BOT_DIR/" +else + echo -e "${RED}Файлы бота не найдены в $SCRIPT_DIR/gotelegram-bot/${NC}" + exit 1 +fi + +# Копируем каталог шаблонов +if [ -f "$SCRIPT_DIR/templates_catalog.json" ]; then + mkdir -p "$GOTELEGRAM_DIR" + cp "$SCRIPT_DIR/templates_catalog.json" "$GOTELEGRAM_DIR/" + echo -e "${GREEN}[*] Каталог шаблонов скопирован${NC}" +fi + +# ── Virtual environment ────────────────────────────────────────────────────── +if [ ! -d "$BOT_DIR/venv" ]; then + echo -e "${GREEN}[*] Создание виртуального окружения...${NC}" + python3 -m venv "$BOT_DIR/venv" +fi + +echo -e "${GREEN}[*] Установка зависимостей...${NC}" +"$BOT_DIR/venv/bin/pip" install -r "$BOT_DIR/requirements.txt" -q + +# ── Конфигурация ───────────────────────────────────────────────────────────── +if [ ! -f "$BOT_DIR/.env" ]; then + echo "" + echo -e "${YELLOW}Введите BOT_TOKEN от @BotFather:${NC}" + TOKEN="" + while [ -z "$TOKEN" ]; do + read -r TOKEN + TOKEN=$(echo "$TOKEN" | tr -d '[:space:]') + [ -z "$TOKEN" ] && echo -e "${RED}Токен не может быть пустым.${NC}" + done + + echo -ne "${YELLOW}ID администратора (Enter = доступ для всех):${NC} " + read -r ADMIN_ID + + { + echo "BOT_TOKEN=$TOKEN" + [ -n "$ADMIN_ID" ] && echo "ALLOWED_IDS=$ADMIN_ID" + } > "$BOT_DIR/.env" + + chmod 600 "$BOT_DIR/.env" + echo -e "${GREEN}[*] .env создан${NC}" +else + echo -e "${GREEN}[*] .env уже существует${NC}" +fi + +# ── Systemd ────────────────────────────────────────────────────────────────── +cat > "/etc/systemd/system/${SERVICE_NAME}.service" << EOF +[Unit] +Description=GoTelegram v2.5.0 Telegram Bot +After=network.target + +[Service] +Type=simple +WorkingDirectory=$BOT_DIR +ExecStart=$BOT_DIR/venv/bin/python $BOT_DIR/bot.py +Restart=always +RestartSec=5 +Environment=PATH=$BOT_DIR/venv/bin:/usr/bin + +[Install] +WantedBy=multi-user.target +EOF + +systemctl daemon-reload +systemctl enable "$SERVICE_NAME" +systemctl restart "$SERVICE_NAME" 2>/dev/null || systemctl start "$SERVICE_NAME" + +# ── Local Web Admin ────────────────────────────────────────────────────────── +if [ -f "$SCRIPT_DIR/admin-web/server.py" ]; then + echo -e "${GREEN}[*] Установка локальной web-админки...${NC}" + mkdir -p "$ADMIN_WEB_DIR/static" + cp "$SCRIPT_DIR/admin-web/server.py" "$ADMIN_WEB_DIR/server.py" + cp -a "$SCRIPT_DIR/admin-web/static/." "$ADMIN_WEB_DIR/static/" + chmod 700 "$ADMIN_WEB_DIR" + chmod 755 "$ADMIN_WEB_DIR/server.py" "$ADMIN_WEB_DIR/static" + rm -f "$ADMIN_WEB_DIR/token" 2>/dev/null || true + + PYTHON_BIN=$(command -v python3) + cat > "/etc/systemd/system/${ADMIN_WEB_SERVICE}.service" << EOF +[Unit] +Description=GoTelegram v2.5.0 Local Web Admin +After=network.target + +[Service] +Type=simple +WorkingDirectory=$ADMIN_WEB_DIR +ExecStart=$PYTHON_BIN $ADMIN_WEB_DIR/server.py +Restart=always +RestartSec=5 +Environment=GOTELEGRAM_ADMIN_HOST=127.0.0.1 +Environment=GOTELEGRAM_ADMIN_PORT=$ADMIN_WEB_PORT + +[Install] +WantedBy=multi-user.target +EOF + systemctl daemon-reload + systemctl enable "$ADMIN_WEB_SERVICE" + systemctl restart "$ADMIN_WEB_SERVICE" 2>/dev/null || systemctl start "$ADMIN_WEB_SERVICE" + if type install_stats_collector &>/dev/null; then + install_stats_collector >/dev/null 2>&1 || echo -e "${YELLOW}[!] Сборщик статистики не запущен; откройте Traffic в Web Admin и нажмите Repair.${NC}" + fi +fi + +echo "" +echo -e "${GREEN}╔═══════════════════════════════════════════╗${NC}" +echo -e "${GREEN}║ ✅ Бот установлен и запущен! ║${NC}" +echo -e "${GREEN}╚═══════════════════════════════════════════╝${NC}" +echo "" +echo -e "Проверка: ${CYAN}systemctl status $SERVICE_NAME${NC}" +echo -e "Логи: ${CYAN}journalctl -u $SERVICE_NAME -f${NC}" +echo -e "Настройки: ${CYAN}$BOT_DIR/.env${NC}" +echo "" + +# Благодарности +echo -e "${CYAN}─────────────────────────────────────────────${NC}" +echo -e "💜 Спасибо авторам открытых проектов:" +echo -e " ${CYAN}telemt${NC} — MTProxy engine (Rust)" +echo -e " ${CYAN}HTML5 UP${NC} — шаблоны сайтов (CC BY 3.0)" +echo -e " ${CYAN}learning-zone${NC} — 150+ HTML5 шаблонов" +echo -e " ${CYAN}Start Bootstrap${NC} — Bootstrap шаблоны (MIT)" +echo -e "${CYAN}─────────────────────────────────────────────${NC}" diff --git a/lib/backup.sh b/lib/backup.sh new file mode 100644 index 0000000..e127d9f --- /dev/null +++ b/lib/backup.sh @@ -0,0 +1,589 @@ +#!/bin/bash +# goTelegram Pro v2.5.0 — backup and restore (i18n-aware) + +# ── Создание бекапа ────────────────────────────────────────────────────────── +create_backup() { + local password="$1" + local output_dir="${2:-$BACKUP_DIR}" + local timestamp + timestamp=$(date +%Y%m%d_%H%M%S) + local backup_name tmp_dir suffix=0 + + mkdir -p "$output_dir" + while true; do + if [ "$suffix" -eq 0 ]; then + backup_name="gotelegram_backup_${timestamp}" + else + backup_name="gotelegram_backup_${timestamp}_${suffix}" + fi + tmp_dir="/tmp/${backup_name}" + if [ ! -e "$tmp_dir" ] && \ + [ ! -e "${output_dir}/${backup_name}.tar.gz" ] && \ + [ ! -e "${output_dir}/${backup_name}.tar.gz.enc" ]; then + break + fi + suffix=$((suffix + 1)) + done + mkdir -p "$tmp_dir" + + # Собираем файлы + log_info "$(_t_or backup_collecting 'Собираю конфигурацию...')" + + # telemt конфиг + if [ -f "$TELEMT_CONFIG" ]; then + cp "$TELEMT_CONFIG" "$tmp_dir/config.toml" + fi + + # goTelegram Pro конфиг + if [ -f "$GOTELEGRAM_CONFIG" ]; then + cp "$GOTELEGRAM_CONFIG" "$tmp_dir/gotelegram.json" + fi + if [ -f "$GOTELEGRAM_DIR/disabled_users.json" ]; then + cp "$GOTELEGRAM_DIR/disabled_users.json" "$tmp_dir/disabled_users.json" 2>/dev/null + fi + if [ -f "$GOTELEGRAM_DIR/backup_schedule.json" ]; then + cp "$GOTELEGRAM_DIR/backup_schedule.json" "$tmp_dir/backup_schedule.json" 2>/dev/null + fi + + # Language marker (i18n) + if [ -f "$GOTELEGRAM_DIR/.language" ]; then + cp "$GOTELEGRAM_DIR/.language" "$tmp_dir/.language" + fi + + # nginx конфиг (stealth mode) + if [ -f "$NGINX_SITE_CONF" ]; then + cp "$NGINX_SITE_CONF" "$tmp_dir/nginx.conf" + fi + + # SSL сертификаты и renewal metadata для переносов между VPS + local domain + domain=$(config_get domain 2>/dev/null) + if [ -n "$domain" ] && [ -d "/etc/letsencrypt/live/$domain" ]; then + mkdir -p "$tmp_dir/letsencrypt/live" "$tmp_dir/letsencrypt/archive" "$tmp_dir/letsencrypt/renewal" + cp -a "/etc/letsencrypt/live/$domain" "$tmp_dir/letsencrypt/live/" 2>/dev/null + [ -d "/etc/letsencrypt/archive/$domain" ] && \ + cp -a "/etc/letsencrypt/archive/$domain" "$tmp_dir/letsencrypt/archive/" 2>/dev/null + [ -f "/etc/letsencrypt/renewal/$domain.conf" ] && \ + cp -a "/etc/letsencrypt/renewal/$domain.conf" "$tmp_dir/letsencrypt/renewal/" 2>/dev/null + log_dim "SSL сертификаты включены" + fi + + # Шаблон сайта (если есть) + if [ -d "$WEBSITE_ROOT" ] && [ -f "$WEBSITE_ROOT/index.html" ]; then + mkdir -p "$tmp_dir/site" + cp -a "$WEBSITE_ROOT/." "$tmp_dir/site/" + log_dim "$(_t_or backup_site_included 'Шаблон сайта включён')" + fi + + # Custom templates and catalog + if [ -d "$GOTELEGRAM_DIR/custom_templates" ]; then + mkdir -p "$tmp_dir/custom_templates" + cp -a "$GOTELEGRAM_DIR/custom_templates/." "$tmp_dir/custom_templates/" 2>/dev/null + fi + if [ -f "$GOTELEGRAM_DIR/templates_catalog.json" ]; then + cp "$GOTELEGRAM_DIR/templates_catalog.json" "$tmp_dir/templates_catalog.json" 2>/dev/null + fi + + # Bot state (.env has BotFather token, so encrypted backups are strongly recommended) + if [ -d "$BOT_DIR" ]; then + mkdir -p "$tmp_dir/bot" + [ -f "$BOT_DIR/.env" ] && cp "$BOT_DIR/.env" "$tmp_dir/bot/.env" 2>/dev/null + [ -f "$BOT_DIR/i18n.py" ] && cp "$BOT_DIR/i18n.py" "$tmp_dir/bot/i18n.py" 2>/dev/null + [ -d "$BOT_DIR/lang" ] && cp -a "$BOT_DIR/lang" "$tmp_dir/bot/" 2>/dev/null + fi + + # Local web admin state + if [ -d "$ADMIN_WEB_DIR" ]; then + mkdir -p "$tmp_dir/admin_web" + [ -f "$ADMIN_WEB_DIR/server.py" ] && cp "$ADMIN_WEB_DIR/server.py" "$tmp_dir/admin_web/server.py" 2>/dev/null + [ -d "$ADMIN_WEB_DIR/static" ] && cp -a "$ADMIN_WEB_DIR/static" "$tmp_dir/admin_web/" 2>/dev/null + fi + + # Traffic history + if [ -f "$GOTELEGRAM_DIR/stats_history.csv" ]; then + cp "$GOTELEGRAM_DIR/stats_history.csv" "$tmp_dir/stats_history.csv" 2>/dev/null + fi + if [ -f "$GOTELEGRAM_DIR/user_stats_history.csv" ]; then + cp "$GOTELEGRAM_DIR/user_stats_history.csv" "$tmp_dir/user_stats_history.csv" 2>/dev/null + fi + if [ -f "$GOTELEGRAM_DIR/shared-443.json" ]; then + cp "$GOTELEGRAM_DIR/shared-443.json" "$tmp_dir/shared-443.json" 2>/dev/null + fi + + # Метаданные + local ip mode engine lang port domain + ip=$(get_server_ip) + mode=$(config_get mode 2>/dev/null || echo "unknown") + engine=$(config_get engine 2>/dev/null || echo "telemt") + lang=$(type get_language &>/dev/null && get_language 2>/dev/null || echo "en") + port=$(config_get port 2>/dev/null || echo "443") + # Ensure port is numeric; fall back to 443 if garbage + [[ "$port" =~ ^[0-9]+$ ]] || port=443 + domain=$(config_get domain 2>/dev/null || echo "") + + cat > "$tmp_dir/metadata.json" << EOMETA +{ + "backup_version": "1.6", + "gotelegram_version": "$GOTELEGRAM_VERSION", + "created_at": "$(date -Iseconds)", + "hostname": "$(hostname)", + "ip": "$ip", + "engine": "$engine", + "mode": "$mode", + "language": "$lang", + "port": $port, + "domain": "$domain" +} +EOMETA + + # Архивируем + local tar_file="/tmp/${backup_name}.tar.gz" + if ! tar czf "$tar_file" -C /tmp "$backup_name" 2>/dev/null; then + log_error "$(_t_or backup_archive_err 'Ошибка создания архива')" + rm -rf "$tmp_dir" + rm -f "$tar_file" + return 1 + fi + + if [ ! -f "$tar_file" ]; then + log_error "$(_t_or backup_archive_missing 'Архив не создан')" + rm -rf "$tmp_dir" + return 1 + fi + + # Шифруем если задан пароль + local final_file="" + if [ -n "$password" ]; then + final_file="${output_dir}/${backup_name}.tar.gz.enc" + openssl enc -aes-256-cbc -salt -pbkdf2 -in "$tar_file" -out "$final_file" -pass "pass:${password}" 2>/dev/null + if [ $? -ne 0 ]; then + log_error "$(_t_or backup_encrypt_err 'Ошибка шифрования')" + rm -f "$tar_file" + rm -rf "$tmp_dir" + return 1 + fi + rm -f "$tar_file" + log_success "$(_t_or backup_encrypted 'Бекап зашифрован (AES-256-CBC)')" + else + final_file="${output_dir}/${backup_name}.tar.gz" + mv "$tar_file" "$final_file" + fi + + # SHA256 подпись + sha256sum "$final_file" > "${final_file}.sha256" 2>/dev/null + + # Очистка + rm -rf "$tmp_dir" + + local size + size=$(du -h "$final_file" | cut -f1) + if type tf &>/dev/null; then + log_success "$(tf backup_created_fmt "$final_file" "$size")" + else + log_success "Бекап создан: $final_file ($size)" + fi + echo "$final_file" + return 0 +} + +# ── Восстановление из бекапа ──────────────────────────────────────────────── +restore_backup() { + local backup_file="$1" + local password="$2" + local assume_yes="$3" + + if [ ! -f "$backup_file" ]; then + if type tf &>/dev/null; then + log_error "$(tf backup_file_not_found_fmt "$backup_file")" + else + log_error "Файл не найден: $backup_file" + fi + return 1 + fi + + local tmp_dir="/tmp/gotelegram_restore_$$" + mkdir -p "$tmp_dir" + + # Расшифровываем если нужно + local tar_file="" + if echo "$backup_file" | grep -q '\.enc$'; then + if [ -z "$password" ]; then + echo -ne " $(_t_or backup_enter_pass 'Введите пароль от бекапа'): " + read -rs password + echo "" + fi + tar_file="/tmp/gotelegram_restore_$$.tar.gz" + openssl enc -aes-256-cbc -d -pbkdf2 -in "$backup_file" -out "$tar_file" -pass "pass:${password}" 2>/dev/null + if [ $? -ne 0 ]; then + log_error "$(_t_or backup_bad_pass 'Неверный пароль или повреждённый файл')" + rm -rf "$tmp_dir" "$tar_file" + return 1 + fi + else + tar_file="$backup_file" + fi + + # Распаковываем + tar xzf "$tar_file" -C "$tmp_dir" 2>/dev/null + if [ $? -ne 0 ]; then + log_error "$(_t_or backup_extract_err 'Ошибка распаковки архива')" + rm -rf "$tmp_dir" + return 1 + fi + + # Находим папку бекапа + local backup_dir + backup_dir=$(find "$tmp_dir" -maxdepth 1 -type d -name "gotelegram_backup_*" | head -1) + [ -z "$backup_dir" ] && backup_dir="$tmp_dir" + + # Legacy bot backups before v2.5.0 stored absolute paths directly in tar: + # opt/gotelegram/config.json and etc/telemt/config.toml. + if [ ! -f "$backup_dir/config.toml" ] && [ -f "$tmp_dir/etc/telemt/config.toml" ]; then + cp "$tmp_dir/etc/telemt/config.toml" "$backup_dir/config.toml" 2>/dev/null || true + fi + if [ ! -f "$backup_dir/gotelegram.json" ] && [ -f "$tmp_dir/opt/gotelegram/config.json" ]; then + cp "$tmp_dir/opt/gotelegram/config.json" "$backup_dir/gotelegram.json" 2>/dev/null || true + fi + if [ ! -f "$backup_dir/disabled_users.json" ] && [ -f "$tmp_dir/opt/gotelegram/disabled_users.json" ]; then + cp "$tmp_dir/opt/gotelegram/disabled_users.json" "$backup_dir/disabled_users.json" 2>/dev/null || true + fi + + # Проверяем метаданные + if [ -f "$backup_dir/metadata.json" ]; then + local bk_version bk_mode bk_ip bk_lang bk_date + bk_version=$(jq -r '.gotelegram_version // "unknown"' "$backup_dir/metadata.json") + bk_mode=$(jq -r '.mode // "unknown"' "$backup_dir/metadata.json") + bk_ip=$(jq -r '.ip // "unknown"' "$backup_dir/metadata.json") + bk_lang=$(jq -r '.language // "-"' "$backup_dir/metadata.json") + bk_date=$(jq -r '.created_at // "-"' "$backup_dir/metadata.json") + echo "" + echo -e " ${BOLD}${WHITE}📦 $(_t_or backup_label 'Бекап'):${NC}" + echo -e " $(_t_or backup_version_label 'Версия'): $bk_version | $(_t_or backup_mode_label 'Режим'): $bk_mode | IP: $bk_ip | $(_t_or backup_lang_label 'Язык'): $bk_lang" + echo -e " $(_t_or backup_date_label 'Дата'): $bk_date" + echo "" + fi + + if [ "$assume_yes" != "yes" ] && ! confirm "$(_t_or backup_confirm_restore 'Восстановить конфигурацию? Текущие настройки будут перезаписаны.')"; then + rm -rf "$tmp_dir" + return 0 + fi + + # Останавливаем сервисы + stop_telemt 2>/dev/null + systemctl stop nginx 2>/dev/null + + # Восстанавливаем telemt конфиг + if [ -f "$backup_dir/config.toml" ]; then + mkdir -p /etc/telemt + cp "$backup_dir/config.toml" "$TELEMT_CONFIG" + chmod 600 "$TELEMT_CONFIG" + log_success "$(_t_or backup_restored_telemt 'telemt конфиг восстановлен')" + fi + + # Восстанавливаем goTelegram Pro конфиг + if [ -f "$backup_dir/gotelegram.json" ]; then + mkdir -p "$GOTELEGRAM_DIR" + cp "$backup_dir/gotelegram.json" "$GOTELEGRAM_CONFIG" + log_success "$(_t_or backup_restored_gotelegram 'GoTelegram конфиг восстановлен')" + fi + if [ -f "$backup_dir/disabled_users.json" ]; then + mkdir -p "$GOTELEGRAM_DIR" + cp "$backup_dir/disabled_users.json" "$GOTELEGRAM_DIR/disabled_users.json" + chmod 600 "$GOTELEGRAM_DIR/disabled_users.json" 2>/dev/null || true + fi + if [ -f "$backup_dir/backup_schedule.json" ]; then + mkdir -p "$GOTELEGRAM_DIR" + cp "$backup_dir/backup_schedule.json" "$GOTELEGRAM_DIR/backup_schedule.json" 2>/dev/null + chmod 600 "$GOTELEGRAM_DIR/backup_schedule.json" 2>/dev/null || true + if command -v jq >/dev/null 2>&1; then + local restored_schedule + restored_schedule=$(jq -r '.frequency // "off"' "$GOTELEGRAM_DIR/backup_schedule.json" 2>/dev/null || echo "off") + case "$restored_schedule" in + off|daily|weekly|monthly) set_backup_schedule "$restored_schedule" >/dev/null 2>&1 || true ;; + esac + fi + fi + + # Восстанавливаем language marker (i18n) + if [ -f "$backup_dir/.language" ]; then + mkdir -p "$GOTELEGRAM_DIR" + cp "$backup_dir/.language" "$GOTELEGRAM_DIR/.language" + log_success "$(_t_or backup_restored_lang 'Язык интерфейса восстановлен')" + fi + + # Восстанавливаем nginx конфиг + if [ -f "$backup_dir/nginx.conf" ]; then + mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled + cp "$backup_dir/nginx.conf" "$NGINX_SITE_CONF" + ln -sf "$NGINX_SITE_CONF" "$NGINX_SITE_LINK" + log_success "$(_t_or backup_restored_nginx 'nginx конфиг восстановлен')" + fi + + # Восстанавливаем SSL / Let's Encrypt structure + if [ -d "$backup_dir/letsencrypt" ]; then + mkdir -p /etc/letsencrypt/live /etc/letsencrypt/archive /etc/letsencrypt/renewal + [ -d "$backup_dir/letsencrypt/live" ] && cp -a "$backup_dir/letsencrypt/live/." /etc/letsencrypt/live/ 2>/dev/null + [ -d "$backup_dir/letsencrypt/archive" ] && cp -a "$backup_dir/letsencrypt/archive/." /etc/letsencrypt/archive/ 2>/dev/null + [ -d "$backup_dir/letsencrypt/renewal" ] && cp -a "$backup_dir/letsencrypt/renewal/." /etc/letsencrypt/renewal/ 2>/dev/null + log_success "$(_t_or backup_restored_ssl 'SSL сертификаты восстановлены')" + elif [ -d "$backup_dir/certs" ]; then + local domain + domain=$(config_get domain 2>/dev/null) + if [ -n "$domain" ]; then + local cert_dir="/etc/letsencrypt/live/$domain" + mkdir -p "$cert_dir" + cp "$backup_dir/certs/"* "$cert_dir/" 2>/dev/null + log_success "$(_t_or backup_restored_ssl 'SSL сертификаты восстановлены')" + fi + fi + + # Восстанавливаем шаблон сайта + if [ -d "$backup_dir/site" ]; then + mkdir -p "$WEBSITE_ROOT" + cp -a "$backup_dir/site/." "$WEBSITE_ROOT/" + chown -R www-data:www-data "$WEBSITE_ROOT" 2>/dev/null + log_success "$(_t_or backup_restored_site 'Шаблон сайта восстановлен')" + fi + + # Восстанавливаем custom templates/catalog/statistics + if [ -d "$backup_dir/custom_templates" ]; then + mkdir -p "$GOTELEGRAM_DIR/custom_templates" + cp -a "$backup_dir/custom_templates/." "$GOTELEGRAM_DIR/custom_templates/" 2>/dev/null + log_success "Пользовательские шаблоны восстановлены" + fi + if [ -f "$backup_dir/templates_catalog.json" ]; then + cp "$backup_dir/templates_catalog.json" "$GOTELEGRAM_DIR/templates_catalog.json" 2>/dev/null + fi + if [ -f "$backup_dir/stats_history.csv" ]; then + cp "$backup_dir/stats_history.csv" "$GOTELEGRAM_DIR/stats_history.csv" 2>/dev/null + log_success "История статистики восстановлена" + fi + if [ -f "$backup_dir/user_stats_history.csv" ]; then + cp "$backup_dir/user_stats_history.csv" "$GOTELEGRAM_DIR/user_stats_history.csv" 2>/dev/null + log_success "История статистики пользователей восстановлена" + fi + if [ -f "$backup_dir/shared-443.json" ]; then + cp "$backup_dir/shared-443.json" "$GOTELEGRAM_DIR/shared-443.json" 2>/dev/null + fi + + # Восстанавливаем состояние бота + if [ -d "$backup_dir/bot" ]; then + mkdir -p "$BOT_DIR" + [ -f "$backup_dir/bot/.env" ] && cp "$backup_dir/bot/.env" "$BOT_DIR/.env" 2>/dev/null && chmod 600 "$BOT_DIR/.env" + [ -d "$backup_dir/bot/lang" ] && cp -a "$backup_dir/bot/lang" "$BOT_DIR/" 2>/dev/null + [ -f "$backup_dir/bot/i18n.py" ] && cp "$backup_dir/bot/i18n.py" "$BOT_DIR/i18n.py" 2>/dev/null + log_success "Конфигурация Telegram-бота восстановлена" + fi + + # Восстанавливаем состояние локальной web-админки + if [ -d "$backup_dir/admin_web" ]; then + mkdir -p "$ADMIN_WEB_DIR" + [ -f "$backup_dir/admin_web/server.py" ] && cp "$backup_dir/admin_web/server.py" "$ADMIN_WEB_DIR/server.py" 2>/dev/null + [ -d "$backup_dir/admin_web/static" ] && cp -a "$backup_dir/admin_web/static" "$ADMIN_WEB_DIR/" 2>/dev/null + rm -f "$ADMIN_WEB_DIR/token" 2>/dev/null || true + log_success "Конфигурация web-админки восстановлена" + fi + + # Запускаем сервисы + if is_telemt_installed && [ ! -f "/etc/systemd/system/${TELEMT_SERVICE}.service" ]; then + install_telemt_service + fi + if is_telemt_installed; then + start_telemt + fi + command -v nginx &>/dev/null && systemctl start nginx 2>/dev/null + systemctl restart gotelegram-bot 2>/dev/null || true + systemctl restart gotelegram-admin 2>/dev/null || true + + # Очистка + rm -rf "$tmp_dir" + [ "$tar_file" != "$backup_file" ] && rm -f "$tar_file" + + log_success "$(_t_or backup_restore_done 'Восстановление завершено!')" + show_proxy_info + return 0 +} + +# ── Список бекапов ─────────────────────────────────────────────────────────── +list_backups() { + if [ ! -d "$BACKUP_DIR" ] || [ -z "$(ls -A "$BACKUP_DIR" 2>/dev/null)" ]; then + log_info "$(_t_or backup_none 'Бекапов нет')" + return 1 + fi + + echo "" + echo -e " ${BOLD}${WHITE}📦 $(_t_or backup_list_title 'Доступные бекапы'):${NC}" + echo -e " ${DIM}$(printf '─%.0s' {1..60})${NC}" + + local i=1 + for f in "$BACKUP_DIR"/*.tar.gz*; do + [ -f "$f" ] || continue + [[ "$f" == *.sha256 ]] && continue + local size date_str name + size=$(du -h "$f" | cut -f1) + name=$(basename "$f") + date_str=$(echo "$name" | grep -oE '[0-9]{8}_[0-9]{6}' | head -1) + local encrypted="" + [[ "$f" == *.enc ]] && encrypted=" 🔒" + echo -e " ${CYAN}${i})${NC} ${name} (${size})${encrypted}" + ((i++)) + done + echo -e " ${DIM}$(printf '─%.0s' {1..60})${NC}" +} + +# ── Очистка старых бекапов ─────────────────────────────────────────────────── +cleanup_old_backups() { + local keep="${1:-5}" + local count + count=$(find "$BACKUP_DIR" -name "*.tar.gz*" ! -name "*.sha256" 2>/dev/null | wc -l) + + if [ "$count" -gt "$keep" ]; then + local to_delete=$((count - keep)) + find "$BACKUP_DIR" -name "*.tar.gz*" ! -name "*.sha256" 2>/dev/null | sort | head -n "$to_delete" | while read -r f; do + rm -f "$f" "${f}.sha256" + done + if type tf &>/dev/null; then + log_dim "$(tf backup_cleanup_fmt "$to_delete" "$keep")" + else + log_dim "Удалено $to_delete старых бекапов (оставлено $keep)" + fi + fi +} + +# ── Расписание бекапов ─────────────────────────────────────────────────────── +backup_schedule_calendar() { + case "${1:-off}" in + off) echo "" ;; + daily) echo "*-*-* 03:20:00" ;; + weekly) echo "Sun 03:20:00" ;; + monthly) echo "*-*-01 03:20:00" ;; + *) return 1 ;; + esac +} + +set_backup_schedule() { + local frequency="${1:-off}" + local calendar + if ! calendar=$(backup_schedule_calendar "$frequency"); then + log_error "Unsupported backup schedule: $frequency" + return 1 + fi + + mkdir -p "$GOTELEGRAM_DIR" "$BACKUP_DIR" + + if [ "$frequency" = "off" ]; then + systemctl disable --now gotelegram-backup.timer >/dev/null 2>&1 || true + rm -f /etc/systemd/system/gotelegram-backup.timer /etc/systemd/system/gotelegram-backup.service + systemctl daemon-reload >/dev/null 2>&1 || true + else + cat > /etc/systemd/system/gotelegram-backup.service << 'EOSVC' +[Unit] +Description=goTelegram Pro backup +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +Environment=GOTELEGRAM_BACKUP_KEEP=30 +ExecStart=/bin/bash -lc 'source /opt/gotelegram/lib/common.sh; source /opt/gotelegram/lib/i18n.sh; source /opt/gotelegram/lib/telemt.sh; source /opt/gotelegram/lib/website.sh; source /opt/gotelegram/lib/backup.sh; load_language "$(detect_language 2>/dev/null || echo en)"; create_backup ""; cleanup_old_backups "${GOTELEGRAM_BACKUP_KEEP:-30}"' +EOSVC + + cat > /etc/systemd/system/gotelegram-backup.timer << EOTIMER +[Unit] +Description=goTelegram Pro scheduled backup + +[Timer] +OnCalendar=$calendar +Persistent=true +RandomizedDelaySec=15m +Unit=gotelegram-backup.service + +[Install] +WantedBy=timers.target +EOTIMER + systemctl daemon-reload >/dev/null 2>&1 || return 1 + systemctl enable --now gotelegram-backup.timer >/dev/null 2>&1 || return 1 + fi + + cat > "$GOTELEGRAM_DIR/backup_schedule.json" << EOSCHEDULE +{ + "frequency": "$frequency", + "calendar": "$calendar", + "keep": 30, + "updated_at": "$(date -Iseconds)" +} +EOSCHEDULE + chmod 600 "$GOTELEGRAM_DIR/backup_schedule.json" 2>/dev/null || true + log_success "Backup schedule: $frequency" + echo "$frequency" +} + +backup_schedule_status() { + local frequency="off" calendar="" + if [ -f "$GOTELEGRAM_DIR/backup_schedule.json" ] && command -v jq >/dev/null 2>&1; then + frequency=$(jq -r '.frequency // "off"' "$GOTELEGRAM_DIR/backup_schedule.json" 2>/dev/null || echo "off") + calendar=$(jq -r '.calendar // ""' "$GOTELEGRAM_DIR/backup_schedule.json" 2>/dev/null || echo "") + fi + echo "frequency=$frequency calendar=$calendar" + systemctl list-timers gotelegram-backup.timer --no-pager 2>/dev/null || true +} + +# ── Интерактивный бекап ────────────────────────────────────────────────────── +interactive_backup() { + echo "" + echo -e " ${BOLD}${WHITE}💾 $(_t_or backup_create_title 'Создание бекапа')${NC}" + echo -ne " $(_t_or backup_encrypt_prompt 'Зашифровать бекап паролем?') [Y/n]: " + read -r use_pass + + local password="" + if [[ ! "$use_pass" =~ ^[Nn] ]]; then + echo -ne " $(_t_or backup_enter_pass 'Введите пароль'): " + read -rs password + echo "" + echo -ne " $(_t_or backup_repeat_pass 'Повторите пароль'): " + read -rs password2 + echo "" + if [ "$password" != "$password2" ]; then + log_error "$(_t_or backup_pass_mismatch 'Пароли не совпадают')" + return 1 + fi + if [ ${#password} -lt 6 ]; then + log_error "$(_t_or backup_pass_short 'Пароль слишком короткий (минимум 6 символов)')" + return 1 + fi + fi + + create_backup "$password" + cleanup_old_backups +} + +# ── Интерактивное восстановление ───────────────────────────────────────────── +interactive_restore() { + list_backups || return 1 + + echo -ne " $(_t_or backup_pick_prompt 'Номер бекапа (или путь к файлу)'): " + read -r choice + + local backup_file="" + if [[ "$choice" =~ ^[0-9]+$ ]]; then + local i=1 + for f in "$BACKUP_DIR"/*.tar.gz*; do + [ -f "$f" ] || continue + [[ "$f" == *.sha256 ]] && continue + if [ "$i" -eq "$choice" ]; then + backup_file="$f" + break + fi + ((i++)) + done + elif [ -f "$choice" ]; then + backup_file="$choice" + fi + + if [ -z "$backup_file" ]; then + log_error "$(_t_or backup_not_found 'Бекап не найден')" + return 1 + fi + + restore_backup "$backup_file" +} diff --git a/lib/common.sh b/lib/common.sh new file mode 100644 index 0000000..ba2caf1 --- /dev/null +++ b/lib/common.sh @@ -0,0 +1,740 @@ +#!/bin/bash +# goTelegram Pro v2.5.0 — common utilities +# Colors, logging, spinner, system helpers, v1 compat, i18n-aware + +# ── Version ─────────────────────────────────────────────────────────────────── +GOTELEGRAM_VERSION="2.5.0" +GOTELEGRAM_NAME="goTelegram Pro" + +# ── Пути ────────────────────────────────────────────────────────────────────── +GOTELEGRAM_DIR="/opt/gotelegram" +GOTELEGRAM_CONFIG="$GOTELEGRAM_DIR/config.json" +TELEMT_CONFIG="/etc/telemt/config.toml" +TELEMT_BIN="/usr/local/bin/telemt" +TELEMT_SERVICE="telemt" +NGINX_SITE_CONF="/etc/nginx/sites-available/gotelegram" +NGINX_SITE_LINK="/etc/nginx/sites-enabled/gotelegram" +WEBSITE_ROOT="/var/www/gotelegram-site" +BACKUP_DIR="$GOTELEGRAM_DIR/backups" +LOG_FILE="/var/log/gotelegram.log" +BOT_DIR="/opt/gotelegram-bot" +ADMIN_WEB_DIR="/opt/gotelegram-admin" +ADMIN_WEB_SERVICE="gotelegram-admin" +ADMIN_WEB_HOST="127.0.0.1" +ADMIN_WEB_PORT="1984" + +# ── V1 совместимость ───────────────────────────────────────────────────────── +V1_CONTAINER_NAME="mtproto-proxy" +V1_CONFIG_FILE="/opt/gotelegram-bot/proxy.json" +V1_SERVICE_NAME="gotelegram-bot" + +# ── Цвета ──────────────────────────────────────────────────────────────────── +RED='\033[0;31m' +GREEN='\033[0;32m' +CYAN='\033[0;36m' +YELLOW='\033[1;33m' +MAGENTA='\033[0;35m' +BLUE='\033[0;34m' +WHITE='\033[1;37m' +BOLD='\033[1m' +DIM='\033[2m' +NC='\033[0m' + +# ── Логирование ────────────────────────────────────────────────────────────── +log_info() { echo -e " ${CYAN}ℹ${NC} $*" >&2; } +log_success() { echo -e " ${GREEN}✓${NC} $*" >&2; } +log_warning() { echo -e " ${YELLOW}⚠${NC} $*" >&2; } +log_error() { echo -e " ${RED}✗${NC} $*" >&2; } +log_step() { echo -e "\n${BOLD}${WHITE} $*${NC}" >&2; } +log_dim() { echo -e " ${DIM}$*${NC}" >&2; } + +log_to_file() { + local ts; ts=$(date '+%Y-%m-%d %H:%M:%S') + echo "[$ts] $*" >> "$LOG_FILE" 2>/dev/null +} + +# ── Spinner ────────────────────────────────────────────────────────────────── +_spin_pid="" +spinner_start() { + local default_msg + default_msg=$(type t &>/dev/null && t wait || echo "Please wait...") + local msg="${1:-$default_msg}" + ( + local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏') + local i=0 + while true; do + printf "\r ${CYAN}${frames[$i]}${NC} ${msg}" >&2 + i=$(( (i+1) % ${#frames[@]} )) + sleep 0.1 + done + ) & + _spin_pid=$! +} + +spinner_stop() { + [ -n "$_spin_pid" ] && kill "$_spin_pid" 2>/dev/null && wait "$_spin_pid" 2>/dev/null + _spin_pid="" + printf "\r\033[K" >&2 +} + +# ── Прогресс-бар ───────────────────────────────────────────────────────────── +progress_bar() { + local current="$1" total="$2" label="${3:-}" + local pct=$(( current * 100 / total )) + local filled=$(( pct / 2 )) + local empty=$(( 50 - filled )) + local bar="" + for ((i=0; i&2 + [ "$current" -eq "$total" ] && echo "" >&2 +} + +# ── Выполнение с индикатором ───────────────────────────────────────────────── +run_with_spinner() { + local label="$1"; shift + local err_file="/tmp/.gotelegram_spinner_err_$$" + spinner_start "$label" + "$@" >/dev/null 2>"$err_file" + local rc=$? + spinner_stop + if [ $rc -eq 0 ]; then + log_success "$label" + else + local err_label + err_label=$(type t &>/dev/null && t error || echo "error") + log_error "$label ${RED}(${err_label}, code: $rc)${NC}" + if [ -s "$err_file" ]; then + log_dim " $(head -3 "$err_file")" + fi + fi + rm -f "$err_file" + return $rc +} + +# ── Banner ─────────────────────────────────────────────────────────────────── +show_banner() { + local line + line=$(printf '━%.0s' $(seq 1 60)) + echo "" + echo -e "${CYAN}${line}${NC}" + if type tf &>/dev/null; then + echo -e " ${BOLD}${WHITE}🚀 $(tf banner_title "$GOTELEGRAM_VERSION")${NC}" + echo -e " ${DIM}$(t banner_subtitle)${NC}" + echo -e " ${DIM}$(t banner_features)${NC}" + else + echo -e " ${BOLD}${WHITE}🚀 goTelegram Pro v${GOTELEGRAM_VERSION}${NC}" + echo -e " ${DIM}MTProxy powered by telemt (Rust + Tokio)${NC}" + echo -e " ${DIM}Anti-DPI • Fake TLS • TCP Splice • JA3/JA4${NC}" + fi + echo -e "${CYAN}${line}${NC}" + echo "" +} + +# ── Credits ────────────────────────────────────────────────────────────────── +show_credits() { + local line + line=$(printf '─%.0s' $(seq 1 60)) + echo "" + echo -e "${MAGENTA}${line}${NC}" + echo -e " ${BOLD}$(type t &>/dev/null && t credits_title || echo 'Credits')${NC}" + echo -e "${MAGENTA}${line}${NC}" + echo -e " ${WHITE}telemt${NC} — MTProxy engine (Rust)" + echo -e " ${DIM}github.com/telemt/telemt${NC}" + echo "" + echo -e " ${WHITE}HTML5 UP${NC} — responsive HTML/CSS templates" + echo -e " ${DIM}html5up.net • CC BY 3.0 • @ajlkn${NC}" + echo "" + echo -e " ${WHITE}learning-zone${NC} — 150+ HTML5 templates" + echo -e " ${DIM}github.com/learning-zone/website-templates${NC}" + echo "" + echo -e " ${WHITE}Start Bootstrap${NC} — MIT license" + echo -e " ${DIM}startbootstrap.com${NC}" + echo -e "${MAGENTA}${line}${NC}" + echo "" +} + +# ── Системные утилиты ──────────────────────────────────────────────────────── +_valid_ip() { + # Validate that each octet is 0-255 + local ip="$1" + local IFS='.' + read -ra octets <<< "$ip" + [ ${#octets[@]} -ne 4 ] && return 1 + for octet in "${octets[@]}"; do + [[ "$octet" =~ ^[0-9]+$ ]] || return 1 + [ "$octet" -gt 255 ] && return 1 + done + return 0 +} + +get_server_ip() { + local ip raw + for url in "https://api.ipify.org" "https://icanhazip.com" "https://ifconfig.me"; do + raw=$(curl -s -4 --max-time 5 "$url" 2>/dev/null) + ip=$(echo "$raw" | grep -Eo '([0-9]{1,3}\.){3}[0-9]{1,3}' | head -1) + if [ -n "$ip" ] && _valid_ip "$ip"; then + echo "$ip" + return 0 + fi + done + echo "0.0.0.0" + return 1 +} + +_t_or() { + # Helper: translate if i18n available, otherwise return fallback + local key="$1" fallback="$2" + if type t &>/dev/null; then + t "$key" + else + echo "$fallback" + fi +} + +check_root() { + if [ "$EUID" -ne 0 ]; then + log_error "$(_t_or err_need_root 'Run the script with sudo / as root')" + exit 1 + fi +} + +check_os() { + if [ ! -f /etc/os-release ]; then + log_error "$(_t_or err_os_unknown 'Failed to detect OS. Linux is required.')" + return 1 + fi + # Validate os-release before sourcing (reject command injection: ;, backticks, $()) + if grep -qE '(;|`|\$\(|\$\{)' /etc/os-release 2>/dev/null; then + log_warning "/etc/os-release contains suspicious strings, skipping" + return 0 + fi + . /etc/os-release + case "$ID" in + ubuntu|debian|centos|rocky|almalinux|fedora|rhel) + log_dim "OS: $PRETTY_NAME" + return 0 + ;; + *) + log_warning "OS $ID may be incompatible. Supported: Ubuntu, Debian, CentOS, Rocky." + return 0 + ;; + esac +} + +get_arch() { + local arch + arch=$(uname -m) + case "$arch" in + x86_64|amd64) echo "amd64" ;; + aarch64|arm64) echo "arm64" ;; + armv7*|armhf) echo "armv7" ;; + *) echo "$arch" ;; + esac +} + +get_pkg_manager() { + if command -v apt-get &>/dev/null; then echo "apt" + elif command -v dnf &>/dev/null; then echo "dnf" + elif command -v yum &>/dev/null; then echo "yum" + else echo "unknown" + fi +} + +install_pkg() { + local pkg="$1" + case "$(get_pkg_manager)" in + apt) apt_install "$pkg" ;; + dnf) dnf install -y -q "$pkg" ;; + yum) yum install -y -q "$pkg" ;; + *) log_error "$(_t_or err_bad_pkg_mgr 'Unknown package manager')"; return 1 ;; + esac +} + +# ── apt lock wait + install ───────────────────────────────────────────────── +# На свежих Ubuntu/Debian unattended-upgrades часто держит dpkg lock на старте +# → любой apt-get install падает с "Could not get lock /var/lib/dpkg/lock-frontend". +# Эти функции ждут освобождения лока до 300с, потом запускают apt с нативным +# таймаутом DPkg::Lock::Timeout. Использовать везде, где раньше был +# "apt-get install ...". +apt_lock_wait() { + local max_wait="${1:-300}" + local waited=0 + local warned=0 + while fuser /var/lib/dpkg/lock-frontend &>/dev/null \ + || fuser /var/lib/dpkg/lock &>/dev/null \ + || fuser /var/lib/apt/lists/lock &>/dev/null \ + || pgrep -f '^/usr/bin/unattended-upgrade' &>/dev/null; do + if [ "$warned" = "0" ]; then + log_warning "apt/dpkg locked by unattended-upgrades, waiting up to ${max_wait}s..." + warned=1 + fi + sleep 3 + waited=$((waited + 3)) + if [ "$waited" -ge "$max_wait" ]; then + log_error "apt lock not released after ${max_wait}s" + log_dim "Manual fix: systemctl stop unattended-upgrades && killall -9 unattended-upgr 2>/dev/null; dpkg --configure -a" + return 1 + fi + done + [ "$warned" = "1" ] && log_success "apt lock released (waited ${waited}s)" + return 0 +} + +# apt_install [pkg2 ...] — ждёт lock + ставит пакеты + показывает ошибку +apt_install() { + [ $# -eq 0 ] && return 0 + apt_lock_wait || return 1 + export DEBIAN_FRONTEND=noninteractive + local opts="-o DPkg::Lock::Timeout=120" + local err_file; err_file=$(mktemp 2>/dev/null || echo /tmp/apt_err.$$) + if ! apt-get $opts install -y -qq "$@" 2>"$err_file"; then + log_error "apt-get install failed: $*" + [ -s "$err_file" ] && tail -n 5 "$err_file" | sed 's/^/ /' >&2 + rm -f "$err_file" + return 1 + fi + rm -f "$err_file" + return 0 +} + +# apt_update — тихий update с ожиданием лока +apt_update() { + apt_lock_wait || return 1 + export DEBIAN_FRONTEND=noninteractive + apt-get -o DPkg::Lock::Timeout=120 update -qq 2>/dev/null || true + return 0 +} + +# ── Зависимости GoTelegram ────────────────────────────────────────────────── +# Полный список внешних команд, которые скрипт использует. Для каждой команды +# указан пакет на apt и dnf/yum (имена различаются: например dig = dnsutils на +# Debian, bind-utils на RHEL). +# +# КРИТИЧЕСКИЕ (без них скрипт просто не работает): +# jq — парсинг config.json, templates_catalog.json +# curl — скачивание telemt и проверки HTTPS +# openssl — генерация секретов, шифрование бекапов, SSL проверка +# git — клонирование шаблонов через download_template +# xxd — hex-encode домена для fake-TLS секрета (ee-prefix) +# tar — распаковка telemt архива и бекапы +# dig — DNS-проверка домена в Pro-режиме +# +# ЖЕЛАТЕЛЬНЫЕ (есть fallback, но с ними лучше): +# qrencode — QR-коды для прокси-ссылок +# bc — красивое форматирование чисел в статистике +# +# Pro-режим доустанавливает nginx/certbot через install_nginx/install_certbot +# (они большие и нужны только если пользователь выбрал Pro). + +# Маппинг команды -> (apt_pkg, dnf_pkg). apt_pkg_for_cmd +apt_pkg_for_cmd() { + case "$1" in + dig) echo "dnsutils" ;; + xxd) echo "xxd" ;; # Ubuntu 22+: отдельный пакет, fallback ниже + nslookup) echo "dnsutils" ;; + host) echo "dnsutils" ;; + ss) echo "iproute2" ;; + netstat) echo "net-tools" ;; + flock) echo "util-linux" ;; + iptables) echo "iptables" ;; + *) echo "$1" ;; # команда == имя пакета + esac +} + +dnf_pkg_for_cmd() { + case "$1" in + dig|nslookup|host) echo "bind-utils" ;; + xxd) echo "vim-common" ;; + ss) echo "iproute" ;; + netstat) echo "net-tools" ;; + flock) echo "util-linux" ;; + iptables) echo "iptables" ;; + *) echo "$1" ;; + esac +} + +ensure_deps() { + # Критические зависимости — без них скрипт не работает. + # flock используется bot_action_dispatch для сериализации параллельных + # вызовов (иначе гонка на config.json при одновременных change-template / + # change-lite-domain из бота). + local critical=(curl jq openssl git xxd tar dig flock) + # Желательные — есть fallback, устанавливать всё равно, но не падать если не смогли + local optional=(qrencode bc iptables) + + local missing_critical=() missing_optional=() cmd + for cmd in "${critical[@]}"; do + command -v "$cmd" &>/dev/null || missing_critical+=("$cmd") + done + for cmd in "${optional[@]}"; do + command -v "$cmd" &>/dev/null || missing_optional+=("$cmd") + done + + local all_missing=("${missing_critical[@]}" "${missing_optional[@]}") + [ ${#all_missing[@]} -eq 0 ] && return 0 + + # Собираем список пакетов для выбранного менеджера + local pkg_mgr pkg pkgs=() + pkg_mgr=$(get_pkg_manager) + + for cmd in "${all_missing[@]}"; do + case "$pkg_mgr" in + apt) pkg=$(apt_pkg_for_cmd "$cmd") ;; + dnf|yum) pkg=$(dnf_pkg_for_cmd "$cmd") ;; + *) pkg="$cmd" ;; + esac + pkgs+=("$pkg") + done + + # Убираем дубликаты (например dig+nslookup оба = dnsutils) + local uniq_pkgs=() + for pkg in "${pkgs[@]}"; do + local found=0 p + for p in "${uniq_pkgs[@]}"; do + [ "$p" = "$pkg" ] && { found=1; break; } + done + [ "$found" = "0" ] && uniq_pkgs+=("$pkg") + done + + if type tf &>/dev/null; then + log_step "$(tf deps_installing "${all_missing[*]}")" + else + log_step "Installing dependencies: ${all_missing[*]} (packages: ${uniq_pkgs[*]})" + fi + + case "$pkg_mgr" in + apt) + apt_update + apt_install "${uniq_pkgs[@]}" || true + ;; + dnf) dnf install -y -q "${uniq_pkgs[@]}" 2>/dev/null ;; + yum) yum install -y -q "${uniq_pkgs[@]}" 2>/dev/null ;; + *) + log_error "Unknown package manager — install manually: ${uniq_pkgs[*]}" + return 1 + ;; + esac + + # Фолбэки для xxd: на некоторых системах нужен vim-common вместо xxd + if ! command -v xxd &>/dev/null && [ "$pkg_mgr" = "apt" ]; then + apt_install vim-common || true + fi + + # Повторная проверка критических команд + local still_missing=() + for cmd in "${critical[@]}"; do + command -v "$cmd" &>/dev/null || still_missing+=("$cmd") + done + + if [ ${#still_missing[@]} -gt 0 ]; then + log_error "Critical dependencies still missing: ${still_missing[*]}" + log_error "Install manually and re-run gotelegram" + return 1 + fi + + # Опциональные — только предупреждение + local still_missing_opt=() + for cmd in "${optional[@]}"; do + command -v "$cmd" &>/dev/null || still_missing_opt+=("$cmd") + done + if [ ${#still_missing_opt[@]} -gt 0 ]; then + log_warning "Optional deps missing (features degraded): ${still_missing_opt[*]}" + fi + + log_success "Dependencies ready" + return 0 +} + +# Быстрая проверка — только смотрит что критические установлены, ничего не ставит. +# Возвращает 0 если всё ок, 1 если что-то отсутствует. Используется на старте +# main() чтобы не дёргать apt-get update при каждом запуске меню. +check_deps_present() { + local cmd + for cmd in curl jq openssl git xxd tar dig flock; do + command -v "$cmd" &>/dev/null || return 1 + done + return 0 +} + +check_port() { + local port="$1" + local line + line=$(ss -tlnp 2>/dev/null | grep -E ":${port}\b" | head -1) + [ -z "$line" ] && line=$(netstat -tlnp 2>/dev/null | grep -E ":${port}\b" | head -1) + if [ -n "$line" ]; then + echo "$line" + return 0 # порт занят + fi + return 1 # свободен +} + +detect_3xui() { + if systemctl list-unit-files 2>/dev/null | grep -Eq '^(x-ui|3x-ui)\.service'; then + return 0 + fi + [ -d /etc/x-ui ] || [ -d /usr/local/x-ui ] || [ -f /etc/x-ui/x-ui.db ] +} + +detect_3xui_443_listener() { + ss -ltnp 2>/dev/null | grep -E '(:|])443[[:space:]]' | grep -Eiq '(xray|x-ui|3x-ui)' +} + +warn_3xui_443_conflict() { + detect_3xui_443_listener || return 1 + log_warning "Обнаружен 3x-ui/Xray, который уже слушает TCP/443." + log_warning "goTelegram Pro не будет молча останавливать или переписывать 3x-ui." + log_dim "Для настоящего shared-443 нужен один фронтовой TLS/SNI-диспетчер и разные SNI-домены для Xray и goTelegram Pro." + mkdir -p "$GOTELEGRAM_DIR" 2>/dev/null + cat > "$GOTELEGRAM_DIR/shared-443-3xui.md" <<'EOF' 2>/dev/null || true +# goTelegram Pro + 3x-ui on one TCP/443 + +goTelegram Pro detected that 3x-ui/Xray already owns TCP/443. Two independent +processes cannot bind the same IP:port at the same time. A safe shared setup +needs one front TLS/SNI dispatcher on 443 and internal backends, for example: + +- dispatcher: 0.0.0.0:443 (nginx stream ssl_preread) +- goTelegram Pro telemt: 127.0.0.1:7443 +- 3x-ui/Xray inbound: 127.0.0.1:9443 +- goTelegram Pro nginx mask site: 127.0.0.1:8443 + +The dispatcher routes Xray SNI domains to Xray. Everything else goes to telemt; +telemt then decides whether the session is MTProxy or regular HTTPS and forwards +the website to nginx through dns_overrides. + +goTelegram Pro can generate the dispatcher with: + + source /opt/gotelegram/lib/shared443.sh + shared443_enable 127.0.0.1:9443 + +Move the 3x-ui/Xray inbound from 0.0.0.0:443 to 127.0.0.1:9443 in the panel first, +or nginx will not be able to own the public 443 socket. goTelegram Pro intentionally +does not rewrite the 3x-ui SQLite database or generated Xray config without explicit +operator confirmation, because 3x-ui can overwrite manual JSON edits on the next +panel change. +EOF + return 0 +} + +check_disk_space() { + local min_mb="${1:-500}" + local avail_mb + avail_mb=$(df -m / | awk 'NR==2 {print $4}') + if [ "$avail_mb" -lt "$min_mb" ]; then + if type tf &>/dev/null; then + log_error "$(tf err_low_disk "$avail_mb" "$min_mb")" + else + log_error "Low disk space: ${avail_mb}MB (need ${min_mb}MB+)" + fi + return 1 + fi + return 0 +} + +# ── Конфигурация GoTelegram (JSON) ────────────────────────────────────────── +save_gotelegram_config() { + mkdir -p "$(dirname "$GOTELEGRAM_CONFIG")" + local cur_lang + cur_lang=$(type get_language &>/dev/null && get_language || echo en) + cat > "$GOTELEGRAM_CONFIG" << EOJSON +{ + "version": "$GOTELEGRAM_VERSION", + "engine": "${1:-telemt}", + "mode": "${2:-lite}", + "port": ${3:-443}, + "secret": "${4:-}", + "mask_host": "${5:-google.com}", + "domain": "${6:-}", + "template_id": "${7:-}", + "language": "${cur_lang}", + "installed_at": "$(date -Iseconds)", + "updated_at": "$(date -Iseconds)" +} +EOJSON + chmod 600 "$GOTELEGRAM_CONFIG" +} + +load_gotelegram_config() { + if [ -f "$GOTELEGRAM_CONFIG" ]; then + cat "$GOTELEGRAM_CONFIG" + return 0 + fi + echo "{}" + return 1 +} + +config_get() { + local key="$1" + if [ ! -f "$GOTELEGRAM_CONFIG" ]; then + return 2 # file missing + fi + local val + val=$(jq -r ".$key // empty" "$GOTELEGRAM_CONFIG" 2>/dev/null) + if [ $? -ne 0 ]; then + return 3 # invalid JSON + fi + if [ -z "$val" ]; then + return 1 # key missing or empty + fi + echo "$val" + return 0 +} + +# ── V1 совместимость ───────────────────────────────────────────────────────── +detect_v1_installation() { + # Проверяем наличие mtg Docker контейнера (v1) + if command -v docker &>/dev/null; then + if docker ps -a --format '{{.Names}}' 2>/dev/null | grep -q "^${V1_CONTAINER_NAME}$"; then + return 0 # v1 обнаружена + fi + fi + # Проверяем наличие конфига v1 + if [ -f "$V1_CONFIG_FILE" ]; then + return 0 + fi + return 1 +} + +get_v1_config() { + # Извлекаем данные из работающего v1 контейнера + if ! command -v docker &>/dev/null; then + echo "{}" + return 1 + fi + + local running + running=$(docker ps --format '{{.Names}}' 2>/dev/null | grep "^${V1_CONTAINER_NAME}$") + + if [ -z "$running" ]; then + # Пробуем из сохранённого конфига + if [ -f "$V1_CONFIG_FILE" ]; then + cat "$V1_CONFIG_FILE" + return 0 + fi + echo "{}" + return 1 + fi + + # Достаём из Docker + local cmd_str port secret ip + cmd_str=$(docker inspect "$V1_CONTAINER_NAME" --format='{{range .Config.Cmd}}{{.}} {{end}}' 2>/dev/null) + secret=$(echo "$cmd_str" | awk '{print $NF}') + port=$(docker inspect "$V1_CONTAINER_NAME" --format='{{range $p,$c := .HostConfig.PortBindings}}{{(index $c 0).HostPort}}{{end}}' 2>/dev/null) + ip=$(get_server_ip) + + jq -n \ + --arg secret "$secret" \ + --arg port "${port:-443}" \ + --arg ip "$ip" \ + '{secret: $secret, port: ($port | tonumber), ip: $ip, engine: "mtg"}' +} + +migrate_v1_to_v2() { + log_step "$(_t_or v1_migration_step 'Migrating from v1 (mtg) to v2 (telemt)')" + + local v1_config + v1_config=$(get_v1_config) + + local old_port old_secret + old_port=$(echo "$v1_config" | jq -r '.port // 443') + old_secret=$(echo "$v1_config" | jq -r '.secret // empty') + + if [ -z "$old_secret" ]; then + log_warning "Failed to extract secret from v1. A new one will be generated." + return 1 + fi + + echo "" + echo -e " ${WHITE}$(_t_or v1_found_title 'Found v1 (mtg) installation:')${NC}" + if type tf &>/dev/null; then + echo -e " $(tf v1_port "$old_port")" + echo -e " $(tf v1_secret "${old_secret:0:16}")" + else + echo -e " Port: ${CYAN}${old_port}${NC}" + echo -e " Secret: ${CYAN}${old_secret:0:16}...${NC}" + fi + echo "" + echo -e " ${YELLOW}$(_t_or warning 'Warning'):${NC} $(_t_or v1_incompatible 'mtg secret is NOT directly compatible with telemt.')" + echo -e " $(_t_or v1_new_link 'Clients will need a new link.')" + echo "" + echo -ne " $(_t_or v1_stop_migrate 'Stop v1 container and migrate to v2? [Y/n]:') " + read -r ans + if [[ "$ans" =~ ^[Nn] ]]; then + log_info "$(_t_or v1_migration_cancelled 'Migration cancelled. v1 left intact.')" + return 1 + fi + + # Stop v1 + log_info "$(_t_or v1_stopping 'Stopping v1 container...')" + docker stop "$V1_CONTAINER_NAME" 2>/dev/null + docker rm "$V1_CONTAINER_NAME" 2>/dev/null + + # Backup v1 config + if [ -f "$V1_CONFIG_FILE" ]; then + mkdir -p "$GOTELEGRAM_DIR" + cp "$V1_CONFIG_FILE" "$GOTELEGRAM_DIR/v1_backup_proxy.json" 2>/dev/null + if type tf &>/dev/null; then + log_success "$(tf v1_config_saved "$GOTELEGRAM_DIR/v1_backup_proxy.json")" + else + log_success "v1 config saved to $GOTELEGRAM_DIR/v1_backup_proxy.json" + fi + fi + + if type tf &>/dev/null; then + log_success "$(tf v1_port_freed "$old_port")" + else + log_success "v1 stopped. Port $old_port freed." + fi + return 0 +} + +# ── Confirm prompt ─────────────────────────────────────────────────────────── +confirm() { + local default_msg + default_msg=$(_t_or install_continue_anyway 'Continue?') + local msg="${1:-$default_msg}" + echo -ne " ${msg} [Y/n]: " >&2 + read -r ans + [[ ! "$ans" =~ ^[Nn] ]] +} + +# ── Выбор из списка ────────────────────────────────────────────────────────── +select_option() { + local title="$1" + shift + local options=("$@") + + echo "" >&2 + echo -e " ${BOLD}${WHITE}${title}${NC}" >&2 + echo -e " ${DIM}$(printf '─%.0s' {1..50})${NC}" >&2 + local i=1 + for opt in "${options[@]}"; do + echo -e " ${CYAN}${i})${NC} ${opt}" >&2 + ((i++)) + done + echo -e " ${DIM}$(printf '─%.0s' {1..50})${NC}" >&2 + echo -ne " ${WHITE}$(_t_or choose 'Choose'):${NC} " >&2 + read -r choice + echo "$choice" +} + +# ── Генерация случайного hex ───────────────────────────────────────────────── +generate_hex() { + local len="${1:-32}" + openssl rand -hex "$((len/2))" 2>/dev/null || head -c "$((len/2))" /dev/urandom | xxd -p | tr -d '\n' +} + +# ── Проверка домена ────────────────────────────────────────────────────────── +validate_domain() { + local domain="$1" + if echo "$domain" | grep -qE '^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$'; then + return 0 + fi + return 1 +} + +# ── Init: создание директорий ──────────────────────────────────────────────── +init_dirs() { + mkdir -p "$GOTELEGRAM_DIR" "$BACKUP_DIR" /etc/telemt 2>/dev/null + touch "$LOG_FILE" 2>/dev/null +} diff --git a/lib/i18n.sh b/lib/i18n.sh new file mode 100644 index 0000000..6fc2767 --- /dev/null +++ b/lib/i18n.sh @@ -0,0 +1,140 @@ +#!/bin/bash +# GoTelegram v2.5.0 — i18n engine +# Internationalization support: EN (English) / RU (Русский) +# +# Usage: +# source lib/i18n.sh +# load_language "ru" # or "en" +# echo "$(t menu_install)" # translated string +# printf "$(t greeting)\n" "$name" # with format args + +# ── Global i18n state ── +declare -gA I18N +LANG_CODE="${LANG_CODE:-en}" +LANG_FILE="" + +# ── Load a language ── +# Sources lib/lang/${lang}.sh into the I18N associative array. +# Falls back to English if requested language file is missing. +load_language() { + local lang="${1:-en}" + # Sanitize: only allow [a-z]{2} codes + if ! [[ "$lang" =~ ^[a-z]{2}$ ]]; then + lang="en" + fi + + local lang_dir + lang_dir="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/lang" + local lang_file="${lang_dir}/${lang}.sh" + + if [ ! -f "$lang_file" ]; then + lang_file="${lang_dir}/en.sh" + lang="en" + fi + + if [ -f "$lang_file" ]; then + # Clear previous keys then source the new language + I18N=() + # shellcheck disable=SC1090 + source "$lang_file" + LANG_CODE="$lang" + LANG_FILE="$lang_file" + return 0 + fi + return 1 +} + +# ── Translate: fetch value by key ── +# t → echoes translation (or key if missing) +t() { + local key="$1" + local val="${I18N[$key]:-}" + if [ -z "$val" ]; then + # Fallback to key name so missing translations are visible + echo "$key" + else + echo "$val" + fi +} + +# ── Translate + printf-style formatting ── +# tf ... +tf() { + local key="$1" + shift + local fmt="${I18N[$key]:-$key}" + # shellcheck disable=SC2059 + printf "$fmt" "$@" +} + +# ── Get current language code ── +get_language() { + echo "$LANG_CODE" +} + +# ── Detect saved language from config.json, default en ── +detect_language() { + local cfg="${GOTELEGRAM_CONFIG:-/opt/gotelegram/config.json}" + local lang="" + if [ -f "$cfg" ] && command -v jq >/dev/null 2>&1; then + lang=$(jq -r '.language // empty' "$cfg" 2>/dev/null) + fi + # Also check marker file (language set before config.json exists) + if [ -z "$lang" ]; then + local marker="${GOTELEGRAM_DIR:-/opt/gotelegram}/.language" + if [ -f "$marker" ]; then + lang=$(head -c 2 "$marker" 2>/dev/null | tr -d '[:space:]') + fi + fi + # Sanitize + if ! [[ "$lang" =~ ^(en|ru)$ ]]; then + lang="en" + fi + echo "$lang" +} + +# ── Persist selected language ── +# Saves to config.json if present, otherwise to marker file +save_language() { + local lang="$1" + if ! [[ "$lang" =~ ^(en|ru)$ ]]; then + return 1 + fi + mkdir -p "${GOTELEGRAM_DIR:-/opt/gotelegram}" 2>/dev/null + # Always write marker for early-access (before config.json exists) + echo "$lang" > "${GOTELEGRAM_DIR:-/opt/gotelegram}/.language" 2>/dev/null + + local cfg="${GOTELEGRAM_CONFIG:-/opt/gotelegram/config.json}" + if [ -f "$cfg" ] && command -v jq >/dev/null 2>&1; then + local tmp + tmp=$(mktemp) || return 1 + if jq --arg lang "$lang" '. + {language: $lang}' "$cfg" > "$tmp" 2>/dev/null; then + mv "$tmp" "$cfg" + chmod 600 "$cfg" + else + rm -f "$tmp" + fi + fi + return 0 +} + +# ── First-run interactive language picker ── +# Shows a minimal, language-agnostic picker (keeps it culture-neutral). +# Returns the chosen code via echo. +pick_language_interactive() { + echo "" >&2 + echo " ┌──────────────────────────────────────────┐" >&2 + echo " │ Select language / Выберите язык │" >&2 + echo " ├──────────────────────────────────────────┤" >&2 + echo " │ 1) English │" >&2 + echo " │ 2) Русский │" >&2 + echo " └──────────────────────────────────────────┘" >&2 + echo -n " > " >&2 + local ch + read -r ch + case "$ch" in + 1|en|EN|english|English) echo "en" ;; + 2|ru|RU|russian|Russian|русский) echo "ru" ;; + *) echo "en" ;; + esac +} diff --git a/lib/lang/en.sh b/lib/lang/en.sh new file mode 100644 index 0000000..5fd89c3 --- /dev/null +++ b/lib/lang/en.sh @@ -0,0 +1,377 @@ +#!/bin/bash +# goTelegram Pro v2.5.0 — English translations +# shellcheck disable=SC2034,SC2148 + +# ── Common words ──────────────────────────────────────────────────────── +I18N[yes]="Yes" +I18N[no]="No" +I18N[ok]="OK" +I18N[cancel]="Cancel" +I18N[back]="« Back" +I18N[exit]="Exit" +I18N[skip]="Skip" +I18N[choose]="Choose" +I18N[press_enter]="Press Enter..." +I18N[press_enter_to_return]="Press Enter to return to menu..." +I18N[invalid_choice]="Invalid choice" +I18N[running]="running" +I18N[stopped]="stopped" +I18N[not_installed]="not installed" +I18N[unknown]="unknown" +I18N[error]="Error" +I18N[warning]="Warning" +I18N[info]="Info" +I18N[success]="Done" +I18N[wait]="Please wait..." + +# ── Banner ────────────────────────────────────────────────────────────── +I18N[banner_title]="goTelegram Pro v%s" +I18N[banner_subtitle]="MTProxy powered by telemt (Rust + Tokio)" +I18N[banner_features]="Anti-DPI • Fake TLS • TCP Splice • JA3/JA4" +I18N[credits_title]="Credits / Thanks" + +# ── Main menu (dashboard) ─────────────────────────────────────────────── +I18N[dashboard_title]="Control panel" +I18N[svc_proxy]="Proxy" +I18N[svc_nginx]="nginx" +I18N[svc_site]="Site" +I18N[svc_ssl]="SSL" +I18N[svc_bot]="Bot" +I18N[ssl_until]="until %s" +I18N[net_ip]="IP:" +I18N[net_port]="Port:" +I18N[net_mode]="Mode:" +I18N[net_domain]="Domain:" +I18N[connection_link]="Telegram connection link:" +I18N[proxy_not_configured]="Proxy is not configured. Select option 1." +I18N[menu_proxy]="Proxy ▸" +I18N[menu_stats]="Statistics ▸" +I18N[menu_manage]="Management ▸" +I18N[menu_telegram_bot]="Telegram bot ▸" +I18N[menu_about]="About ▸" +I18N[auto_refresh_30s]="Refresh in 30 sec" + +# ── Submenu: Proxy ────────────────────────────────────────────────────── +I18N[submenu_proxy_title]="🚀 PROXY" +I18N[proxy_install_update]="Install / Update" +I18N[proxy_status_detail]="Detailed status" +I18N[proxy_copy_link]="Copy link" +I18N[proxy_share]="Share key" +I18N[proxy_restart]="Restart" +I18N[proxy_logs]="Logs" +I18N[proxy_change_mode]="Change mode / template" + +# ── Submenu: Manage ───────────────────────────────────────────────────── +I18N[submenu_manage_title]="⚙️ MANAGEMENT" +I18N[manage_backup]="Backup" +I18N[manage_restore]="Restore" +I18N[manage_update_telemt]="Update telemt" +I18N[manage_site_ssl]="Site / SSL" +I18N[manage_remove]="Remove" +I18N[manage_language]="Language / Язык" + +# ── Submenu: About ────────────────────────────────────────────────────── +I18N[submenu_about_title]="ℹ️ ABOUT" +I18N[about_version_info]="Version info" +I18N[about_promo]="Promo / Donate" +I18N[version_title]="🔍 Information" +I18N[version_label]="goTelegram Pro:" +I18N[version_engine]="Engine:" +I18N[version_tech]="Technology:" +I18N[version_license]="License:" + +# ── Install flow ──────────────────────────────────────────────────────── +I18N[install_select_mode]="🎭 Select masquerade mode:" +I18N[install_lite_title]="⚡ Lite — masquerade as popular website" +I18N[install_lite_desc1]="Fast, no domain needed. telemt disguises traffic" +I18N[install_lite_desc2]="as the chosen site (google.com etc.)" +I18N[install_pro_title]="🛡 Pro — your own site + full masquerade" +I18N[install_pro_desc1]="nginx + SSL + HTML template + telemt." +I18N[install_pro_desc2]="DPI sees a real website with a real certificate." +I18N[install_pro_desc3]="Requires: a domain pointing to this server." +I18N[install_mode_choice]="Choice (1/2):" +I18N[install_bad_choice]="Invalid choice: %s" +I18N[install_lite_step]="Installing Lite mode" +I18N[install_pro_step]="Installing Pro mode" +I18N[install_enter_domain]="Enter your domain (e.g. example.com):" +I18N[install_bad_domain]="Invalid domain: %s" +I18N[install_dns_mismatch]="Domain %s points to %s, not to %s" +I18N[install_continue_anyway]="Continue anyway?" +I18N[install_enter_email]="Email for SSL (Enter = no email):" +I18N[install_config_title]="📋 Configuration:" +I18N[install_cfg_ip]="IP:" +I18N[install_cfg_port]="Port:" +I18N[install_cfg_mask]="Masquerade:" +I18N[install_cfg_mode]="Mode:" +I18N[install_cfg_domain]="Domain:" +I18N[install_confirm_proxy]="Install proxy?" +I18N[install_confirm_proxy_site]="Install proxy + website?" +I18N[install_done]="goTelegram Pro v%s installed! (%s mode)" +I18N[install_arch_desc1]="telemt accepts all traffic on 443 (HTTPS masquerade)" +I18N[install_arch_desc2]="nginx serves the site on internal port %s" +I18N[install_arch_desc3]="ISP only sees HTTPS traffic to %s:443" + +# ── Change mode/template ──────────────────────────────────────────────── +I18N[change_current_mode]="Current mode:" +I18N[change_template]="Change site template (pro only)" +I18N[change_mode_switch]="Switch mode (lite ↔ pro)" +I18N[change_only_pro]="Template change is available in pro mode only" +I18N[change_requires_reinstall]="Mode switch requires reinstall." +I18N[change_reinstall_confirm]="Reinstall proxy?" + +# ── Logs ──────────────────────────────────────────────────────────────── +I18N[logs_telemt_title]="📋 telemt logs (last %s lines):" + +# ── Link / Share ──────────────────────────────────────────────────────── +I18N[link_title]="🔗 Connection link:" +I18N[share_title]="📤 Forward this message:" +I18N[share_line1]="🔐 MTProxy for Telegram (goTelegram Pro v%s)" +I18N[share_server]="🌍 Server: %s" +I18N[share_port]="🔌 Port: %s" +I18N[share_connect_cta]="👉 Connect with one tap:" +I18N[share_footer]="Just tap the link or configure manually." + +# ── Website ───────────────────────────────────────────────────────────── +I18N[website_title]="🌐 Website management" +I18N[website_domain]="Domain:" +I18N[website_ssl_until]="SSL until:" +I18N[website_only_pro]="Website management is available in pro mode only" +I18N[website_renew_ssl]="Renew SSL certificate" +I18N[website_restart_nginx]="Restart nginx" +I18N[website_change_template]="Change template" + +# ── Remove ────────────────────────────────────────────────────────────── +I18N[remove_title]="🗑 Remove goTelegram Pro" +I18N[remove_proxy_only]="Remove proxy only (telemt)" +I18N[remove_bot_only]="Remove Telegram bot only" +I18N[remove_all]="Remove everything (proxy + bot + settings)" +I18N[remove_warn_proxy]="This will remove the proxy and all its settings." +I18N[remove_confirm_proxy]="Remove proxy?" +I18N[remove_backup_before]="Create a backup before removal?" +I18N[remove_warn_all]="This will remove EVERYTHING: proxy, bot, site, settings." +I18N[remove_confirm_all]="Are you absolutely sure?" +I18N[remove_proxy_done]="Proxy removed" +I18N[remove_all_done]="goTelegram Pro fully removed (proxy + bot)" + +# ── Telegram bot submenu ──────────────────────────────────────────────── +I18N[bot_title]="🤖 Telegram bot" +I18N[bot_status_running]="● Running" +I18N[bot_status_stopped]="○ Stopped" +I18N[bot_status_not_installed]="✗ Not installed" +I18N[bot_menu_status]="📊 Bot status" +I18N[bot_menu_logs]="📋 Bot logs" +I18N[bot_menu_restart]="🔄 Restart bot" +I18N[bot_menu_stop]="⏹ Stop bot" +I18N[bot_menu_start]="▶️ Start bot" +I18N[bot_menu_settings]="⚙️ Settings (.env)" +I18N[bot_menu_remove]="🗑 Remove bot" +I18N[bot_menu_install]="🔧 Install bot" +I18N[bot_intro1]="The bot lets you manage the proxy from Telegram:" +I18N[bot_intro2]="status, restart, change mode, backup, QR code." +I18N[bot_install_step]="Installing Telegram bot" +I18N[bot_install_python]="Installing Python3..." +I18N[bot_files_not_found]="Bot files not found in %s" +I18N[bot_create_venv]="Creating virtual environment..." +I18N[bot_install_deps]="Installing dependencies..." +I18N[bot_enter_token]="Enter BOT_TOKEN from @BotFather:" +I18N[bot_token_empty]="Token cannot be empty" +I18N[bot_token]="Token:" +I18N[bot_add_admin_how]="How to add the administrator?" +I18N[bot_admin_auto]="Auto — bot will capture the ID on first /start" +I18N[bot_admin_manual]="Manual — enter the ID now" +I18N[bot_admin_ids_prompt]="Admin IDs (space or comma separated):" +I18N[bot_env_created]=".env created" +I18N[bot_env_exists]=".env already exists, settings preserved" +I18N[bot_wait_admin_title]="Waiting for administrator" +I18N[bot_wait_admin_msg1]="Open the bot in Telegram and send" +I18N[bot_wait_admin_msg2]="The bot will automatically make you an admin" +I18N[bot_wait_admin_skip]="Press Ctrl+C to skip" +I18N[bot_wait_spinner]="Waiting... send /start to the bot (%d sec)" +I18N[bot_admin_assigned]="Administrator assigned!" +I18N[bot_wait_skipped]="Skipped. Add admin later via: menu → Telegram bot → Settings" +I18N[bot_wait_timeout]="Timeout (5 min). Add admin via: menu → Telegram bot → Settings" +I18N[bot_installed]="Bot installed and running!" +I18N[bot_status_title]="📊 Telegram bot status" +I18N[bot_token_configured]="configured" +I18N[bot_access_open]="all users" +I18N[bot_logs_title]="📋 Bot logs (last 30 lines):" +I18N[bot_settings_title]="⚙️ Bot settings" +I18N[bot_current_env]="Current .env:" +I18N[bot_change_token]="Change BOT_TOKEN" +I18N[bot_change_allowed]="Change ALLOWED_IDS" +I18N[bot_new_token]="New BOT_TOKEN:" +I18N[bot_token_empty_err]="Empty token" +I18N[bot_token_updated]="Token updated, bot restarted" +I18N[bot_allowed_prompt]="ALLOWED_IDS (space or comma separated, empty = auto):" +I18N[bot_access_updated]="Access updated, bot restarted" +I18N[bot_remove_warn]="This will remove the Telegram bot and all its settings." +I18N[bot_remove_confirm]="Remove bot?" +I18N[bot_removed]="Bot fully removed" +I18N[bot_restarted]="Bot restarted" +I18N[bot_stopped]="Bot stopped" +I18N[bot_started]="Bot started" +I18N[bot_status_colon]="Status:" +I18N[bot_access_colon]="Access:" +I18N[bot_access_ids_fmt]="ID: %s" + +# ── Promo / Donate ────────────────────────────────────────────────────── +I18N[promo_host1_title]="💰 HOSTING #1 — UP TO 60% OFF" +I18N[promo_host2_title]="💰 HOSTING #2 — UP TO 60% OFF" +I18N[promo_tips_title]="☕ Donate / Tips" +I18N[promo_youtube_title]="▶ YouTube Channel" +I18N[promo_link_label]="Link:" +I18N[promo_off60]="60%% discount on the first month" +I18N[promo_ant20]="20%% + 3%% when paid for 3 months" +I18N[promo_ant6]="15%% + 5%% when paid for 6 months" +I18N[promo_qr_host1]="── QR: Hosting #1 ──" +I18N[promo_qr_host2]="── QR: Hosting #2 ──" +I18N[promo_qr_tips]="── QR: Donate / Tips ──" +I18N[promo_qr_youtube]="── QR: YouTube Channel ──" +I18N[promo_menu_in]="Menu in %d sec..." + +# ── Stats ─────────────────────────────────────────────────────────────── +I18N[stats_title]="📊 Traffic statistics" +I18N[stats_module_missing]="Statistics module not loaded." +I18N[stats_file_missing]="File lib/stats.sh not found." +I18N[stats_toggle]="Toggle counter (now: %s)" +I18N[stats_install_collector]="Install/update stats collector" +I18N[stats_auto_refresh]="Refresh every 3 sec" +I18N[stats_on]="on" +I18N[stats_off]="off" + +# ── Templates catalog ─────────────────────────────────────────────────── +I18N[templates_categories]="📂 Site template categories:" +I18N[templates_custom_git]="📎 Custom template from git URL" +I18N[templates_random]="🎲 Random template" +I18N[templates_count_fmt]="(%d templates)" +I18N[templates_list]="📋 %s — available templates:" +I18N[templates_preview_title]="🔍 Template preview:" +I18N[templates_name]="Name:" +I18N[templates_source]="Source:" +I18N[templates_description]="Description:" +I18N[templates_preview]="👁 Preview:" +I18N[templates_preview_hint]="Open the link in a browser to preview the template" +I18N[templates_repo]="📦 Repo:" +I18N[templates_thanks]="💜 Thanks to the authors of %s for the open source code!" +I18N[templates_install_this]="Install this template?" +I18N[templates_cat_empty]="No templates in this category" +I18N[templates_downloading]="Downloading template \"%s\"..." +I18N[templates_downloaded]="Template \"%s\" downloaded" +I18N[templates_downloaded_subfolder]="Template \"%s\" downloaded (from subfolder)" +I18N[templates_no_index]="Template does not contain index.html" +I18N[templates_path]="Path: %s" +I18N[templates_catalog_not_found]="Templates catalog not found: %s" + +# ── Custom git template ───────────────────────────────────────────────── +I18N[custom_git_title]="📎 CUSTOM TEMPLATE FROM GIT URL" +I18N[custom_git_help_1]="You can use ANY public static HTML repository as a template." +I18N[custom_git_help_2]="The repository must be public and contain a ready-made" +I18N[custom_git_help_3]="index.html (build via npm is NOT performed)." +I18N[custom_git_formats]="Supported URL formats:" +I18N[custom_git_fmt_github]=" • https://github.com/user/repo" +I18N[custom_git_fmt_gitlab]=" • https://gitlab.com/user/repo" +I18N[custom_git_fmt_gitext]=" • https://example.com/user/repo.git" +I18N[custom_git_fmt_branch]=" • https://github.com/user/repo@branch (branch after @)" +I18N[custom_git_auto_detect]="Repository structure (auto-detection):" +I18N[custom_git_auto_1]=" 1. index.html in repo root" +I18N[custom_git_auto_2]=" 2. dist/index.html (StartBootstrap, Vite, webpack)" +I18N[custom_git_auto_3]=" 3. public/ or build/ or _site/ or site/ or docs/" +I18N[custom_git_auto_4]=" 4. Fallback: search index.html across whole repo" +I18N[custom_git_requirements]="Requirements:" +I18N[custom_git_req_1]=" • HTTPS only (ssh:// and git:// are blocked)" +I18N[custom_git_req_2]=" • Public repositories only" +I18N[custom_git_req_3]=" • Repo size up to 100 MB" +I18N[custom_git_req_4]=" • Static HTML (no PHP/Python/Node server code)" +I18N[custom_git_examples]="Tested example repos:" +I18N[custom_git_ex_1]=" • https://github.com/html5up-collective/strata" +I18N[custom_git_ex_2]=" • https://github.com/StartBootstrap/startbootstrap-landing-page" +I18N[custom_git_enter_url]="Paste git URL (or Enter to cancel):" +I18N[custom_git_empty]="No URL provided, cancelled" +I18N[custom_git_bad_url]="Invalid URL. Only https:// addresses are accepted" +I18N[custom_git_cloning]="Cloning repository..." +I18N[custom_git_clone_failed]="Failed to clone repository: %s" +I18N[custom_git_too_big]="Repository is too large: %s (limit 100MB)" +I18N[custom_git_scanning]="Scanning for index.html..." +I18N[custom_git_found_at]="✓ Found index.html in: %s" +I18N[custom_git_no_index]="index.html not found in the repository" +I18N[custom_git_installed]="Custom template installed from %s" +I18N[custom_git_saved]="Template URL saved in config (menu → Site → Update from git)" + +# ── First-run language picker ─────────────────────────────────────────── +I18N[lang_picker_title]="Select language / Выберите язык" +I18N[lang_english]="English" +I18N[lang_russian]="Русский" +I18N[lang_saved]="Language saved: %s" +I18N[lang_change_prompt]="Select a new language:" + +# ── Backup ────────────────────────────────────────────────────────────── +I18N[backup_title]="💾 Backup" +I18N[backup_creating]="Creating backup..." +I18N[backup_created]="Backup created: %s" +I18N[backup_failed]="Backup creation failed" +I18N[backup_restore_title]="↩️ Restore from backup" +I18N[backup_no_files]="No backup files" +I18N[backup_select]="Select a backup to restore:" +I18N[backup_restoring]="Restoring..." +I18N[backup_restored]="Backup restored" +I18N[backup_collecting]="Collecting configuration..." +I18N[backup_site_included]="Website template included" +I18N[backup_archive_err]="Archive creation failed" +I18N[backup_archive_missing]="Archive not created" +I18N[backup_encrypt_err]="Encryption failed" +I18N[backup_encrypted]="Backup encrypted (AES-256-CBC)" +I18N[backup_created_fmt]="Backup created: %s (%s)" +I18N[backup_file_not_found_fmt]="File not found: %s" +I18N[backup_enter_pass]="Enter backup password" +I18N[backup_bad_pass]="Wrong password or corrupted file" +I18N[backup_extract_err]="Archive extraction failed" +I18N[backup_label]="Backup" +I18N[backup_version_label]="Version" +I18N[backup_mode_label]="Mode" +I18N[backup_lang_label]="Language" +I18N[backup_date_label]="Date" +I18N[backup_confirm_restore]="Restore configuration? Current settings will be overwritten." +I18N[backup_restored_telemt]="telemt config restored" +I18N[backup_restored_gotelegram]="goTelegram Pro config restored" +I18N[backup_restored_lang]="Interface language restored" +I18N[backup_restored_nginx]="nginx config restored" +I18N[backup_restored_ssl]="SSL certificates restored" +I18N[backup_restored_site]="Website template restored" +I18N[backup_restore_done]="Restore completed!" +I18N[backup_none]="No backups" +I18N[backup_list_title]="Available backups" +I18N[backup_cleanup_fmt]="Removed %s old backups (kept %s)" +I18N[backup_create_title]="Create backup" +I18N[backup_encrypt_prompt]="Encrypt backup with a password?" +I18N[backup_repeat_pass]="Repeat password" +I18N[backup_pass_mismatch]="Passwords do not match" +I18N[backup_pass_short]="Password too short (minimum 6 characters)" +I18N[backup_pick_prompt]="Backup number (or path to file)" +I18N[backup_not_found]="Backup not found" + +# ── Errors / misc ─────────────────────────────────────────────────────── +I18N[err_need_root]="Run the script with sudo / as root" +I18N[err_os_unknown]="Failed to detect OS. Linux is required." +I18N[err_low_disk]="Low disk space: %sMB (need %sMB+)" +I18N[err_bad_pkg_mgr]="Unknown package manager" +I18N[err_unexpected]="Unexpected error" +I18N[bye]="See you later! 👋" +I18N[auto_refresh]="Refresh in 30 sec" + +# ── Deps ──────────────────────────────────────────────────────────────── +I18N[deps_installing]="Installing dependencies: %s" + +# ── Migration ─────────────────────────────────────────────────────────── +I18N[v1_detected]="⚠️ goTelegram Pro v1 (mtg) installation detected" +I18N[v1_container]="Container: %s" +I18N[v1_migration_step]="Migrating from v1 (mtg) to v2 (telemt)" +I18N[v1_found_title]="Found v1 (mtg) installation:" +I18N[v1_port]="Port: %s" +I18N[v1_secret]="Secret: %s..." +I18N[v1_incompatible]="mtg secret is NOT directly compatible with telemt." +I18N[v1_new_link]="Clients will need a new link." +I18N[v1_stop_migrate]="Stop v1 container and migrate to v2? [Y/n]:" +I18N[v1_migration_cancelled]="Migration cancelled. v1 left intact." +I18N[v1_stopping]="Stopping v1 container..." +I18N[v1_config_saved]="v1 config saved to %s" +I18N[v1_port_freed]="v1 stopped. Port %s freed." diff --git a/lib/lang/ru.sh b/lib/lang/ru.sh new file mode 100644 index 0000000..af738f4 --- /dev/null +++ b/lib/lang/ru.sh @@ -0,0 +1,377 @@ +#!/bin/bash +# goTelegram Pro v2.5.0 — Russian translations +# shellcheck disable=SC2034,SC2148 + +# ── Common words ──────────────────────────────────────────────────────── +I18N[yes]="Да" +I18N[no]="Нет" +I18N[ok]="OK" +I18N[cancel]="Отмена" +I18N[back]="« Назад" +I18N[exit]="Выход" +I18N[skip]="Пропустить" +I18N[choose]="Выбор" +I18N[press_enter]="Нажмите Enter..." +I18N[press_enter_to_return]="Нажмите Enter для возврата в меню..." +I18N[invalid_choice]="Неверный выбор" +I18N[running]="работает" +I18N[stopped]="остановлен" +I18N[not_installed]="не установлен" +I18N[unknown]="неизвестно" +I18N[error]="Ошибка" +I18N[warning]="Внимание" +I18N[info]="Инфо" +I18N[success]="Готово" +I18N[wait]="Подождите..." + +# ── Banner ────────────────────────────────────────────────────────────── +I18N[banner_title]="goTelegram Pro v%s" +I18N[banner_subtitle]="MTProxy на ядре telemt (Rust + Tokio)" +I18N[banner_features]="Anti-DPI • Fake TLS • TCP Splice • JA3/JA4" +I18N[credits_title]="Благодарности / Credits" + +# ── Main menu (dashboard) ─────────────────────────────────────────────── +I18N[dashboard_title]="Панель управления" +I18N[svc_proxy]="Прокси" +I18N[svc_nginx]="nginx" +I18N[svc_site]="Сайт" +I18N[svc_ssl]="SSL" +I18N[svc_bot]="Бот" +I18N[ssl_until]="до %s" +I18N[net_ip]="IP:" +I18N[net_port]="Порт:" +I18N[net_mode]="Режим:" +I18N[net_domain]="Домен:" +I18N[connection_link]="Ссылка для Telegram:" +I18N[proxy_not_configured]="Прокси не настроен. Выберите пункт 1." +I18N[menu_proxy]="Прокси ▸" +I18N[menu_stats]="Статистика ▸" +I18N[menu_manage]="Управление ▸" +I18N[menu_telegram_bot]="Telegram-бот ▸" +I18N[menu_about]="О программе ▸" +I18N[auto_refresh_30s]="Обновление через 30 сек" + +# ── Submenu: Proxy ────────────────────────────────────────────────────── +I18N[submenu_proxy_title]="🚀 ПРОКСИ" +I18N[proxy_install_update]="Установить / Обновить" +I18N[proxy_status_detail]="Статус подробно" +I18N[proxy_copy_link]="Скопировать ссылку" +I18N[proxy_share]="Поделиться ключом" +I18N[proxy_restart]="Перезапуск" +I18N[proxy_logs]="Логи" +I18N[proxy_change_mode]="Сменить режим / шаблон" + +# ── Submenu: Manage ───────────────────────────────────────────────────── +I18N[submenu_manage_title]="⚙️ УПРАВЛЕНИЕ" +I18N[manage_backup]="Бекап" +I18N[manage_restore]="Восстановить" +I18N[manage_update_telemt]="Обновить telemt" +I18N[manage_site_ssl]="Сайт / SSL" +I18N[manage_remove]="Удалить" +I18N[manage_language]="Язык / Language" + +# ── Submenu: About ────────────────────────────────────────────────────── +I18N[submenu_about_title]="ℹ️ О ПРОГРАММЕ" +I18N[about_version_info]="Информация о версии" +I18N[about_promo]="Промо / Донат" +I18N[version_title]="🔍 Информация" +I18N[version_label]="goTelegram Pro:" +I18N[version_engine]="Ядро:" +I18N[version_tech]="Технология:" +I18N[version_license]="Лицензия:" + +# ── Install flow ──────────────────────────────────────────────────────── +I18N[install_select_mode]="🎭 Выберите режим маскировки:" +I18N[install_lite_title]="⚡ Lite — маскировка под популярный сайт" +I18N[install_lite_desc1]="Быстро, без домена. telemt маскирует трафик" +I18N[install_lite_desc2]="под выбранный сайт (google.com и т.д.)" +I18N[install_pro_title]="🛡 Pro — свой сайт + полная маскировка" +I18N[install_pro_desc1]="nginx + SSL + HTML-шаблон + telemt." +I18N[install_pro_desc2]="DPI видит реальный сайт с реальным сертификатом." +I18N[install_pro_desc3]="Требует: домен, направленный на этот сервер." +I18N[install_mode_choice]="Выбор (1/2):" +I18N[install_bad_choice]="Неверный выбор: %s" +I18N[install_lite_step]="Установка Lite-режима" +I18N[install_pro_step]="Установка Pro-режима" +I18N[install_enter_domain]="Введите ваш домен (например, example.com):" +I18N[install_bad_domain]="Некорректный домен: %s" +I18N[install_dns_mismatch]="Домен %s указывает на %s, а не на %s" +I18N[install_continue_anyway]="Продолжить всё равно?" +I18N[install_enter_email]="Email для SSL (Enter = без email):" +I18N[install_config_title]="📋 Конфигурация:" +I18N[install_cfg_ip]="IP:" +I18N[install_cfg_port]="Порт:" +I18N[install_cfg_mask]="Маскировка:" +I18N[install_cfg_mode]="Режим:" +I18N[install_cfg_domain]="Домен:" +I18N[install_confirm_proxy]="Установить прокси?" +I18N[install_confirm_proxy_site]="Установить прокси + сайт?" +I18N[install_done]="goTelegram Pro v%s установлен! (%s-режим)" +I18N[install_arch_desc1]="telemt принимает весь трафик на 443 (маскировка под HTTPS)" +I18N[install_arch_desc2]="nginx обслуживает сайт на внутреннем порту %s" +I18N[install_arch_desc3]="Провайдер видит только HTTPS-трафик к %s:443" + +# ── Change mode/template ──────────────────────────────────────────────── +I18N[change_current_mode]="Текущий режим:" +I18N[change_template]="Сменить шаблон сайта (только pro)" +I18N[change_mode_switch]="Переключить режим (lite ↔ pro)" +I18N[change_only_pro]="Смена шаблона доступна только в pro-режиме" +I18N[change_requires_reinstall]="Переключение режима требует переустановки." +I18N[change_reinstall_confirm]="Переустановить прокси?" + +# ── Logs ──────────────────────────────────────────────────────────────── +I18N[logs_telemt_title]="📋 Логи telemt (последние %s строк):" + +# ── Link / Share ──────────────────────────────────────────────────────── +I18N[link_title]="🔗 Ссылка для подключения:" +I18N[share_title]="📤 Перешлите это сообщение:" +I18N[share_line1]="🔐 MTProxy для Telegram (goTelegram Pro v%s)" +I18N[share_server]="🌍 Сервер: %s" +I18N[share_port]="🔌 Порт: %s" +I18N[share_connect_cta]="👉 Подключиться одним нажатием:" +I18N[share_footer]="Просто нажмите на ссылку или настройте вручную." + +# ── Website ───────────────────────────────────────────────────────────── +I18N[website_title]="🌐 Управление сайтом" +I18N[website_domain]="Домен:" +I18N[website_ssl_until]="SSL до:" +I18N[website_only_pro]="Управление сайтом доступно только в pro-режиме" +I18N[website_renew_ssl]="Обновить SSL сертификат" +I18N[website_restart_nginx]="Перезапустить nginx" +I18N[website_change_template]="Сменить шаблон" + +# ── Remove ────────────────────────────────────────────────────────────── +I18N[remove_title]="🗑 Удаление goTelegram Pro" +I18N[remove_proxy_only]="Удалить только прокси (telemt)" +I18N[remove_bot_only]="Удалить только Telegram-бота" +I18N[remove_all]="Удалить всё (прокси + бот + настройки)" +I18N[remove_warn_proxy]="Это удалит прокси и все его настройки." +I18N[remove_confirm_proxy]="Удалить прокси?" +I18N[remove_backup_before]="Сделать бекап перед удалением?" +I18N[remove_warn_all]="Это удалит ВСЁ: прокси, бот, сайт, настройки." +I18N[remove_confirm_all]="Вы точно уверены?" +I18N[remove_proxy_done]="Прокси удалён" +I18N[remove_all_done]="goTelegram Pro полностью удалён (прокси + бот)" + +# ── Telegram bot submenu ──────────────────────────────────────────────── +I18N[bot_title]="🤖 Telegram-бот" +I18N[bot_status_running]="● Работает" +I18N[bot_status_stopped]="○ Остановлен" +I18N[bot_status_not_installed]="✗ Не установлен" +I18N[bot_menu_status]="📊 Статус бота" +I18N[bot_menu_logs]="📋 Логи бота" +I18N[bot_menu_restart]="🔄 Перезапустить бота" +I18N[bot_menu_stop]="⏹ Остановить бота" +I18N[bot_menu_start]="▶️ Запустить бота" +I18N[bot_menu_settings]="⚙️ Настройки (.env)" +I18N[bot_menu_remove]="🗑 Удалить бота" +I18N[bot_menu_install]="🔧 Установить бота" +I18N[bot_intro1]="Бот позволяет управлять прокси прямо из Telegram:" +I18N[bot_intro2]="статус, перезапуск, смена режима, бекап, QR-код." +I18N[bot_install_step]="Установка Telegram-бота" +I18N[bot_install_python]="Установка Python3..." +I18N[bot_files_not_found]="Файлы бота не найдены в %s" +I18N[bot_create_venv]="Создание виртуального окружения..." +I18N[bot_install_deps]="Установка зависимостей..." +I18N[bot_enter_token]="Введите BOT_TOKEN от @BotFather:" +I18N[bot_token_empty]="Токен не может быть пустым" +I18N[bot_token]="Token:" +I18N[bot_add_admin_how]="Как добавить администратора?" +I18N[bot_admin_auto]="Автоматически — бот определит ID при первом /start" +I18N[bot_admin_manual]="Вручную — ввести ID сейчас" +I18N[bot_admin_ids_prompt]="ID администраторов (через пробел/запятую):" +I18N[bot_env_created]=".env создан" +I18N[bot_env_exists]=".env уже существует, настройки сохранены" +I18N[bot_wait_admin_title]="Ожидание администратора" +I18N[bot_wait_admin_msg1]="Откройте бота в Telegram и отправьте" +I18N[bot_wait_admin_msg2]="Бот автоматически назначит вас администратором" +I18N[bot_wait_admin_skip]="Нажмите Ctrl+C чтобы пропустить" +I18N[bot_wait_spinner]="Ожидание... напишите /start боту (%d сек)" +I18N[bot_admin_assigned]="Администратор назначен!" +I18N[bot_wait_skipped]="Пропущено. Добавить админа позже: меню → Telegram-бот → Настройки" +I18N[bot_wait_timeout]="Таймаут (5 мин). Добавить админа: меню → Telegram-бот → Настройки" +I18N[bot_installed]="Бот установлен и запущен!" +I18N[bot_status_title]="📊 Статус Telegram-бота" +I18N[bot_token_configured]="настроен" +I18N[bot_access_open]="все пользователи" +I18N[bot_logs_title]="📋 Логи бота (последние 30 строк):" +I18N[bot_settings_title]="⚙️ Настройки бота" +I18N[bot_current_env]="Текущий .env:" +I18N[bot_change_token]="Сменить BOT_TOKEN" +I18N[bot_change_allowed]="Изменить ALLOWED_IDS" +I18N[bot_new_token]="Новый BOT_TOKEN:" +I18N[bot_token_empty_err]="Пустой токен" +I18N[bot_token_updated]="Токен обновлён, бот перезапущен" +I18N[bot_allowed_prompt]="ALLOWED_IDS (через пробел/запятую, пусто = авто):" +I18N[bot_access_updated]="Доступ обновлён, бот перезапущен" +I18N[bot_remove_warn]="Это удалит Telegram-бота и все его настройки." +I18N[bot_remove_confirm]="Удалить бота?" +I18N[bot_removed]="Бот полностью удалён" +I18N[bot_restarted]="Бот перезапущен" +I18N[bot_stopped]="Бот остановлен" +I18N[bot_started]="Бот запущен" +I18N[bot_status_colon]="Статус:" +I18N[bot_access_colon]="Доступ:" +I18N[bot_access_ids_fmt]="ID: %s" + +# ── Promo / Donate ────────────────────────────────────────────────────── +I18N[promo_host1_title]="💰 ХОСТИНГ #1 — СКИДКА ДО 60%" +I18N[promo_host2_title]="💰 ХОСТИНГ #2 — СКИДКА ДО 60%" +I18N[promo_tips_title]="☕ Донат / Чаевые" +I18N[promo_youtube_title]="▶ YouTube-канал" +I18N[promo_link_label]="Ссылка:" +I18N[promo_off60]="60%% скидки на первый месяц" +I18N[promo_ant20]="20%% + 3%% при оплате за 3 месяца" +I18N[promo_ant6]="15%% + 5%% при оплате за 6 месяцев" +I18N[promo_qr_host1]="── QR: Хостинг #1 ──" +I18N[promo_qr_host2]="── QR: Хостинг #2 ──" +I18N[promo_qr_tips]="── QR: Чаевые / Донат ──" +I18N[promo_qr_youtube]="── QR: YouTube-канал ──" +I18N[promo_menu_in]="Меню через %d сек..." + +# ── Stats ─────────────────────────────────────────────────────────────── +I18N[stats_title]="📊 Статистика трафика" +I18N[stats_module_missing]="Модуль статистики не загружен." +I18N[stats_file_missing]="Файл lib/stats.sh не найден." +I18N[stats_toggle]="Вкл/Выкл подсчёт (сейчас: %s)" +I18N[stats_install_collector]="Установить/обновить сборщик статистики" +I18N[stats_auto_refresh]="Обновление каждые 3 сек" +I18N[stats_on]="вкл" +I18N[stats_off]="выкл" + +# ── Templates catalog ─────────────────────────────────────────────────── +I18N[templates_categories]="📂 Категории шаблонов сайтов:" +I18N[templates_custom_git]="📎 Свой шаблон по git URL" +I18N[templates_random]="🎲 Случайный шаблон" +I18N[templates_count_fmt]="(%d шаблонов)" +I18N[templates_list]="📋 %s — доступные шаблоны:" +I18N[templates_preview_title]="🔍 Превью шаблона:" +I18N[templates_name]="Название:" +I18N[templates_source]="Источник:" +I18N[templates_description]="Описание:" +I18N[templates_preview]="👁 Превью:" +I18N[templates_preview_hint]="Откройте ссылку в браузере для просмотра шаблона" +I18N[templates_repo]="📦 Репо:" +I18N[templates_thanks]="💜 Спасибо авторам %s за открытый код!" +I18N[templates_install_this]="Установить этот шаблон?" +I18N[templates_cat_empty]="В этой категории нет шаблонов" +I18N[templates_downloading]="Скачивание шаблона \"%s\"..." +I18N[templates_downloaded]="Шаблон \"%s\" скачан" +I18N[templates_downloaded_subfolder]="Шаблон \"%s\" скачан (из подпапки)" +I18N[templates_no_index]="Шаблон не содержит index.html" +I18N[templates_path]="Путь: %s" +I18N[templates_catalog_not_found]="Каталог шаблонов не найден: %s" + +# ── Custom git template ───────────────────────────────────────────────── +I18N[custom_git_title]="📎 СВОЙ ШАБЛОН ПО GIT URL" +I18N[custom_git_help_1]="Вы можете использовать ЛЮБОЙ репозиторий со статическим HTML-сайтом" +I18N[custom_git_help_2]="в качестве шаблона. Репозиторий должен быть публичным и содержать" +I18N[custom_git_help_3]="готовый index.html (сборка через npm НЕ выполняется)." +I18N[custom_git_formats]="Поддерживаемые форматы URL:" +I18N[custom_git_fmt_github]=" • https://github.com/user/repo" +I18N[custom_git_fmt_gitlab]=" • https://gitlab.com/user/repo" +I18N[custom_git_fmt_gitext]=" • https://example.com/user/repo.git" +I18N[custom_git_fmt_branch]=" • https://github.com/user/repo@branch (ветка после @)" +I18N[custom_git_auto_detect]="Структура репозитория (авто-определение):" +I18N[custom_git_auto_1]=" 1. index.html в корне репозитория" +I18N[custom_git_auto_2]=" 2. dist/index.html (StartBootstrap, Vite, webpack)" +I18N[custom_git_auto_3]=" 3. public/ или build/ или _site/ или site/ или docs/" +I18N[custom_git_auto_4]=" 4. Fallback: поиск index.html по всему репозиторию" +I18N[custom_git_requirements]="Требования:" +I18N[custom_git_req_1]=" • Только HTTPS (ssh:// и git:// блокируются)" +I18N[custom_git_req_2]=" • Только публичные репозитории" +I18N[custom_git_req_3]=" • Размер репо не более 100 МБ" +I18N[custom_git_req_4]=" • Статический HTML (без серверного кода PHP/Python/Node)" +I18N[custom_git_examples]="Примеры проверенных репо:" +I18N[custom_git_ex_1]=" • https://github.com/html5up-collective/strata" +I18N[custom_git_ex_2]=" • https://github.com/StartBootstrap/startbootstrap-landing-page" +I18N[custom_git_enter_url]="Вставьте git URL (или Enter для отмены):" +I18N[custom_git_empty]="URL не указан, отмена" +I18N[custom_git_bad_url]="Недопустимый URL. Принимаются только https:// адреса" +I18N[custom_git_cloning]="Клонирование репозитория..." +I18N[custom_git_clone_failed]="Не удалось клонировать репозиторий: %s" +I18N[custom_git_too_big]="Репозиторий слишком большой: %s (лимит 100MB)" +I18N[custom_git_scanning]="Поиск index.html в структуре..." +I18N[custom_git_found_at]="✓ Найден index.html в: %s" +I18N[custom_git_no_index]="index.html не найден в репозитории" +I18N[custom_git_installed]="Свой шаблон установлен из %s" +I18N[custom_git_saved]="URL шаблона сохранён в конфиге (меню → Сайт → Обновить из git)" + +# ── First-run language picker ─────────────────────────────────────────── +I18N[lang_picker_title]="Выберите язык / Select language" +I18N[lang_english]="English" +I18N[lang_russian]="Русский" +I18N[lang_saved]="Язык сохранён: %s" +I18N[lang_change_prompt]="Выберите новый язык:" + +# ── Backup ────────────────────────────────────────────────────────────── +I18N[backup_title]="💾 Бекап" +I18N[backup_creating]="Создание бекапа..." +I18N[backup_created]="Бекап создан: %s" +I18N[backup_failed]="Ошибка создания бекапа" +I18N[backup_restore_title]="↩️ Восстановление из бекапа" +I18N[backup_no_files]="Нет файлов бекапа" +I18N[backup_select]="Выберите бекап для восстановления:" +I18N[backup_restoring]="Восстановление..." +I18N[backup_restored]="Бекап восстановлен" +I18N[backup_collecting]="Собираю конфигурацию..." +I18N[backup_site_included]="Шаблон сайта включён" +I18N[backup_archive_err]="Ошибка создания архива" +I18N[backup_archive_missing]="Архив не создан" +I18N[backup_encrypt_err]="Ошибка шифрования" +I18N[backup_encrypted]="Бекап зашифрован (AES-256-CBC)" +I18N[backup_created_fmt]="Бекап создан: %s (%s)" +I18N[backup_file_not_found_fmt]="Файл не найден: %s" +I18N[backup_enter_pass]="Введите пароль от бекапа" +I18N[backup_bad_pass]="Неверный пароль или повреждённый файл" +I18N[backup_extract_err]="Ошибка распаковки архива" +I18N[backup_label]="Бекап" +I18N[backup_version_label]="Версия" +I18N[backup_mode_label]="Режим" +I18N[backup_lang_label]="Язык" +I18N[backup_date_label]="Дата" +I18N[backup_confirm_restore]="Восстановить конфигурацию? Текущие настройки будут перезаписаны." +I18N[backup_restored_telemt]="telemt конфиг восстановлен" +I18N[backup_restored_gotelegram]="goTelegram Pro конфиг восстановлен" +I18N[backup_restored_lang]="Язык интерфейса восстановлен" +I18N[backup_restored_nginx]="nginx конфиг восстановлен" +I18N[backup_restored_ssl]="SSL сертификаты восстановлены" +I18N[backup_restored_site]="Шаблон сайта восстановлен" +I18N[backup_restore_done]="Восстановление завершено!" +I18N[backup_none]="Бекапов нет" +I18N[backup_list_title]="Доступные бекапы" +I18N[backup_cleanup_fmt]="Удалено %s старых бекапов (оставлено %s)" +I18N[backup_create_title]="Создание бекапа" +I18N[backup_encrypt_prompt]="Зашифровать бекап паролем?" +I18N[backup_repeat_pass]="Повторите пароль" +I18N[backup_pass_mismatch]="Пароли не совпадают" +I18N[backup_pass_short]="Пароль слишком короткий (минимум 6 символов)" +I18N[backup_pick_prompt]="Номер бекапа (или путь к файлу)" +I18N[backup_not_found]="Бекап не найден" + +# ── Errors / misc ─────────────────────────────────────────────────────── +I18N[err_need_root]="Запустите скрипт с sudo / от root" +I18N[err_os_unknown]="Не удалось определить ОС. Требуется Linux." +I18N[err_low_disk]="Мало места на диске: %sMB (нужно %sMB+)" +I18N[err_bad_pkg_mgr]="Неизвестный пакетный менеджер" +I18N[err_unexpected]="Неожиданная ошибка" +I18N[bye]="До встречи! 👋" +I18N[auto_refresh]="Обновление через 30 сек" + +# ── Deps ──────────────────────────────────────────────────────────────── +I18N[deps_installing]="Установка зависимостей: %s" + +# ── Migration ─────────────────────────────────────────────────────────── +I18N[v1_detected]="⚠️ Обнаружена установка goTelegram Pro v1 (mtg)" +I18N[v1_container]="Контейнер: %s" +I18N[v1_migration_step]="Миграция с v1 (mtg) на v2 (telemt)" +I18N[v1_found_title]="Найдена установка v1 (mtg):" +I18N[v1_port]="Порт: %s" +I18N[v1_secret]="Secret: %s..." +I18N[v1_incompatible]="секрет mtg НЕ совместим с telemt напрямую." +I18N[v1_new_link]="Клиентам потребуется новая ссылка." +I18N[v1_stop_migrate]="Остановить v1 контейнер и перейти на v2? [Y/n]:" +I18N[v1_migration_cancelled]="Миграция отменена. v1 оставлен без изменений." +I18N[v1_stopping]="Остановка v1 контейнера..." +I18N[v1_config_saved]="Конфиг v1 сохранён в %s" +I18N[v1_port_freed]="v1 остановлен. Порт %s освобождён." diff --git a/lib/stats.sh b/lib/stats.sh new file mode 100644 index 0000000..2332d7d --- /dev/null +++ b/lib/stats.sh @@ -0,0 +1,594 @@ +#!/bin/bash +# stats.sh — Traffic statistics module for GoTelegram v2.5.0 +# Tracks proxy (telemt port 443) and site (nginx port 8443) traffic +# Uses iptables counters + real-time snapshots + historical CSV + +# Color codes (from common.sh) +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +STATS_DIR="/run/gotelegram" +HISTORY_FILE="/opt/gotelegram/stats_history.csv" +USER_HISTORY_FILE="/opt/gotelegram/user_stats_history.csv" +SNAPSHOTS_DIR="$STATS_DIR/snapshots" +CURRENT_SNAPSHOT="$STATS_DIR/stats_current.json" +CONFIG_FILE="/opt/gotelegram/config.json" +TELEMT_CONFIG_FILE="/etc/telemt/config.toml" +STATS_RETENTION_DAYS="${STATS_RETENTION_DAYS:-365}" +STATS_MINUTE_RETENTION_DAYS="${STATS_MINUTE_RETENTION_DAYS:-31}" +STATS_CLEANUP_INTERVAL="${STATS_CLEANUP_INTERVAL:-3600}" +STATS_CLEANUP_STAMP="$STATS_DIR/last_history_cleanup" +USER_STATS_COLLECT_STAMP="$STATS_DIR/last_user_stats_minute" + +# Initialize stats infrastructure +stats_init() { + if ! command -v iptables &>/dev/null; then + log_warning "iptables не найден: установите пакет iptables или запустите установку зависимостей" + return 1 + fi + + # Create runtime directory + mkdir -p "$STATS_DIR" "$SNAPSHOTS_DIR" 2>/dev/null + chmod 755 "$STATS_DIR" "$SNAPSHOTS_DIR" 2>/dev/null + + # Create iptables chain if not exists + if ! iptables -L GOTELEGRAM_STATS -n >/dev/null 2>&1; then + iptables -N GOTELEGRAM_STATS 2>/dev/null + fi + + # Add chain to INPUT if not already present + if ! iptables -C INPUT -j GOTELEGRAM_STATS 2>/dev/null; then + iptables -I INPUT -j GOTELEGRAM_STATS 2>/dev/null + fi + + # Add rule for proxy traffic (port 443, TCP) + if ! iptables -C GOTELEGRAM_STATS -p tcp --dport 443 2>/dev/null; then + iptables -A GOTELEGRAM_STATS -p tcp --dport 443 2>/dev/null + fi + + # Add rule for site traffic (loopback, port 8443, TCP) + if ! iptables -C GOTELEGRAM_STATS -i lo -p tcp --dport 8443 2>/dev/null; then + iptables -A GOTELEGRAM_STATS -i lo -p tcp --dport 8443 2>/dev/null + fi + + # Initialize CSV header if file doesn't exist + if [[ ! -f "$HISTORY_FILE" ]]; then + echo "epoch,proxy_bytes,site_bytes" > "$HISTORY_FILE" 2>/dev/null + fi + if [[ ! -f "$USER_HISTORY_FILE" ]]; then + echo "epoch,user,total_octets,current_connections,active_unique_ips,recent_unique_ips" > "$USER_HISTORY_FILE" 2>/dev/null + fi + + # Write initial snapshot + stats_collect +} + +# Collect current traffic statistics from iptables +stats_collect() { + local proxy_bytes=0 proxy_pkts=0 site_bytes=0 site_pkts=0 + local ts=$(date +%s) + local temp_file=$(mktemp) + + if ! command -v iptables &>/dev/null; then + mkdir -p "$STATS_DIR" 2>/dev/null + echo "{\"ts\":$ts,\"proxy_bytes\":0,\"proxy_pkts\":0,\"site_bytes\":0,\"site_pkts\":0,\"error\":\"iptables_missing\"}" > "$CURRENT_SNAPSHOT" 2>/dev/null + rm -f "$temp_file" 2>/dev/null + return 1 + fi + + # Parse iptables output: format is "pkts bytes target" + # We need to extract bytes (2nd column) for each rule + local iptables_output=$(iptables -L GOTELEGRAM_STATS -v -n -x 2>/dev/null) + + # Extract counters for port 443 (proxy) + proxy_bytes=$(echo "$iptables_output" | grep "dpt:443" | grep -v "lo" | awk '{print $2}') + proxy_pkts=$(echo "$iptables_output" | grep "dpt:443" | grep -v "lo" | awk '{print $1}') + + # Extract counters for port 8443 on loopback (site) + site_bytes=$(echo "$iptables_output" | grep "dpt:8443" | awk '{print $2}') + site_pkts=$(echo "$iptables_output" | grep "dpt:8443" | awk '{print $1}') + + # Default to 0 if not found + proxy_bytes=${proxy_bytes:-0} + proxy_pkts=${proxy_pkts:-0} + site_bytes=${site_bytes:-0} + site_pkts=${site_pkts:-0} + + # Write current snapshot as JSON + if command -v jq &>/dev/null; then + echo "{\"ts\":$ts,\"proxy_bytes\":$proxy_bytes,\"proxy_pkts\":$proxy_pkts,\"site_bytes\":$site_bytes,\"site_pkts\":$site_pkts}" > "$CURRENT_SNAPSHOT" 2>/dev/null + else + cat > "$CURRENT_SNAPSHOT" 2>/dev/null </dev/null) + local snapshot_file="$SNAPSHOTS_DIR/snap_${minute_key}.json" + cp "$CURRENT_SNAPSHOT" "$snapshot_file" 2>/dev/null + + # Append to history CSV (once per minute, check if last entry is fresh) + # Auto-recreate the file with header if it was deleted — otherwise the + # collector would silently stop writing history after any wipe (v2.4.1 fix). + if [[ ! -f "$HISTORY_FILE" ]]; then + mkdir -p "$(dirname "$HISTORY_FILE")" 2>/dev/null + echo "epoch,proxy_bytes,site_bytes" > "$HISTORY_FILE" 2>/dev/null + fi + + if [[ -f "$HISTORY_FILE" ]]; then + local last_ts + last_ts=$(grep -E '^[0-9]' "$HISTORY_FILE" 2>/dev/null | tail -1 | cut -d, -f1) + last_ts="${last_ts:-0}" + local current_minute=$((ts - (ts % 60))) + + if [[ "$last_ts" -eq 0 ]] || [[ $((current_minute - last_ts)) -ge 60 ]]; then + echo "$current_minute,$proxy_bytes,$site_bytes" >> "$HISTORY_FILE" 2>/dev/null + + # Cleanup/compact history at most once per hour. + stats_cleanup_history + fi + fi + + stats_collect_users "$ts" + + rm -f "$temp_file" 2>/dev/null +} + +# Print active telemt usernames from [access.users]. Usernames are restricted by +# goTelegram to A-Z/a-z/0-9/_.- so they are safe in URLs and CSV fields. +stats_active_users() { + [[ -f "$TELEMT_CONFIG_FILE" ]] || return 0 + awk ' + /^\[access\.users\]$/ { in_users=1; next } + in_users && /^\[/ { exit } + in_users && /^[[:space:]]*#/ { next } + in_users && /=/ { + key=$1 + gsub(/^[[:space:]]+|[[:space:]]+$/, "", key) + gsub(/^"|"$/, "", key) + if (key ~ /^[A-Za-z0-9_.-]{1,48}$/) print key + } + ' "$TELEMT_CONFIG_FILE" 2>/dev/null +} + +stats_collect_users() { + local ts="${1:-$(date +%s)}" + local current_minute=$((ts - (ts % 60))) + + mkdir -p "$(dirname "$USER_HISTORY_FILE")" 2>/dev/null + if [[ ! -f "$USER_HISTORY_FILE" ]]; then + echo "epoch,user,total_octets,current_connections,active_unique_ips,recent_unique_ips" > "$USER_HISTORY_FILE" 2>/dev/null + fi + + command -v curl &>/dev/null || return 0 + command -v jq &>/dev/null || return 0 + + if [[ -f "$USER_STATS_COLLECT_STAMP" ]] && [[ "$(cat "$USER_STATS_COLLECT_STAMP" 2>/dev/null)" == "$current_minute" ]]; then + return 0 + fi + + local existing_users="" + existing_users=$(awk -F, -v ts="$current_minute" '$1 == ts { print $2 }' "$USER_HISTORY_FILE" 2>/dev/null || true) + + local user payload total conns active_ips recent_ips + while IFS= read -r user; do + [[ -n "$user" ]] || continue + if printf '%s\n' "$existing_users" | grep -Fxq "$user"; then + continue + fi + + payload=$(curl -sS --max-time 2 "http://127.0.0.1:9091/v1/users/${user}" 2>/dev/null || true) + [[ -n "$payload" ]] || continue + total=$(echo "$payload" | jq -r '.data.total_octets // .total_octets // 0' 2>/dev/null) + conns=$(echo "$payload" | jq -r '.data.current_connections // .current_connections // 0' 2>/dev/null) + active_ips=$(echo "$payload" | jq -r '.data.active_unique_ips // .active_unique_ips // 0' 2>/dev/null) + recent_ips=$(echo "$payload" | jq -r '.data.recent_unique_ips // .recent_unique_ips // 0' 2>/dev/null) + [[ "$total" =~ ^[0-9]+$ ]] || total=0 + [[ "$conns" =~ ^[0-9]+$ ]] || conns=0 + [[ "$active_ips" =~ ^[0-9]+$ ]] || active_ips=0 + [[ "$recent_ips" =~ ^[0-9]+$ ]] || recent_ips=0 + echo "$current_minute,$user,$total,$conns,$active_ips,$recent_ips" >> "$USER_HISTORY_FILE" 2>/dev/null + done < <(stats_active_users) + + echo "$current_minute" > "$USER_STATS_COLLECT_STAMP" 2>/dev/null || true + stats_cleanup_user_history +} + +# Read current snapshot as JSON +stats_read_current() { + if [[ -f "$CURRENT_SNAPSHOT" ]]; then + cat "$CURRENT_SNAPSHOT" + else + echo "{}" + fi +} + +# Extract value from JSON (fallback if jq not available) +json_get() { + local json="$1" + local key="$2" + + if command -v jq &>/dev/null; then + echo "$json" | jq -r ".${key}" 2>/dev/null || echo "0" + else + echo "$json" | grep -o "\"$key\":[^,}]*" | cut -d: -f2 | tr -d ' "' || echo "0" + fi +} + +# Convert bytes to human-readable format +format_bytes() { + local bytes=$1 + + if (( bytes < 1024 )); then + printf "%.0f B" "$bytes" + elif (( bytes < 1024 * 1024 )); then + printf "%.1f KB" "$(echo "scale=1; $bytes / 1024" | bc 2>/dev/null || echo "$((bytes / 1024))")" + elif (( bytes < 1024 * 1024 * 1024 )); then + printf "%.1f MB" "$(echo "scale=1; $bytes / 1024 / 1024" | bc 2>/dev/null || echo "$((bytes / 1024 / 1024))")" + elif (( bytes < 1024 * 1024 * 1024 * 1024 )); then + printf "%.1f GB" "$(echo "scale=1; $bytes / 1024 / 1024 / 1024" | bc 2>/dev/null || echo "$((bytes / 1024 / 1024 / 1024))")" + else + printf "%.1f TB" "$(echo "scale=1; $bytes / 1024 / 1024 / 1024 / 1024" | bc 2>/dev/null || echo "$((bytes / 1024 / 1024 / 1024 / 1024))")" + fi +} + +# Convert bytes/sec to human-readable rate +format_rate() { + local bytes_per_sec=$1 + + if (( bytes_per_sec < 1024 )); then + printf "%.0f B/s" "$bytes_per_sec" + elif (( bytes_per_sec < 1024 * 1024 )); then + printf "%.1f KB/s" "$(echo "scale=1; $bytes_per_sec / 1024" | bc 2>/dev/null || echo "$((bytes_per_sec / 1024))")" + elif (( bytes_per_sec < 1024 * 1024 * 1024 )); then + printf "%.1f MB/s" "$(echo "scale=1; $bytes_per_sec / 1024 / 1024" | bc 2>/dev/null || echo "$((bytes_per_sec / 1024 / 1024))")" + else + printf "%.1f GB/s" "$(echo "scale=1; $bytes_per_sec / 1024 / 1024 / 1024" | bc 2>/dev/null || echo "$((bytes_per_sec / 1024 / 1024 / 1024))")" + fi +} + +# Safely convert value to integer (returns 0 for empty/non-numeric) +_to_int() { + local val="${1:-0}" + # Strip non-numeric chars, default to 0 + val="${val//[^0-9]/}" + echo "${val:-0}" +} + +# Calculate diff safely (never negative, never crashes on empty) +_safe_diff() { + local a=$(_to_int "$1") + local b=$(_to_int "$2") + local d=$((a - b)) + (( d < 0 )) && d=0 + echo "$d" +} + +# Calculate traffic rates and totals from history +stats_calculate_rates() { + local traffic_type="$1" # "proxy" or "site" + local col_idx=2 # proxy_bytes is column 2 + [[ "$traffic_type" == "site" ]] && col_idx=3 + + local now + now=$(date +%s) + + # Get latest data line (skip header with grep -E '^[0-9]') + local bytes_now + bytes_now=$(_to_int "$(grep -E '^[0-9]' "$HISTORY_FILE" 2>/dev/null | tail -1 | cut -d, -f"$col_idx")") + + local periods="60 300 3600 86400 604800 2592000 31536000" + local results="" + + for secs in $periods; do + local target_ts=$((now - secs)) + # Find closest entry at or after target timestamp (skip header) + local old_val + old_val=$(_to_int "$(awk -F, -v ts="$target_ts" '$1 ~ /^[0-9]/ && $1 <= ts' "$HISTORY_FILE" 2>/dev/null | tail -1 | cut -d, -f"$col_idx")") + + local diff + diff=$(_safe_diff "$bytes_now" "$old_val") + local rate=$(( secs > 0 ? diff / secs : 0 )) + + local bytes_fmt rate_fmt + bytes_fmt=$(format_bytes "$diff") + rate_fmt=$(format_rate "$rate") + + if [ -z "$results" ]; then + results="${bytes_fmt}|${rate_fmt}" + else + results="${results}|${bytes_fmt}|${rate_fmt}" + fi + done + + echo "$results" +} + +# Main display function for traffic statistics +show_traffic_stats() { + # Ensure stats are collected + stats_collect + + # Get current counters + local current_json=$(stats_read_current) + local proxy_pkts=$(json_get "$current_json" "proxy_pkts") + local site_pkts=$(json_get "$current_json" "site_pkts") + + # Calculate rates for proxy + local proxy_rates=$(stats_calculate_rates "proxy") + IFS='|' read -r p1m p1mr p5m p5mr p60m p60mr p1d p1dr p7d p7dr p30d p30dr p365d p365dr <<< "$proxy_rates" + + # Calculate rates for site + local site_rates=$(stats_calculate_rates "site") + IFS='|' read -r s1m s1mr s5m s5mr s60m s60mr s1d s1dr s7d s7dr s30d s30dr s365d s365dr <<< "$site_rates" + + # Display proxy stats + { + echo "" + echo -e "${BLUE} Proxy (telemt, порт 443):${NC}" + echo -e "${BLUE} ─────────────────────────────────────────${NC}" + echo -e "${BLUE} Период │ Входящий │ Скорость${NC}" + echo -e "${BLUE} ─────────────────────────────────────────${NC}" + printf " %-9s │ %14s │ %s\n" "1 мин" "$p1m" "$p1mr" + printf " %-9s │ %14s │ %s\n" "5 мин" "$p5m" "$p5mr" + printf " %-9s │ %14s │ %s\n" "60 мин" "$p60m" "$p60mr" + printf " %-9s │ %14s │ %s\n" "1 день" "$p1d" "$p1dr" + printf " %-9s │ %14s │ %s\n" "7 дней" "$p7d" "$p7dr" + printf " %-9s │ %14s │ %s\n" "30 дней" "$p30d" "$p30dr" + printf " %-9s │ %14s │ %s\n" "365 дней" "$p365d" "$p365dr" + echo -e "${BLUE} ─────────────────────────────────────────${NC}" + printf " Пакетов: %d\n\n" "$proxy_pkts" + + echo -e "${BLUE} Сайт (nginx, порт 8443):${NC}" + echo -e "${BLUE} ─────────────────────────────────────────${NC}" + echo -e "${BLUE} Период │ Входящий │ Скорость${NC}" + echo -e "${BLUE} ─────────────────────────────────────────${NC}" + printf " %-9s │ %14s │ %s\n" "1 мин" "$s1m" "$s1mr" + printf " %-9s │ %14s │ %s\n" "5 мин" "$s5m" "$s5mr" + printf " %-9s │ %14s │ %s\n" "60 мин" "$s60m" "$s60mr" + printf " %-9s │ %14s │ %s\n" "1 день" "$s1d" "$s1dr" + printf " %-9s │ %14s │ %s\n" "7 дней" "$s7d" "$s7dr" + printf " %-9s │ %14s │ %s\n" "30 дней" "$s30d" "$s30dr" + printf " %-9s │ %14s │ %s\n" "365 дней" "$s365d" "$s365dr" + echo -e "${BLUE} ─────────────────────────────────────────${NC}" + printf " Пакетов: %d\n" "$site_pkts" + echo "" + } >&2 +} + +_stats_positive_int() { + local value="${1:-0}" + [[ "$value" =~ ^[0-9]+$ ]] && [[ "$value" -gt 0 ]] && echo "$value" || echo "$2" +} + +stats_should_cleanup() { + local stamp="$1" + local now last interval + mkdir -p "$STATS_DIR" 2>/dev/null || true + now=$(date +%s) + interval=$(_stats_positive_int "$STATS_CLEANUP_INTERVAL" 3600) + last=$(cat "$stamp" 2>/dev/null || echo 0) + [[ "$last" =~ ^[0-9]+$ ]] || last=0 + if (( now - last < interval )); then + return 1 + fi + echo "$now" > "$stamp" 2>/dev/null || true + return 0 +} + +stats_retention_cutoffs() { + local now retention_days minute_days + now=$(date +%s) + retention_days=$(_stats_positive_int "$STATS_RETENTION_DAYS" 365) + minute_days=$(_stats_positive_int "$STATS_MINUTE_RETENTION_DAYS" 31) + if (( minute_days > retention_days )); then + minute_days="$retention_days" + fi + echo "$((now - retention_days * 86400)) $((now - minute_days * 86400))" +} + +# Keep history for at most one year. Recent points stay per-minute; older +# points are compacted to one last cumulative snapshot per hour. +stats_cleanup_history() { + if [[ ! -f "$HISTORY_FILE" ]]; then + return + fi + + stats_should_cleanup "$STATS_CLEANUP_STAMP" || return 0 + + local retention_cutoff minute_cutoff temp_file + read -r retention_cutoff minute_cutoff <<< "$(stats_retention_cutoffs)" + temp_file=$(mktemp) + + { + head -1 "$HISTORY_FILE" + awk -F, -v keep="$retention_cutoff" -v minute="$minute_cutoff" ' + BEGIN { OFS="," } + NR == 1 { next } + $1 !~ /^[0-9]+$/ { next } + $1 < keep { next } + $1 >= minute { print $1, $2, $3; next } + { + bucket = int($1 / 3600) + compact[bucket] = $1 OFS $2 OFS $3 + } + END { + for (bucket in compact) print compact[bucket] + } + ' "$HISTORY_FILE" | sort -t, -k1,1n + } > "$temp_file" 2>/dev/null + + mv "$temp_file" "$HISTORY_FILE" 2>/dev/null +} + +stats_cleanup_user_history() { + if [[ ! -f "$USER_HISTORY_FILE" ]]; then + return + fi + + stats_should_cleanup "${STATS_CLEANUP_STAMP}.users" || return 0 + + local retention_cutoff minute_cutoff temp_file + read -r retention_cutoff minute_cutoff <<< "$(stats_retention_cutoffs)" + temp_file=$(mktemp) + + { + head -1 "$USER_HISTORY_FILE" + awk -F, -v keep="$retention_cutoff" -v minute="$minute_cutoff" ' + BEGIN { OFS="," } + NR == 1 { next } + $1 !~ /^[0-9]+$/ { next } + $1 < keep { next } + $1 >= minute { print $1, $2, $3, $4, $5, $6; next } + { + bucket = $2 SUBSEP int($1 / 3600) + compact[bucket] = $1 OFS $2 OFS $3 OFS $4 OFS $5 OFS $6 + } + END { + for (bucket in compact) print compact[bucket] + } + ' "$USER_HISTORY_FILE" | sort -t, -k1,1n -k2,2 + } > "$temp_file" 2>/dev/null + + mv "$temp_file" "$USER_HISTORY_FILE" 2>/dev/null +} + +# Toggle stats collection on/off +toggle_stats() { + local current_state="false" + + # Read current state from config + if [[ -f "$CONFIG_FILE" ]] && command -v jq &>/dev/null; then + current_state=$(jq -r '.stats_enabled // false' "$CONFIG_FILE" 2>/dev/null) + fi + + # Toggle + if [[ "$current_state" == "true" ]]; then + # Disable stats + if [[ -f "$CONFIG_FILE" ]]; then + if command -v jq &>/dev/null; then + jq '.stats_enabled = false' "$CONFIG_FILE" > "${CONFIG_FILE}.tmp" 2>/dev/null + mv "${CONFIG_FILE}.tmp" "$CONFIG_FILE" + fi + fi + + # Remove iptables rules + iptables -D INPUT -j GOTELEGRAM_STATS 2>/dev/null + iptables -F GOTELEGRAM_STATS 2>/dev/null + iptables -X GOTELEGRAM_STATS 2>/dev/null + + # Clean up directories + rm -rf "$STATS_DIR" 2>/dev/null + + echo "Сбор статистики ОТКЛЮЧЕН" >&2 + else + # Enable stats + if [[ -f "$CONFIG_FILE" ]]; then + if command -v jq &>/dev/null; then + jq '.stats_enabled = true' "$CONFIG_FILE" > "${CONFIG_FILE}.tmp" 2>/dev/null + mv "${CONFIG_FILE}.tmp" "$CONFIG_FILE" + fi + fi + + # Initialize stats collection + stats_init + + echo "Сбор статистики ВКЛЮЧЕН" >&2 + fi +} + +# Install systemd service for stats collection +install_stats_collector() { + local service_file="/etc/systemd/system/gotelegram-stats.service" + + # Check if running as root + if [[ $EUID -ne 0 ]]; then + echo "Требуется root для установки сервиса" >&2 + return 1 + fi + + if ! command -v iptables &>/dev/null; then + log_info "Установка iptables для подсчёта трафика..." + install_pkg "$(apt_pkg_for_cmd iptables)" || { + echo "Не удалось установить iptables" >&2 + return 1 + } + fi + + # Get script directory (resolve symlinks) + local script_dir=$(dirname "$(readlink -f "${BASH_SOURCE[0]}")") + local lib_dir=$(dirname "$script_dir") + + # Create systemd service file + cat > "$service_file" <<'EOF' +[Unit] +Description=goTelegram Pro Traffic Stats Collector +After=network.target +Wants=network-online.target + +[Service] +Type=simple +User=root +ExecStart=/bin/bash -c 'source /opt/gotelegram/lib/common.sh; source /opt/gotelegram/lib/stats.sh; stats_init; while true; do stats_collect; sleep 1; done' +Restart=always +RestartSec=5 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target +EOF + + chmod 644 "$service_file" + systemctl daemon-reload + systemctl enable gotelegram-stats.service + systemctl restart gotelegram-stats.service + + if [[ -f "$CONFIG_FILE" ]] && command -v jq &>/dev/null; then + local tmp + tmp=$(mktemp) + if jq '.stats_enabled = true' "$CONFIG_FILE" > "$tmp" 2>/dev/null; then + mv "$tmp" "$CONFIG_FILE" + chmod 600 "$CONFIG_FILE" 2>/dev/null || true + else + rm -f "$tmp" 2>/dev/null + fi + fi + + echo "Сервис gotelegram-stats установлен и запущен" >&2 +} + +# Remove stats collector service +remove_stats_collector() { + if [[ $EUID -ne 0 ]]; then + echo "Требуется root для удаления сервиса" >&2 + return 1 + fi + + systemctl stop gotelegram-stats.service 2>/dev/null + systemctl disable gotelegram-stats.service 2>/dev/null + rm -f /etc/systemd/system/gotelegram-stats.service + systemctl daemon-reload + + # Remove iptables rules + iptables -D INPUT -j GOTELEGRAM_STATS 2>/dev/null + iptables -F GOTELEGRAM_STATS 2>/dev/null + iptables -X GOTELEGRAM_STATS 2>/dev/null + + # Clean up directories and files + rm -rf "$STATS_DIR" 2>/dev/null + rm -f "$HISTORY_FILE" "$USER_HISTORY_FILE" 2>/dev/null + + echo "Сервис статистики удалён" >&2 +} + +# Export functions for external use +export -f stats_init stats_collect stats_collect_users stats_active_users stats_read_current stats_calculate_rates +export -f show_traffic_stats format_bytes format_rate toggle_stats +export -f stats_cleanup_history stats_cleanup_user_history stats_should_cleanup stats_retention_cutoffs install_stats_collector remove_stats_collector +export -f json_get diff --git a/lib/telemt.sh b/lib/telemt.sh new file mode 100644 index 0000000..cb3c5fe --- /dev/null +++ b/lib/telemt.sh @@ -0,0 +1,366 @@ +#!/bin/bash +# GoTelegram v2.5.0 — Управление telemt binary +# Скачивание, обновление, запуск, остановка через systemd + +TELEMT_GITHUB="telemt/telemt" +TELEMT_RELEASE_API="https://api.github.com/repos/${TELEMT_GITHUB}/releases/latest" +TELEMT_USER="telemt" +TELEMT_GROUP="telemt" + +# ── Получение последней версии ─────────────────────────────────────────────── +get_latest_telemt_version() { + local resp + resp=$(curl -s --max-time 10 "$TELEMT_RELEASE_API" 2>/dev/null) + if [ $? -ne 0 ] || [ -z "$resp" ]; then + log_error "Не удалось получить информацию о релизах telemt" + return 1 + fi + echo "$resp" | jq -r '.tag_name // empty' +} + +get_telemt_download_url() { + local arch + arch=$(get_arch) + local resp + resp=$(curl -s --max-time 10 "$TELEMT_RELEASE_API" 2>/dev/null) + if [ -z "$resp" ]; then return 1; fi + + # URL format: telemt-x86_64-linux-gnu.tar.gz (arch BEFORE linux) + local arch_pattern + case "$arch" in + amd64) arch_pattern="(amd64|x86_64)" ;; + arm64) arch_pattern="(arm64|aarch64)" ;; + armv7) arch_pattern="(armv7|arm)" ;; + *) arch_pattern="${arch}" ;; + esac + + echo "$resp" | jq -r ".assets[].browser_download_url" 2>/dev/null \ + | grep -iE "$arch_pattern" \ + | grep -i "linux" \ + | grep -v "sha256" \ + | grep "gnu" \ + | head -1 +} + +# ── Установленная версия ───────────────────────────────────────────────────── +get_installed_telemt_version() { + if [ -x "$TELEMT_BIN" ]; then + "$TELEMT_BIN" --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 + else + echo "" + fi +} + +is_telemt_installed() { + [ -x "$TELEMT_BIN" ] +} + +# ── Скачивание и установка ─────────────────────────────────────────────────── +download_telemt() { + local url + url=$(get_telemt_download_url) + if [ -z "$url" ]; then + log_error "Не найден бинарник telemt для архитектуры $(get_arch)" + return 1 + fi + + local tmp_file="/tmp/telemt_download_$$" + local extract_dir="/tmp/telemt_extract_$$" + log_info "Скачивание: $url" + + if ! curl -L -s --max-time 120 -o "$tmp_file" "$url"; then + log_error "Ошибка скачивания telemt" + rm -f "$tmp_file" + return 1 + fi + + # Проверяем что файл не пустой и не HTML + local file_size + file_size=$(stat -c%s "$tmp_file" 2>/dev/null || echo 0) + if [ "$file_size" -lt 1000 ]; then + log_error "Скачанный файл слишком маленький ($file_size байт) — возможна ошибка сети" + rm -f "$tmp_file" + return 1 + fi + + # Определяем тип файла и распаковываем + local mime extracted="" + mime=$(file -b --mime-type "$tmp_file" 2>/dev/null) + rm -rf "$extract_dir" + mkdir -p "$extract_dir" + + case "$mime" in + application/gzip|application/x-gzip) + tar xzf "$tmp_file" -C "$extract_dir" 2>/dev/null + extracted=$(find "$extract_dir" -name "telemt" -type f 2>/dev/null | head -1) + if [ -z "$extracted" ]; then + # Может быть просто gzip без tar + gunzip -c "$tmp_file" > "$extract_dir/telemt_bin" 2>/dev/null + extracted="$extract_dir/telemt_bin" + fi + ;; + application/x-tar) + tar xf "$tmp_file" -C "$extract_dir" 2>/dev/null + extracted=$(find "$extract_dir" -name "telemt" -type f 2>/dev/null | head -1) + ;; + application/zip) + unzip -o "$tmp_file" -d "$extract_dir" 2>/dev/null + extracted=$(find "$extract_dir" -name "telemt" -type f 2>/dev/null | head -1) + ;; + application/octet-stream|application/x-executable) + extracted="$tmp_file" + ;; + *) + # Пробуем определить по содержимому + if file "$tmp_file" 2>/dev/null | grep -q "ELF"; then + extracted="$tmp_file" + else + # Пробуем как tar.gz + tar xzf "$tmp_file" -C "$extract_dir" 2>/dev/null + extracted=$(find "$extract_dir" -name "telemt" -type f 2>/dev/null | head -1) + fi + ;; + esac + + if [ -z "$extracted" ] || [ ! -f "$extracted" ]; then + log_error "Не удалось извлечь бинарник telemt (mime: $mime)" + rm -f "$tmp_file" + rm -rf "$extract_dir" + return 1 + fi + + # Устанавливаем + cp "$extracted" "$TELEMT_BIN" + chmod 755 "$TELEMT_BIN" + rm -f "$tmp_file" + rm -rf "$extract_dir" + + # Проверяем + if "$TELEMT_BIN" --version &>/dev/null; then + log_success "telemt $(get_installed_telemt_version) установлен в $TELEMT_BIN" + return 0 + else + log_error "Бинарник telemt не запускается ($(file -b "$TELEMT_BIN" 2>/dev/null))" + return 1 + fi +} + +# ── Системный пользователь ─────────────────────────────────────────────────── +create_telemt_user() { + if ! id "$TELEMT_USER" &>/dev/null; then + useradd -r -s /usr/sbin/nologin -d /etc/telemt "$TELEMT_USER" 2>/dev/null + log_dim "Создан системный пользователь: $TELEMT_USER" + fi +} + +# ── Systemd сервис ─────────────────────────────────────────────────────────── +install_telemt_service() { + local config_path="${1:-$TELEMT_CONFIG}" + + cat > "/etc/systemd/system/${TELEMT_SERVICE}.service" << EOF +[Unit] +Description=goTelegram Pro MTProxy (telemt engine) +Documentation=https://github.com/telemt/telemt +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=root +ExecStart=$TELEMT_BIN run $config_path +Restart=always +RestartSec=5 +LimitNOFILE=65535 + +# Безопасность +NoNewPrivileges=true +ProtectSystem=full +ProtectHome=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target +EOF + + systemctl daemon-reload + log_success "Systemd сервис $TELEMT_SERVICE создан" +} + +# ── Управление сервисом ────────────────────────────────────────────────────── +# start_telemt ensures telemt is running with the CURRENT on-disk config. +# If the service is already active we must restart (not plain start) — otherwise +# the running process keeps its old in-memory config and the freshly generated +# /etc/telemt/config.toml is silently ignored. This was the root cause of the +# "lite-mode key doesn't work after reinstall" bug: telemt had loaded the +# previous Pro config (tls_domain=legacy-upstream-domain) and was rejecting SNI=google.com +# clients with unknown_sni_action=Drop even though the on-disk config said +# tls_domain=google.com. +wait_telemt_ready() { + local timeout="${1:-90}" + local port elapsed=0 + port=$(awk ' + /^\[server\]/ { in_server=1; next } + /^\[/ && in_server { exit } + in_server && $1 == "port" { + sub(/^[^=]*=[[:space:]]*/, "") + gsub(/[[:space:]]/, "") + print + exit + } + ' "$TELEMT_CONFIG" 2>/dev/null) + [[ "$port" =~ ^[0-9]+$ ]] || port=443 + + while [ "$elapsed" -lt "$timeout" ]; do + if ! systemctl is-active --quiet "$TELEMT_SERVICE" 2>/dev/null; then + return 1 + fi + if ss -ltnp 2>/dev/null | grep -E ":${port}\b" | grep -q "telemt"; then + return 0 + fi + sleep 1 + elapsed=$((elapsed + 1)) + done + return 1 +} + +start_telemt() { + if systemctl is-active --quiet "$TELEMT_SERVICE" 2>/dev/null; then + systemctl restart "$TELEMT_SERVICE" 2>/dev/null + else + systemctl start "$TELEMT_SERVICE" 2>/dev/null + fi + if wait_telemt_ready 90; then + log_success "telemt запущен" + return 0 + else + log_error "telemt не запустился или не открыл порт" + journalctl -u "$TELEMT_SERVICE" --no-pager -n 10 2>/dev/null + return 1 + fi +} + +stop_telemt() { + if systemctl is-active --quiet "$TELEMT_SERVICE" 2>/dev/null; then + systemctl stop "$TELEMT_SERVICE" + log_success "telemt остановлен" + else + log_dim "telemt уже остановлен" + fi +} + +restart_telemt() { + systemctl restart "$TELEMT_SERVICE" 2>/dev/null + if wait_telemt_ready 90; then + log_success "telemt перезапущен" + return 0 + else + log_error "telemt не перезапустился или не открыл порт" + journalctl -u "$TELEMT_SERVICE" --no-pager -n 10 2>/dev/null + return 1 + fi +} + +enable_telemt() { + systemctl enable "$TELEMT_SERVICE" 2>/dev/null +} + +telemt_status() { + if ! is_telemt_installed; then + echo "not_installed" + return + fi + if systemctl is-active --quiet "$TELEMT_SERVICE" 2>/dev/null; then + echo "running" + elif systemctl is-enabled --quiet "$TELEMT_SERVICE" 2>/dev/null; then + echo "stopped" + else + echo "disabled" + fi +} + +telemt_logs() { + local lines="${1:-40}" + journalctl -u "$TELEMT_SERVICE" --no-pager -n "$lines" 2>/dev/null +} + +telemt_uptime() { + local started + started=$(systemctl show "$TELEMT_SERVICE" --property=ActiveEnterTimestamp --value 2>/dev/null) + if [ -n "$started" ] && [ "$started" != "" ]; then + echo "$started" + else + echo "N/A" + fi +} + +# ── Обновление ─────────────────────────────────────────────────────────────── +check_telemt_update() { + local current latest + current=$(get_installed_telemt_version) + latest=$(get_latest_telemt_version) + + if [ -z "$current" ] || [ -z "$latest" ]; then + return 1 + fi + + if [ "$current" != "$latest" ]; then + echo "$latest" + return 0 # есть обновление + fi + return 1 # актуально +} + +update_telemt() { + local latest + latest=$(check_telemt_update) + if [ $? -ne 0 ]; then + log_info "telemt уже последней версии ($(get_installed_telemt_version))" + return 0 + fi + + log_info "Доступно обновление: $(get_installed_telemt_version) → $latest" + if ! confirm "Обновить telemt?"; then + return 0 + fi + + stop_telemt + if download_telemt; then + start_telemt + log_success "telemt обновлён до $latest" + else + start_telemt # запускаем старую версию обратно + log_error "Обновление не удалось" + return 1 + fi +} + +# ── Полная установка telemt ────────────────────────────────────────────────── +install_telemt_full() { + log_step "Установка telemt" + + # Создаём директории + mkdir -p /etc/telemt + + # Скачиваем бинарник + run_with_spinner "Скачивание telemt" download_telemt || return 1 + + # Устанавливаем systemd сервис + install_telemt_service + + # Включаем автозапуск + enable_telemt + + log_success "telemt готов к работе" + return 0 +} + +# ── Удаление telemt ────────────────────────────────────────────────────────── +remove_telemt() { + stop_telemt + systemctl disable "$TELEMT_SERVICE" 2>/dev/null + rm -f "/etc/systemd/system/${TELEMT_SERVICE}.service" + systemctl daemon-reload + rm -f "$TELEMT_BIN" + rm -rf /etc/telemt + log_success "telemt полностью удалён" +} diff --git a/lib/telemt_config.sh b/lib/telemt_config.sh new file mode 100644 index 0000000..a4bdd76 --- /dev/null +++ b/lib/telemt_config.sh @@ -0,0 +1,501 @@ +#!/bin/bash +# GoTelegram v2.5.0 — Генерация TOML конфигурации для telemt + +# ── Популярные домены (не заблокированные в РФ) ────────────────────────────── +QUICK_DOMAINS=( + "google.com" + "microsoft.com" + "cloudflare.com" + "apple.com" + "amazon.com" + "github.com" + "stackoverflow.com" + "medium.com" + "wikipedia.org" + "coursera.org" + "udemy.com" + "habr.com" + "stepik.org" + "duolingo.com" + "khanacademy.org" + "bbc.com" + "reuters.com" + "nytimes.com" + "ted.com" + "zoom.us" +) + +# ── Генерация TOML конфига (telemt v3 формат) ─────────────────────────────── +generate_telemt_toml() { + local secret="$1" + local port="${2:-443}" + local mask_mode="${3:-lite}" # lite | pro + local mask_domain="${4:-google.com}" + local mask_port="${5:-443}" + local output="${6:-$TELEMT_CONFIG}" + + mkdir -p "$(dirname "$output")" + + # DNS override для pro: домен резолвится в 127.0.0.1 + # чтобы mask-трафик шёл на локальный nginx, а не в интернет + local dns_line="" + if [ "$mask_mode" = "pro" ]; then + dns_line="dns_overrides = [\"${mask_domain}:${mask_port}:127.0.0.1\"]" + fi + + cat > "$output" << EOTOML +# GoTelegram v${GOTELEGRAM_VERSION} — telemt v3 configuration +# Сгенерировано: $(date -Iseconds) +# Режим: ${mask_mode} + +[general] +use_middle_proxy = true +log_level = "normal" + +[general.modes] +classic = false +secure = false +tls = true + +[general.links] +show = "*" +public_port = ${port} + +[server] +port = ${port} +listen_addr_ipv4 = "0.0.0.0" +metrics_listen = "127.0.0.1:9090" +metrics_whitelist = ["127.0.0.1/32", "::1/128"] + +[server.api] +enabled = true +listen = "127.0.0.1:9091" +whitelist = ["127.0.0.1/32", "::1/128"] +minimal_runtime_enabled = false +minimal_runtime_cache_ttl_ms = 1000 + +[censorship] +tls_domain = "${mask_domain}" +mask = true +mask_port = ${mask_port} +tls_emulation = $([ "$mask_mode" = "pro" ] && echo "false" || echo "true") +unknown_sni_action = "mask" + +[access.users] +main = "${secret}" + +[network] +${dns_line} +EOTOML + + chmod 600 "$output" + log_success "Конфиг telemt записан: $output" + log_dim "Режим: $mask_mode, домен: $mask_domain, порт mask: $mask_port" +} + +# ── Добавление дополнительного секрета ─────────────────────────────────────── +add_secret_to_config() { + local name="$1" + local secret="$2" + local config="${3:-$TELEMT_CONFIG}" + + if [ ! -f "$config" ]; then + log_error "Конфиг не найден: $config" + return 1 + fi + + # telemt v3: добавляем ключ в секцию [access.users] + # Формат: name = "secret" под блоком [access.users] + if grep -q '\[access\.users\]' "$config"; then + sed -i "/\[access\.users\]/a ${name} = \"${secret}\"" "$config" + else + cat >> "$config" << EOSECRET + +[access.users] +${name} = "${secret}" +EOSECRET + fi + + log_success "Добавлен секрет: $name" +} + +# ── Чтение текущего конфига (telemt v3 формат) ────────────────────────────── +get_config_value() { + local key="$1" + local config="${2:-$TELEMT_CONFIG}" + + if [ ! -f "$config" ]; then return 1; fi + + case "$key" in + secret) + # [access.users] main = "..." + awk ' + /^\[access\.users\]/ { in_users=1; next } + /^\[/ && in_users { exit } + in_users && /^[[:space:]]*[^#[:space:]][^=]*=/ { + user_key=$1 + gsub(/"/, "", user_key) + if (user_key != "main") next + + value=$0 + sub(/^[^=]*=[[:space:]]*/, "", value) + sub(/^"/, "", value) + sub(/".*$/, "", value) + gsub(/[[:space:]]/, "", value) + if (value != "") { + print value + found=1 + } + exit + } + END { exit found ? 0 : 1 } + ' "$config" + ;; + port) + # [server] port = 443 + awk ' + /^\[server\]/ { in_server=1; next } + /^\[/ && in_server { exit } + in_server && $1 == "port" { + sub(/^[^=]*=[[:space:]]*/, "") + gsub(/[[:space:]]/, "") + print + exit + } + ' "$config" + ;; + mask_host|tls_domain) + # [censorship] tls_domain = "..." + awk ' + /^\[censorship\]/ { in_cens=1; next } + /^\[/ && in_cens { exit } + in_cens && $1 == "tls_domain" { + sub(/^[^=]*=[[:space:]]*"/, "") + sub(/".*$/, "") + print + exit + } + ' "$config" + ;; + mask_port) + awk ' + /^\[censorship\]/ { in_cens=1; next } + /^\[/ && in_cens { exit } + in_cens && $1 == "mask_port" { + sub(/^[^=]*=[[:space:]]*/, "") + gsub(/[[:space:]]/, "") + print + exit + } + ' "$config" + ;; + *) + grep "$key" "$config" | head -1 | sed 's/^[^=]*=[[:space:]]*//; s/^"//; s/"$//' | tr -d ' ' + ;; + esac +} + +get_telemt_users_block() { + local config="${1:-$TELEMT_CONFIG}" + [ -f "$config" ] || return 1 + awk ' + /^\[access\.users\]/ { in_users=1; next } + /^\[/ && in_users { exit } + in_users && /^[[:space:]]*[^#[:space:]][^=]*=/ { print } + ' "$config" +} + +first_telemt_user_secret() { + local config="${1:-$TELEMT_CONFIG}" + get_telemt_users_block "$config" | head -1 | sed 's/^[^=]*=[[:space:]]*//; s/^"//; s/".*$//' | tr -d ' ' +} + +telemt_users_block_has_main() { + local users_block="$1" + printf '%s\n' "$users_block" | awk -F= ' + /^[[:space:]]*#/ || ! /=/ { next } + { + key=$1 + gsub(/^[[:space:]]+|[[:space:]]+$/, "", key) + if (key ~ /^".*"$/ || key ~ /^\047.*\047$/) { + key=substr(key, 2, length(key) - 2) + } + if (key == "main") { + found=1 + exit + } + } + END { exit found ? 0 : 1 } + ' +} + +replace_telemt_users_block() { + local users_block="$1" + local config="${2:-$TELEMT_CONFIG}" + [ -f "$config" ] || return 1 + [ -n "$users_block" ] || return 0 + + local tmp + tmp=$(mktemp) || return 1 + awk -v users="$users_block" ' + BEGIN { split(users, lines, "\n") } + /^\[access\.users\]/ { + found=1 + print + for (i = 1; i in lines; i++) { + if (lines[i] != "") print lines[i] + } + in_users=1 + next + } + /^\[/ && in_users { in_users=0 } + in_users { next } + { print } + END { + if (!found) { + print "" + print "[access.users]" + for (i = 1; i in lines; i++) { + if (lines[i] != "") print lines[i] + } + } + } + ' "$config" > "$tmp" && mv "$tmp" "$config" + chmod 600 "$config" +} + +toml_bool_value() { + local table="$1" + local key="$2" + local config="${3:-$TELEMT_CONFIG}" + awk -v table="$table" -v key="$key" ' + $0 == "[" table "]" { in_table=1; next } + /^\[/ && in_table { exit } + in_table && $1 == key { + sub(/^[^=]*=[[:space:]]*/, "") + gsub(/[[:space:]]/, "") + print + exit + } + ' "$config" +} + +# ── Валидация конфига ──────────────────────────────────────────────────────── +validate_telemt_config() { + local config="${1:-$TELEMT_CONFIG}" + + if [ ! -f "$config" ]; then + log_error "Конфиг не найден: $config" + return 1 + fi + + # Проверяем обязательные поля + local secret port host + secret=$(get_config_value secret "$config") + port=$(get_config_value port "$config") + host=$(get_config_value mask_host "$config") + + local errors=0 + + if [ -z "$secret" ]; then + log_error "Не задан secret" + ((errors++)) + elif [ ${#secret} -lt 32 ]; then + log_warning "Secret слишком короткий (${#secret} символов, рекомендуется 32+)" + fi + + if [ -z "$port" ]; then + log_error "Не задан порт (bind_to)" + ((errors++)) + elif [ "$port" -lt 1 ] || [ "$port" -gt 65535 ] 2>/dev/null; then + log_error "Порт вне диапазона: $port" + ((errors++)) + fi + + if [ -z "$host" ]; then + log_error "Не задан маскировочный хост (censorship.tls_domain)" + ((errors++)) + fi + + if [ $errors -gt 0 ]; then + log_error "Найдено ошибок: $errors" + return 1 + fi + + log_success "Конфиг валиден" + return 0 +} + +# ── Выбор домена (интерактивный) ───────────────────────────────────────────── +select_quick_domain() { + echo "" >&2 + echo -e " ${BOLD}${WHITE}🌐 Выберите домен для маскировки (Fake TLS):${NC}" >&2 + echo -e " ${DIM}$(printf '─%.0s' {1..50})${NC}" >&2 + + local i=1 + local row="" + for d in "${QUICK_DOMAINS[@]}"; do + printf " ${CYAN}%2d)${NC} %-25s" "$i" "$d" >&2 + if (( i % 2 == 0 )); then + echo "" >&2 + fi + ((i++)) + done + if (( (i-1) % 2 != 0 )); then echo "" >&2; fi + + echo -e " ${DIM}$(printf '─%.0s' {1..50})${NC}" >&2 + echo -ne " ${WHITE}Выбор (1-${#QUICK_DOMAINS[@]}):${NC} " >&2 + read -r choice + + if [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -le ${#QUICK_DOMAINS[@]} ]; then + echo "${QUICK_DOMAINS[$((choice-1))]}" + return 0 + fi + + log_error "Неверный выбор" + return 1 +} + +# ── Выбор порта (интерактивный) ────────────────────────────────────────────── +select_port() { + echo "" >&2 + echo -e " ${BOLD}${WHITE}🔌 Выберите порт:${NC}" >&2 + + # Проверяем стандартные порты + local busy_443 busy_8443 + busy_443=$(check_port 443) + busy_8443=$(check_port 8443) + + local label_443="443 (рекомендуется)" + local label_8443="8443" + [ -n "$busy_443" ] && label_443="443 ⚠️ занят" + [ -n "$busy_8443" ] && label_8443="8443 ⚠️ занят" + + echo -e " ${CYAN}1)${NC} $label_443" >&2 + echo -e " ${CYAN}2)${NC} $label_8443" >&2 + echo -e " ${CYAN}3)${NC} Свой порт" >&2 + + if [ -n "$busy_443" ]; then + echo -e " ${DIM} ⚠ Порт 443 занят: $(echo "$busy_443" | head -c 60)${NC}" >&2 + fi + + echo -ne " ${WHITE}Выбор:${NC} " >&2 + read -r choice + + case "$choice" in + 1) echo "443" ;; + 2) echo "8443" ;; + 3) + echo -ne " Введите порт (1-65535): " >&2 + read -r custom_port + if [[ "$custom_port" =~ ^[0-9]+$ ]] && [ "$custom_port" -ge 1 ] && [ "$custom_port" -le 65535 ]; then + echo "$custom_port" + else + log_error "Неверный порт" + return 1 + fi + ;; + *) echo "443" ;; + esac +} + +# ── Генерация ссылки tg://proxy ────────────────────────────────────────────── +generate_proxy_link() { + local server="${1:-$(get_server_ip)}" + local port="${2:-443}" + local secret="$3" + local mask_host="${4:-}" + + # Если указан mask_host (fake-TLS), формируем ee-секрет + if [ -n "$mask_host" ]; then + local domain_hex + domain_hex=$(printf '%s' "$mask_host" | xxd -p | tr -d '\n') + secret="ee${secret}${domain_hex}" + fi + + echo "tg://proxy?server=${server}&port=${port}&secret=${secret}" +} + +# ── Вывод информации о прокси ──────────────────────────────────────────────── +show_proxy_info() { + local config="${1:-$TELEMT_CONFIG}" + local secret port mask_host ip link status + + secret=$(get_config_value secret "$config") + port=$(get_config_value port "$config") + mask_host=$(get_config_value mask_host "$config") + ip=$(get_server_ip) + status=$(telemt_status) + + local mode domain + mode=$(config_get mode 2>/dev/null || echo "lite") + domain=$(config_get domain 2>/dev/null || echo "") + + # Генерация ссылки: оба режима используют ee-секрет с mask_host + if [ "$mode" = "pro" ] && [ -n "$domain" ]; then + link=$(generate_proxy_link "$domain" "$port" "$secret" "$domain") + else + link=$(generate_proxy_link "$ip" "$port" "$secret" "$mask_host") + fi + + local status_icon status_text + case "$status" in + running) status_icon="✅"; status_text="Работает" ;; + stopped) status_icon="⏸️"; status_text="Остановлен" ;; + *) status_icon="❌"; status_text="Не установлен" ;; + esac + + echo "" + echo -e " ${BOLD}${WHITE}${status_icon} Статус прокси: ${status_text}${NC}" + echo -e " ${DIM}$(printf '─%.0s' {1..50})${NC}" + echo -e " ${WHITE}Ядро:${NC} telemt (Rust)" + if [ "$mode" = "pro" ] && [ -n "$domain" ]; then + echo -e " ${WHITE}Домен:${NC} ${CYAN}${domain}${NC}" + else + echo -e " ${WHITE}IP:${NC} ${CYAN}${ip}${NC}" + fi + echo -e " ${WHITE}Порт:${NC} ${CYAN}${port}${NC}" + echo -e " ${WHITE}Режим:${NC} ${CYAN}${mode}${NC}" + echo -e " ${WHITE}Маскировка:${NC} ${CYAN}${mask_host}${NC}" + echo -e " ${WHITE}Secret:${NC} ${CYAN}${secret:0:16}...${NC}" + echo -e " ${DIM}$(printf '─%.0s' {1..50})${NC}" + echo -e " ${WHITE}Ссылка:${NC}" + echo -e " ${GREEN}${link}${NC}" + echo "" + + # QR если доступен + if command -v qrencode &>/dev/null; then + qrencode -t UTF8 -m 2 "$link" 2>/dev/null + fi +} + +# ── Вывод информации о прокси (Pro-режим) ────────────────────────────────── +# В pro-режиме ссылка содержит домен (не IP) и fake-TLS секрет (ee...) +show_proxy_info_pro() { + local domain="$1" + local faketls_secret="$2" + + local link="tg://proxy?server=${domain}&port=443&secret=${faketls_secret}" + + echo "" + echo -e " ${BOLD}${WHITE}✅ Pro-прокси настроен${NC}" + echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" + echo -e " ${WHITE}Ядро:${NC} telemt (Rust)" + echo -e " ${WHITE}Домен:${NC} ${CYAN}${domain}${NC}" + echo -e " ${WHITE}Порт:${NC} ${CYAN}443${NC} (внешний, telemt)" + echo -e " ${WHITE}Режим:${NC} ${MAGENTA}Pro (fake-TLS)${NC}" + echo -e " ${WHITE}nginx:${NC} ${CYAN}127.0.0.1:8443${NC} (внутренний)" + echo -e " ${WHITE}Secret:${NC} ${CYAN}${faketls_secret:0:20}...${NC}" + echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" + echo -e " ${WHITE}Ссылка для Telegram:${NC}" + echo -e " ${GREEN}${link}${NC}" + echo "" + echo -e " ${DIM}Провайдер видит: HTTPS-трафик к ${domain}:443${NC}" + echo -e " ${DIM}Telegram-клиент маскирует соединение под TLS${NC}" + echo "" + + # QR если доступен + if command -v qrencode &>/dev/null; then + qrencode -t UTF8 -m 2 "$link" 2>/dev/null + fi +} diff --git a/lib/templates_catalog.sh b/lib/templates_catalog.sh new file mode 100644 index 0000000..7c3eeca --- /dev/null +++ b/lib/templates_catalog.sh @@ -0,0 +1,595 @@ +#!/bin/bash +# GoTelegram v2.5.0 — website templates catalog +# Pick from ~1800 templates, preview links, git sparse-checkout downloads, +# + custom git URL templates (user-supplied public repos) + +CATALOG_FILE="$(dirname "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")")/templates_catalog.json" +TEMPLATES_CACHE="/tmp/gotelegram_templates" + +# Custom git template limits +CUSTOM_GIT_MAX_SIZE_MB=100 +CUSTOM_GIT_CLONE_TIMEOUT=90 + +# ── Catalog loading ──────────────────────────────────────────────────── +load_catalog() { + if [ ! -f "$CATALOG_FILE" ]; then + if type tf &>/dev/null; then + log_error "$(tf templates_catalog_not_found "$CATALOG_FILE")" + else + log_error "Templates catalog not found: $CATALOG_FILE" + fi + return 1 + fi + return 0 +} + +# ── Categories ───────────────────────────────────────────────────────── +get_categories() { + jq -r '.categories[] | "\(.id)|\(.name)|\(.icon)|\(.templates | length)"' "$CATALOG_FILE" 2>/dev/null +} + +get_category_name() { + local cat_id="$1" + jq -r ".categories[] | select(.id == \"$cat_id\") | .name" "$CATALOG_FILE" 2>/dev/null +} + +# ── Templates in a category ──────────────────────────────────────────── +get_templates_by_category() { + local cat_id="$1" + jq -r ".categories[] | select(.id == \"$cat_id\") | .templates[] | \"\(.id)|\(.name)|\(.source)|\(.preview_url)\"" "$CATALOG_FILE" 2>/dev/null +} + +# ── Template info ────────────────────────────────────────────────────── +get_template_info() { + local tpl_id="$1" + jq ".categories[].templates[] | select(.id == \"$tpl_id\")" "$CATALOG_FILE" 2>/dev/null +} + +get_template_field() { + local tpl_id="$1" + local field="$2" + jq -r ".categories[].templates[] | select(.id == \"$tpl_id\") | .$field" "$CATALOG_FILE" 2>/dev/null +} + +# ── Interactive category picker (returns category id or special __custom_git__/__random__) ── +select_category() { + load_catalog || return 1 + + echo "" >&2 + echo -e " ${BOLD}${WHITE}$(t templates_categories)${NC}" >&2 + echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" >&2 + + # First item: custom git URL template + printf " ${CYAN}%2d)${NC} ${GREEN}%s${NC}\n" 1 "$(t templates_custom_git)" >&2 + + local cats=() + local i=2 + while IFS='|' read -r id name icon count; do + [ "$count" -eq 0 ] && continue + local emoji + case "$icon" in + briefcase) emoji="🏢" ;; + shopping-cart) emoji="🛒" ;; + heart) emoji="🏥" ;; + book) emoji="🎓" ;; + palette) emoji="📸" ;; + home) emoji="🏠" ;; + utensils) emoji="🍕" ;; + rocket) emoji="🎨" ;; + chart-bar) emoji="🔧" ;; + *) emoji="📄" ;; + esac + printf " ${CYAN}%2d)${NC} ${emoji} %-30s ${DIM}$(tf templates_count_fmt "$count")${NC}\n" "$i" "$name" >&2 + cats+=("$id") + ((i++)) + done < <(get_categories) + + printf " ${CYAN}%2d)${NC} %s\n" "$i" "$(t templates_random)" >&2 + echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" >&2 + echo -ne " ${WHITE}$(t choose):${NC} " >&2 + read -r choice + + if ! [[ "$choice" =~ ^[0-9]+$ ]]; then + log_error "$(t invalid_choice)" + return 1 + fi + + # Custom git URL + if [ "$choice" -eq 1 ]; then + echo "__custom_git__" + return 0 + fi + + # Random + if [ "$choice" -eq "$i" ]; then + local random_cat="${cats[$((RANDOM % ${#cats[@]}))]}" + echo "$random_cat" + return 0 + fi + + # Regular category (offset by 1 because item 1 is custom git) + if [ "$choice" -ge 2 ] && [ "$choice" -lt "$i" ]; then + echo "${cats[$((choice-2))]}" + return 0 + fi + + log_error "$(t invalid_choice)" + return 1 +} + +# ── Interactive template picker ──────────────────────────────────────── +select_template() { + local cat_id="$1" + local cat_name + cat_name=$(get_category_name "$cat_id") + + echo "" >&2 + echo -e " ${BOLD}${WHITE}$(tf templates_list "$cat_name")${NC}" >&2 + echo -e " ${DIM}$(printf '─%.0s' {1..60})${NC}" >&2 + + local tpls=() + local i=1 + while IFS='|' read -r id name source preview; do + printf " ${CYAN}%2d)${NC} %-30s ${DIM}[%s]${NC}\n" "$i" "$name" "$source" >&2 + tpls+=("$id") + ((i++)) + done < <(get_templates_by_category "$cat_id") + + if [ ${#tpls[@]} -eq 0 ]; then + log_info "$(t templates_cat_empty)" + return 1 + fi + + echo -e " ${DIM}$(printf '─%.0s' {1..60})${NC}" >&2 + echo -ne " ${WHITE}$(t choose) (1-$((i-1))):${NC} " >&2 + read -r choice + + if [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -lt "$i" ]; then + local selected_id="${tpls[$((choice-1))]}" + + # Show preview + show_template_preview "$selected_id" || return 1 + + echo "$selected_id" + return 0 + fi + + log_error "$(t invalid_choice)" + return 1 +} + +# ── Template preview ─────────────────────────────────────────────────── +show_template_preview() { + local tpl_id="$1" + local info + info=$(get_template_info "$tpl_id") + + local name source preview_url repo_url description + name=$(echo "$info" | jq -r '.name') + source=$(echo "$info" | jq -r '.source') + preview_url=$(echo "$info" | jq -r '.preview_url // empty') + repo_url=$(echo "$info" | jq -r '.repo_url // empty') + description=$(echo "$info" | jq -r '.description // "—"') + + echo "" >&2 + echo -e " ${BOLD}${WHITE}$(t templates_preview_title)${NC}" >&2 + echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" >&2 + echo -e " ${WHITE}$(t templates_name)${NC} $name" >&2 + echo -e " ${WHITE}$(t templates_source)${NC} $source" >&2 + echo -e " ${WHITE}$(t templates_description)${NC} $description" >&2 + + if [ -n "$preview_url" ]; then + echo "" >&2 + echo -e " ${GREEN}$(t templates_preview)${NC} ${CYAN}${preview_url}${NC}" >&2 + echo -e " ${DIM}$(t templates_preview_hint)${NC}" >&2 + fi + + if [ -n "$repo_url" ]; then + echo -e " ${DIM}$(t templates_repo) ${repo_url}${NC}" >&2 + fi + + # Thanks + echo "" >&2 + echo -e " ${MAGENTA}$(tf templates_thanks "$source")${NC}" >&2 + + echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" >&2 + echo "" >&2 + + if ! confirm "$(t templates_install_this)"; then + return 1 + fi + return 0 +} + +# ── Template download (from catalog) ─────────────────────────────────── +download_template() { + local tpl_id="$1" + local output_dir="${2:-$TEMPLATES_CACHE}" + local info + info=$(get_template_info "$tpl_id") + + local repo_url sparse_path source name + repo_url=$(echo "$info" | jq -r '.repo_url') + sparse_path=$(echo "$info" | jq -r '.sparse_path') + source=$(echo "$info" | jq -r '.source') + name=$(echo "$info" | jq -r '.name') + + local clone_dir="$output_dir/${tpl_id}" + rm -rf "$clone_dir" + mkdir -p "$clone_dir" + + log_info "$(tf templates_downloading "$name")" + + # HTML5 UP — one repo with folders + if [ "$source" = "html5up" ]; then + local tmp_clone="/tmp/html5up_clone_$$" + rm -rf "$tmp_clone" + + # Sparse checkout + git clone --depth 1 --filter=blob:none --sparse "$repo_url" "$tmp_clone" 2>/dev/null + if [ $? -ne 0 ]; then + # Fallback: full clone + git clone --depth 1 "$repo_url" "$tmp_clone" 2>/dev/null + fi + + if [ -d "$tmp_clone" ]; then + cd "$tmp_clone" && git sparse-checkout set "$sparse_path" 2>/dev/null + if [ -d "$tmp_clone/$sparse_path" ]; then + cp -r "$tmp_clone/$sparse_path"/* "$clone_dir/" + fi + cd - >/dev/null + fi + rm -rf "$tmp_clone" + + # learning-zone — one big repo + elif [ "$source" = "learning-zone" ]; then + local tmp_clone="/tmp/lz_clone_$$" + rm -rf "$tmp_clone" + + git clone --depth 1 --filter=blob:none --sparse "$repo_url" "$tmp_clone" 2>/dev/null + if [ $? -ne 0 ]; then + git clone --depth 1 "$repo_url" "$tmp_clone" 2>/dev/null + fi + + if [ -d "$tmp_clone" ]; then + cd "$tmp_clone" && git sparse-checkout set "$sparse_path" 2>/dev/null + if [ -d "$tmp_clone/$sparse_path" ]; then + cp -r "$tmp_clone/$sparse_path"/* "$clone_dir/" + fi + cd - >/dev/null + fi + rm -rf "$tmp_clone" + + # StartBootstrap — each template in its own repo + elif [ "$source" = "startbootstrap" ]; then + local sb_tmp="/tmp/sb_clone_$$" + rm -rf "$sb_tmp" + git clone --depth 1 "$repo_url" "$sb_tmp" 2>/dev/null + if [ -d "$sb_tmp" ]; then + rm -rf "$sb_tmp/.git" + # StartBootstrap stores production files in dist/ + if [ -f "$sb_tmp/dist/index.html" ]; then + cp -r "$sb_tmp/dist/"* "$clone_dir/" + elif [ -f "$sb_tmp/index.html" ]; then + cp -r "$sb_tmp/"* "$clone_dir/" + else + local found_index + found_index=$(find "$sb_tmp" -name "index.html" -type f 2>/dev/null | head -1) + if [ -n "$found_index" ]; then + local found_dir + found_dir=$(dirname "$found_index") + cp -r "$found_dir/"* "$clone_dir/" + fi + fi + fi + rm -rf "$sb_tmp" + + # ThemeWagon / ColorlibHQ — each template in its own repo + elif [ "$source" = "themewagon" ] || [ "$source" = "colorlib" ]; then + local tw_tmp="/tmp/tw_clone_$$" + rm -rf "$tw_tmp" + git clone --depth 1 "$repo_url" "$tw_tmp" 2>/dev/null + if [ -d "$tw_tmp" ]; then + rm -rf "$tw_tmp/.git" + if [ -f "$tw_tmp/dist/index.html" ]; then + cp -r "$tw_tmp/dist/"* "$clone_dir/" + elif [ -f "$tw_tmp/index.html" ]; then + cp -r "$tw_tmp/"* "$clone_dir/" + else + local found_index + found_index=$(find "$tw_tmp" -name "index.html" -type f -maxdepth 3 2>/dev/null | head -1) + if [ -n "$found_index" ]; then + local found_dir + found_dir=$(dirname "$found_index") + cp -r "$found_dir/"* "$clone_dir/" + fi + fi + fi + rm -rf "$tw_tmp" + + # dawidolko — one big repo with folders (similar to learning-zone) + elif [ "$source" = "dawidolko" ]; then + local tmp_clone="/tmp/dw_clone_$$" + rm -rf "$tmp_clone" + git clone --depth 1 --filter=blob:none --sparse "$repo_url" "$tmp_clone" 2>/dev/null + if [ $? -ne 0 ]; then + git clone --depth 1 "$repo_url" "$tmp_clone" 2>/dev/null + fi + if [ -d "$tmp_clone" ]; then + cd "$tmp_clone" && git sparse-checkout set "$sparse_path" 2>/dev/null + if [ -d "$tmp_clone/$sparse_path" ]; then + cp -r "$tmp_clone/$sparse_path"/* "$clone_dir/" + fi + cd - >/dev/null + fi + rm -rf "$tmp_clone" + fi + + # Check result + if [ -f "$clone_dir/index.html" ]; then + log_success "$(tf templates_downloaded "$name")" + echo "$clone_dir" + return 0 + else + # fallback: find index.html in subfolders (non-standard structure) + local fallback_index + fallback_index=$(find "$clone_dir" -name "index.html" -type f 2>/dev/null | head -1) + if [ -n "$fallback_index" ]; then + local fallback_dir + fallback_dir=$(dirname "$fallback_index") + if [ "$fallback_dir" != "$clone_dir" ]; then + cp -r "$fallback_dir/"* "$clone_dir/" + log_success "$(tf templates_downloaded_subfolder "$name")" + echo "$clone_dir" + return 0 + fi + fi + log_error "$(t templates_no_index)" + log_dim "$(tf templates_path "$clone_dir")" + ls -la "$clone_dir" 2>/dev/null >&2 + return 1 + fi +} + +# ── Custom git URL helpers ───────────────────────────────────────────── + +# Validate a user-supplied git URL +# Accepts: https://host/path[.git][@branch] +# Rejects: ssh://, git://, file://, absolute file paths +_validate_custom_git_url() { + local url="$1" + # Must begin with https:// + [[ "$url" =~ ^https:// ]] || return 1 + # Reject shell metacharacters that could be exploited + [[ "$url" =~ [[:space:]\;\`\$\(\)\<\>\|\\\&] ]] && return 1 + # Reasonable length limit + [ "${#url}" -gt 512 ] && return 1 + return 0 +} + +# Parse URL → sets CUSTOM_GIT_CLEAN and CUSTOM_GIT_BRANCH globals +_parse_custom_git_url() { + local url="$1" + CUSTOM_GIT_CLEAN="" + CUSTOM_GIT_BRANCH="" + # Handle trailing @branch + if [[ "$url" =~ ^(https://[^@]+)@([A-Za-z0-9._/-]+)$ ]]; then + CUSTOM_GIT_CLEAN="${BASH_REMATCH[1]}" + CUSTOM_GIT_BRANCH="${BASH_REMATCH[2]}" + else + CUSTOM_GIT_CLEAN="$url" + fi + # Strip trailing slash + CUSTOM_GIT_CLEAN="${CUSTOM_GIT_CLEAN%/}" + # Append .git if missing (works better with git clone on some hosts) + if [[ ! "$CUSTOM_GIT_CLEAN" =~ \.git$ ]]; then + CUSTOM_GIT_CLEAN="${CUSTOM_GIT_CLEAN}.git" + fi +} + +# Check repo size (in MB) by inspecting cloned directory +_clone_dir_size_mb() { + local dir="$1" + du -sm "$dir" 2>/dev/null | awk '{print $1}' +} + +# ── Show detailed help for custom git template ───────────────────────── +show_custom_git_help() { + local line + line=$(printf '─%.0s' $(seq 1 60)) + echo "" >&2 + echo -e " ${BOLD}${GREEN}$(t custom_git_title)${NC}" >&2 + echo -e " ${DIM}${line}${NC}" >&2 + echo -e " $(t custom_git_help_1)" >&2 + echo -e " $(t custom_git_help_2)" >&2 + echo -e " $(t custom_git_help_3)" >&2 + echo "" >&2 + echo -e " ${BOLD}${WHITE}$(t custom_git_formats)${NC}" >&2 + echo -e " ${CYAN}$(t custom_git_fmt_github)${NC}" >&2 + echo -e " ${CYAN}$(t custom_git_fmt_gitlab)${NC}" >&2 + echo -e " ${CYAN}$(t custom_git_fmt_gitext)${NC}" >&2 + echo -e " ${CYAN}$(t custom_git_fmt_branch)${NC}" >&2 + echo "" >&2 + echo -e " ${BOLD}${WHITE}$(t custom_git_auto_detect)${NC}" >&2 + echo -e " $(t custom_git_auto_1)" >&2 + echo -e " $(t custom_git_auto_2)" >&2 + echo -e " $(t custom_git_auto_3)" >&2 + echo -e " $(t custom_git_auto_4)" >&2 + echo "" >&2 + echo -e " ${BOLD}${WHITE}$(t custom_git_requirements)${NC}" >&2 + echo -e " ${YELLOW}$(t custom_git_req_1)${NC}" >&2 + echo -e " ${YELLOW}$(t custom_git_req_2)${NC}" >&2 + echo -e " ${YELLOW}$(t custom_git_req_3)${NC}" >&2 + echo -e " ${YELLOW}$(t custom_git_req_4)${NC}" >&2 + echo "" >&2 + echo -e " ${BOLD}${WHITE}$(t custom_git_examples)${NC}" >&2 + echo -e " ${DIM}$(t custom_git_ex_1)${NC}" >&2 + echo -e " ${DIM}$(t custom_git_ex_2)${NC}" >&2 + echo -e " ${DIM}${line}${NC}" >&2 + echo "" >&2 +} + +# ── Download a custom git template ───────────────────────────────────── +# Prompts user for a URL (unless passed), clones, detects index.html, +# copies result into $output_dir/custom_, echoes the final path. +download_custom_git_template() { + local url="${1:-}" + local output_dir="${2:-$TEMPLATES_CACHE}" + + show_custom_git_help + + if [ -z "$url" ]; then + echo -ne " ${WHITE}$(t custom_git_enter_url)${NC} " >&2 + read -r url + url=$(echo "$url" | tr -d '\r\n[:space:]') + fi + + if [ -z "$url" ]; then + log_error "$(t custom_git_empty)" + return 1 + fi + + if ! _validate_custom_git_url "$url"; then + log_error "$(t custom_git_bad_url)" + return 1 + fi + + _parse_custom_git_url "$url" + local clean_url="$CUSTOM_GIT_CLEAN" + local branch="$CUSTOM_GIT_BRANCH" + + # Stable-ish directory name from a hash of the original URL + local hash + hash=$(echo -n "$url" | md5sum 2>/dev/null | awk '{print $1}' | head -c 10) + [ -z "$hash" ] && hash=$(date +%s) + local tpl_id="custom_${hash}" + local clone_dir="$output_dir/${tpl_id}" + local tmp_clone="/tmp/custom_git_clone_$$" + + rm -rf "$clone_dir" "$tmp_clone" + mkdir -p "$clone_dir" + + log_info "$(t custom_git_cloning)" + + # Clone with timeout so a hung server can't freeze the installer + local clone_status=0 + local git_args=("clone" "--depth" "1") + [ -n "$branch" ] && git_args+=("--branch" "$branch") + git_args+=("$clean_url" "$tmp_clone") + + if command -v timeout &>/dev/null; then + timeout "$CUSTOM_GIT_CLONE_TIMEOUT" git "${git_args[@]}" 2>/tmp/custom_git_err_$$ + clone_status=$? + else + git "${git_args[@]}" 2>/tmp/custom_git_err_$$ + clone_status=$? + fi + + if [ $clone_status -ne 0 ] || [ ! -d "$tmp_clone" ]; then + local err_msg + err_msg=$(head -3 "/tmp/custom_git_err_$$" 2>/dev/null | tr '\n' ' ') + rm -f "/tmp/custom_git_err_$$" + rm -rf "$tmp_clone" "$clone_dir" + log_error "$(tf custom_git_clone_failed "${err_msg:-$clone_status}")" + return 1 + fi + rm -f "/tmp/custom_git_err_$$" + + # Drop .git before measuring size (we only care about payload) + rm -rf "$tmp_clone/.git" + + # Size guard + local size_mb + size_mb=$(_clone_dir_size_mb "$tmp_clone") + if [ -n "$size_mb" ] && [ "$size_mb" -gt "$CUSTOM_GIT_MAX_SIZE_MB" ]; then + rm -rf "$tmp_clone" "$clone_dir" + log_error "$(tf custom_git_too_big "${size_mb}MB")" + return 1 + fi + + log_info "$(t custom_git_scanning)" + + # Priority list of common static-site output folders + local candidates=("" "dist" "public" "build" "_site" "site" "docs" "out" "www") + local found_dir="" + for sub in "${candidates[@]}"; do + local try_dir="$tmp_clone" + [ -n "$sub" ] && try_dir="$tmp_clone/$sub" + if [ -f "$try_dir/index.html" ]; then + found_dir="$try_dir" + break + fi + done + + # Fallback: search for any index.html in the repo (shallow depth first) + if [ -z "$found_dir" ]; then + local fallback_index + fallback_index=$(find "$tmp_clone" -maxdepth 4 -name "index.html" -type f 2>/dev/null | head -1) + if [ -n "$fallback_index" ]; then + found_dir=$(dirname "$fallback_index") + fi + fi + + if [ -z "$found_dir" ] || [ ! -f "$found_dir/index.html" ]; then + rm -rf "$tmp_clone" "$clone_dir" + log_error "$(t custom_git_no_index)" + return 1 + fi + + # Show what we found (human-friendly relative path) + local rel_path="${found_dir#$tmp_clone}" + rel_path="${rel_path#/}" + [ -z "$rel_path" ] && rel_path="(root)" + log_dim "$(tf custom_git_found_at "$rel_path")" + + # Copy the detected directory as the new template + cp -r "$found_dir"/* "$clone_dir/" 2>/dev/null + cp -r "$found_dir"/.[!.]* "$clone_dir/" 2>/dev/null + + rm -rf "$tmp_clone" + + if [ ! -f "$clone_dir/index.html" ]; then + rm -rf "$clone_dir" + log_error "$(t custom_git_no_index)" + return 1 + fi + + # Remember the URL so users can see what template they used + echo "$url" > "$clone_dir/.custom_git_source" 2>/dev/null + + log_success "$(tf custom_git_installed "$url")" + echo "$clone_dir" + return 0 +} + +# ── Full interactive template selection ─────────────────────────────── +interactive_template_selection() { + load_catalog || return 1 + + # Category selection + local cat_id + cat_id=$(select_category) + [ $? -ne 0 ] && return 1 + + # Custom git URL path + if [ "$cat_id" = "__custom_git__" ]; then + local template_dir + template_dir=$(download_custom_git_template) + [ $? -ne 0 ] && return 1 + echo "$template_dir" + return 0 + fi + + # Template selection + local tpl_id + tpl_id=$(select_template "$cat_id") + [ $? -ne 0 ] && return 1 + + # Download + local template_dir + template_dir=$(download_template "$tpl_id") + [ $? -ne 0 ] && return 1 + + echo "$template_dir" + return 0 +} diff --git a/lib/website.sh b/lib/website.sh new file mode 100644 index 0000000..c6e63bb --- /dev/null +++ b/lib/website.sh @@ -0,0 +1,347 @@ +#!/bin/bash +# GoTelegram v2.5.0 — Управление сайтом (nginx + certbot + шаблоны) + +# ── Установка nginx ────────────────────────────────────────────────────────── +install_nginx() { + if command -v nginx &>/dev/null; then + log_dim "nginx уже установлен" + return 0 + fi + log_info "Установка nginx..." + case "$(get_pkg_manager)" in + apt) apt_update && apt_install nginx || return 1 ;; + dnf) dnf install -y -q nginx || return 1 ;; + yum) yum install -y -q nginx || return 1 ;; + esac + systemctl enable nginx 2>/dev/null +} + +# ── Установка certbot ──────────────────────────────────────────────────────── +install_certbot() { + if command -v certbot &>/dev/null; then + log_dim "certbot уже установлен" + return 0 + fi + log_info "Установка certbot..." + case "$(get_pkg_manager)" in + apt) apt_install certbot python3-certbot-nginx || return 1 ;; + dnf) dnf install -y -q certbot python3-certbot-nginx || return 1 ;; + yum) yum install -y -q certbot python3-certbot-nginx || return 1 ;; + esac +} + +# ── Генерация nginx конфига ────────────────────────────────────────────────── +generate_nginx_config() { + local domain="$1" + local proxy_port="${2:-443}" + local use_ssl="${3:-true}" + + mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled + + cat > "$NGINX_SITE_CONF" << 'EONGINX' +# GoTelegram v2.5.0 — nginx config +# Pro: nginx на 127.0.0.1:8443 (внутренний), telemt на 0.0.0.0:443 (внешний) +# Обычный браузер → :443 → telemt → 127.0.0.1:8443 → nginx (сайт) + +server { + listen 80; + listen [::]:80; + server_name DOMAIN_PLACEHOLDER; + + # Let's Encrypt ACME challenge + location /.well-known/acme-challenge/ { + root /var/www/certbot; + allow all; + } + + # Редирект на HTTPS + location / { + return 301 https://$server_name$request_uri; + } +} + +server { + listen 127.0.0.1:SSL_PORT_PLACEHOLDER ssl http2; + server_name DOMAIN_PLACEHOLDER; + + # SSL сертификаты + ssl_certificate /etc/letsencrypt/live/DOMAIN_PLACEHOLDER/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/DOMAIN_PLACEHOLDER/privkey.pem; + + # Современные TLS настройки + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + ssl_session_tickets off; + + # OCSP stapling + ssl_stapling on; + ssl_stapling_verify on; + + # Security headers + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Корень сайта + root /var/www/gotelegram-site; + index index.html; + + location / { + try_files $uri $uri/ =404; + expires 30d; + } + + # Кеширование статики + location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Скрываем служебные файлы + location ~ /\. { deny all; } + location = /robots.txt { allow all; log_not_found off; access_log off; } + location = /favicon.ico { log_not_found off; access_log off; } +} +EONGINX + + # Подставляем значения (используем | как разделитель, чтобы / в домене не ломал sed) + local escaped_domain + escaped_domain=$(printf '%s\n' "$domain" | sed 's/[&/\]/\\&/g') + sed -i "s|DOMAIN_PLACEHOLDER|${escaped_domain}|g" "$NGINX_SITE_CONF" + sed -i "s|SSL_PORT_PLACEHOLDER|${proxy_port}|g" "$NGINX_SITE_CONF" + + # Активируем сайт + rm -f /etc/nginx/sites-enabled/default 2>/dev/null + ln -sf "$NGINX_SITE_CONF" "$NGINX_SITE_LINK" + + log_success "nginx конфиг создан для $domain" +} + +# ── Временный конфиг (до получения SSL) ────────────────────────────────────── +generate_nginx_temp_config() { + local domain="$1" + + cat > "$NGINX_SITE_CONF" << EONGINX_TEMP +# GoTelegram — временный конфиг (до получения SSL) +server { + listen 80; + listen [::]:80; + server_name ${domain}; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + allow all; + } + + root /var/www/gotelegram-site; + index index.html; + + location / { + try_files \$uri \$uri/ =404; + } +} +EONGINX_TEMP + + rm -f /etc/nginx/sites-enabled/default 2>/dev/null + ln -sf "$NGINX_SITE_CONF" "$NGINX_SITE_LINK" + mkdir -p /var/www/certbot +} + +# ── Получение SSL сертификата ──────────────────────────────────────────────── +obtain_ssl_certificate() { + local domain="$1" + local email="${2:-}" + + if [ ! -d "/etc/letsencrypt/live/$domain" ]; then + log_info "Получение SSL сертификата для $domain..." + + # Временный конфиг для ACME challenge + generate_nginx_temp_config "$domain" + systemctl restart nginx 2>/dev/null + + local certbot_args=( + certonly + --webroot + -w /var/www/certbot + -d "$domain" + --non-interactive + --agree-tos + ) + + if [ -n "$email" ]; then + certbot_args+=(--email "$email") + else + certbot_args+=(--register-unsafely-without-email) + fi + + if certbot "${certbot_args[@]}" 2>/dev/null; then + log_success "SSL сертификат получен для $domain" + return 0 + else + log_error "Не удалось получить SSL сертификат" + log_dim "Убедитесь что домен $domain направлен на IP этого сервера" + log_dim "и порт 80 открыт в файрволе." + return 1 + fi + else + log_dim "SSL сертификат уже существует для $domain" + return 0 + fi +} + +# ── Авто-обновление сертификата ────────────────────────────────────────────── +setup_ssl_auto_renewal() { + # Certbot systemd timer (предпочтительно) + if [ -f /etc/systemd/system/certbot.timer ] || [ -f /lib/systemd/system/certbot.timer ]; then + systemctl enable certbot.timer 2>/dev/null + systemctl start certbot.timer 2>/dev/null + log_success "Авто-обновление SSL через systemd timer" + return 0 + fi + + # Fallback: cron + if ! crontab -l 2>/dev/null | grep -q "certbot renew"; then + (crontab -l 2>/dev/null; echo "0 3 * * * certbot renew --quiet --post-hook 'systemctl reload nginx'") | crontab - + log_success "Авто-обновление SSL через cron (3:00 ежедневно)" + fi +} + +# ── Обновление сертификата вручную ─────────────────────────────────────────── +renew_ssl_certificate() { + log_info "Обновление SSL сертификата..." + if certbot renew --quiet --post-hook "systemctl reload nginx" 2>/dev/null; then + log_success "Сертификат обновлён" + return 0 + else + log_error "Ошибка обновления сертификата" + return 1 + fi +} + +# ── Дата истечения SSL ─────────────────────────────────────────────────────── +get_ssl_expiry() { + local domain="$1" + local cert="/etc/letsencrypt/live/$domain/fullchain.pem" + if [ -f "$cert" ]; then + openssl x509 -enddate -noout -in "$cert" 2>/dev/null | sed 's/notAfter=//' + else + echo "N/A" + fi +} + +# ── Деплой шаблона сайта ───────────────────────────────────────────────────── +deploy_template_to_nginx() { + local template_dir="$1" + local template_id="${2:-}" + local source_url="" + + if [ ! -d "$template_dir" ] || [ ! -f "$template_dir/index.html" ]; then + log_error "Шаблон не содержит index.html: $template_dir" + return 1 + fi + [ -z "$template_id" ] && template_id=$(basename "$template_dir") + [ -f "$template_dir/.custom_git_source" ] && source_url=$(head -1 "$template_dir/.custom_git_source" 2>/dev/null || echo "") + + # Бекапим старый сайт + if [ -d "$WEBSITE_ROOT" ] && [ "$(ls -A "$WEBSITE_ROOT" 2>/dev/null)" ]; then + local backup_name="site_backup_$(date +%Y%m%d_%H%M%S)" + mv "$WEBSITE_ROOT" "/tmp/$backup_name" 2>/dev/null + log_dim "Старый сайт сохранён в /tmp/$backup_name" + fi + + mkdir -p "$WEBSITE_ROOT" + cp -a "$template_dir/." "$WEBSITE_ROOT/" + rm -f "$WEBSITE_ROOT/.custom_git_source" 2>/dev/null || true + echo "$template_id" > "$WEBSITE_ROOT/.gotelegram_template_id" 2>/dev/null || true + [ -n "$source_url" ] && echo "$source_url" > "$WEBSITE_ROOT/.gotelegram_template_source" 2>/dev/null || true + chown -R www-data:www-data "$WEBSITE_ROOT" 2>/dev/null || chown -R nginx:nginx "$WEBSITE_ROOT" 2>/dev/null + chmod -R 755 "$WEBSITE_ROOT" + + log_success "Шаблон развёрнут в $WEBSITE_ROOT" +} + +# ── Полная установка pro-режима ────────────────────────────────────────────── +setup_pro_mode() { + local domain="$1" + local template_dir="$2" + local proxy_port="${3:-443}" + local email="${4:-}" + + log_step "Настройка pro-режима" + + # 1. Устанавливаем nginx + run_with_spinner "Установка nginx" install_nginx || return 1 + + # 2. Устанавливаем certbot + run_with_spinner "Установка certbot" install_certbot || return 1 + + # 3. Деплоим шаблон сайта + deploy_template_to_nginx "$template_dir" || return 1 + + # 4. Получаем SSL + obtain_ssl_certificate "$domain" "$email" || return 1 + + # 5. Генерируем полный nginx конфиг с SSL + generate_nginx_config "$domain" "$proxy_port" + + # 6. Тестируем и перезапускаем nginx + if nginx -t 2>/dev/null; then + systemctl restart nginx + log_success "nginx запущен с SSL" + else + log_error "Ошибка в конфигурации nginx" + nginx -t + return 1 + fi + + # 7. Настраиваем авто-обновление SSL + setup_ssl_auto_renewal + + # 8. Показываем благодарности авторам шаблонов + show_credits + + log_success "Pro-режим настроен: https://${domain}" + return 0 +} + +# ── Управление nginx ──────────────────────────────────────────────────────── +nginx_status() { + if systemctl is-active --quiet nginx 2>/dev/null; then + echo "running" + else + echo "stopped" + fi +} + +restart_nginx() { + if nginx -t 2>/dev/null; then + systemctl restart nginx 2>/dev/null + log_success "nginx перезапущен" + else + log_error "Ошибка конфигурации nginx" + nginx -t + return 1 + fi +} + +# ── Удаление pro-режима ────────────────────────────────────────────────────── +remove_pro_mode() { + log_info "Удаление pro-режима..." + rm -f "$NGINX_SITE_CONF" "$NGINX_SITE_LINK" + rm -rf "$WEBSITE_ROOT" + systemctl restart nginx 2>/dev/null + log_success "Pro-режим удалён (nginx оставлен)" +} + +# ── Смена шаблона ──────────────────────────────────────────────────────────── +switch_template() { + local new_template_dir="$1" + deploy_template_to_nginx "$new_template_dir" "$(basename "$new_template_dir")" + # nginx не требует перезапуска — статика обновилась на месте + log_success "Шаблон сайта обновлён" +} diff --git a/templates_catalog.json b/templates_catalog.json new file mode 100644 index 0000000..c4125eb --- /dev/null +++ b/templates_catalog.json @@ -0,0 +1,16339 @@ +{ + "categories": [ + { + "id": "business", + "name": "Бизнес и корпоративные", + "icon": "briefcase", + "templates": [ + { + "id": "h5up_aerial", + "name": "Aerial", + "source": "html5up", + "description": "Корпоративный сайт с минималистичным дизайном", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "aerial", + "preview_url": "https://html5up.net/aerial/" + }, + { + "id": "h5up_alpha", + "name": "Alpha", + "source": "html5up", + "description": "Профессиональный корпоративный шаблон", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "alpha", + "preview_url": "https://html5up.net/alpha/" + }, + { + "id": "h5up_arcana", + "name": "Arcana", + "source": "html5up", + "description": "Стильный деловой сайт с эффектами", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "arcana", + "preview_url": "https://html5up.net/arcana/" + }, + { + "id": "h5up_directive", + "name": "Directive", + "source": "html5up", + "description": "Компактный корпоративный шаблон", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "directive", + "preview_url": "https://html5up.net/directive/" + }, + { + "id": "h5up_dimension", + "name": "Dimension", + "source": "html5up", + "description": "Элегантный деловой портал", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "dimension", + "preview_url": "https://html5up.net/dimension/" + }, + { + "id": "h5up_editorial", + "name": "Editorial", + "source": "html5up", + "description": "Деловой журнальный сайт", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "editorial", + "preview_url": "https://html5up.net/editorial/" + }, + { + "id": "h5up_helios", + "name": "Helios", + "source": "html5up", + "description": "Современный бизнес-сайт", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "helios", + "preview_url": "https://html5up.net/helios/" + }, + { + "id": "h5up_identity", + "name": "Identity", + "source": "html5up", + "description": "Профильная карточка компании", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "identity", + "preview_url": "https://html5up.net/identity/" + }, + { + "id": "lz_atlanta_business", + "name": "Atlanta Business", + "source": "learning-zone", + "description": "Шаблон для бизнес-консультаций", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "atlanta-free-business-bootstrap-template", + "preview_url": "https://learning-zone.github.io/website-templates/atlanta-free-business-bootstrap-template/" + }, + { + "id": "lz_creative_bee", + "name": "Creative Bee Corporate", + "source": "learning-zone", + "description": "Креативное корпоративное агентство", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "creative-bee-corporate-free-html5-web-template", + "preview_url": "https://learning-zone.github.io/website-templates/creative-bee-corporate-free-html5-web-template/" + }, + { + "id": "lz_frames_corporate", + "name": "Frames Corporate", + "source": "learning-zone", + "description": "Корпоративный сайт с фреймами", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "frames-corporate-bootstrap-free-html5-template", + "preview_url": "https://learning-zone.github.io/website-templates/frames-corporate-bootstrap-free-html5-template/" + }, + { + "id": "lz_ninja_consulting", + "name": "Ninja Business Consulting", + "source": "learning-zone", + "description": "Сайт бизнес-консультаций", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "ninja-business-consulting-html-responsive-web-template", + "preview_url": "https://learning-zone.github.io/website-templates/ninja-business-consulting-html-responsive-web-template/" + }, + { + "id": "lz_vone_business", + "name": "Vone Business", + "source": "learning-zone", + "description": "Адаптивный бизнес-шаблон", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "vone-free-business-html5-responsive-website", + "preview_url": "https://learning-zone.github.io/website-templates/vone-free-business-html5-responsive-website/" + }, + { + "id": "lz_kavin_corporate", + "name": "Kavin Corporate", + "source": "learning-zone", + "description": "Элегантный корпоративный портал", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "kavin-corporate-bootstrap-responsive-web-template", + "preview_url": "https://learning-zone.github.io/website-templates/kavin-corporate-bootstrap-responsive-web-template/" + }, + { + "id": "lz_swifty_business", + "name": "Swifty Business", + "source": "learning-zone", + "description": "Быстрый и стильный бизнес-сайт", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "swifty-business-html5-website-template", + "preview_url": "https://learning-zone.github.io/website-templates/swifty-business-html5-website-template/" + }, + { + "id": "lz_techking_corporate", + "name": "TechKing Corporate", + "source": "learning-zone", + "description": "IT-компании и технологический бизнес", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "techking-free-html5-template-for-corporate-business", + "preview_url": "https://learning-zone.github.io/website-templates/techking-free-html5-template-for-corporate-business/" + }, + { + "id": "lz_everest_corporate", + "name": "Everest Corporate", + "source": "learning-zone", + "description": "Профессиональный корпоративный портал", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "everest-corporate-business-bootstrap-template", + "preview_url": "https://learning-zone.github.io/website-templates/everest-corporate-business-bootstrap-template/" + }, + { + "id": "sb_agency", + "name": "StartBootstrap Agency", + "source": "startbootstrap", + "description": "Сайт креативного агентства", + "repo_url": "https://github.com/StartBootstrap/startbootstrap-agency", + "sparse_path": ".", + "preview_url": "https://startbootstrap.github.io/startbootstrap-agency/" + }, + { + "id": "sb_modern_business", + "name": "StartBootstrap Modern Business", + "source": "startbootstrap", + "description": "Современный шаблон для бизнеса", + "repo_url": "https://github.com/StartBootstrap/startbootstrap-modern-business", + "sparse_path": ".", + "preview_url": "https://startbootstrap.github.io/startbootstrap-modern-business/" + }, + { + "id": "th_bizpage", + "name": "Bizpage", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/bizpage", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/bizpage/", + "description": "Free Bootstrap 4 Business Template" + }, + { + "id": "th_boxus", + "name": "Boxus", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/boxus", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/boxus/", + "description": "Boxus is a free HTML website template for the creative agency. Its boxed style and unique layout will help you create any website that attracts more audience." + }, + { + "id": "th_glint", + "name": "Glint", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/glint", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/glint/", + "description": "A digital agency website template. It's free to download and easy to customize." + }, + { + "id": "th_office", + "name": "Office", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Office", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Office/", + "description": "Office - Free Responsive Multipage Bootstrap Template for Small and Medium Business" + }, + { + "id": "th_startup", + "name": "Startup", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/startup", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/startup/", + "description": "Startup" + }, + { + "id": "th_startup2", + "name": "Startup2", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/startup2", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/startup2/", + "description": "Startup2" + }, + { + "id": "th_euro_travels", + "name": "Euro Travels", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Euro-Travels", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Euro-Travels/", + "description": "A Free Responsive Agency Template " + }, + { + "id": "th_blue", + "name": "Blue", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/blue", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/blue/", + "description": "Free One Page Bootstrap Agency Template" + }, + { + "id": "th_cleaning_company", + "name": "Cleaning Company", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/cleaning-company", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/cleaning-company/", + "description": "Cleaning Company" + }, + { + "id": "th_agency_2", + "name": "Agency 2", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/agency-2", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/agency-2/", + "description": "Agency 2" + }, + { + "id": "th_finances", + "name": "Finances", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/finances", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/finances/", + "description": "Finances" + }, + { + "id": "th_finance_business", + "name": "Finance Business", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/finance-business", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/finance-business/", + "description": "Finance Business" + }, + { + "id": "th_fotogency", + "name": "Fotogency", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/fotogency", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/fotogency/", + "description": "A Photography Agency Web Template" + }, + { + "id": "th_constructioncompany", + "name": "Constructioncompany", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/constructioncompany", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/constructioncompany/", + "description": "Constructioncompany" + }, + { + "id": "th_law", + "name": "Law", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/law", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/law/", + "description": "A Free Bootstrap 4 Law Firm Template for your convenience" + }, + { + "id": "th_startupbusiness", + "name": "Startupbusiness", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/startupbusiness", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/startupbusiness/", + "description": "Startupbusiness" + }, + { + "id": "th_law_firm", + "name": "Law Firm", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/law-firm", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/law-firm/", + "description": "Law Firm" + }, + { + "id": "th_open_enterprise", + "name": "Open Enterprise", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/open-enterprise", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/open-enterprise/", + "description": "Open Enterprise" + }, + { + "id": "th_consultingbiz", + "name": "Consultingbiz", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/consultingbiz", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/consultingbiz/", + "description": "Consultingbiz" + }, + { + "id": "th_navigator", + "name": "Navigator", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/navigator", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/navigator/", + "description": "Free One Page Agency Template" + }, + { + "id": "th_industrious", + "name": "Industrious", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/industrious", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/industrious/", + "description": "It's Industrious, a responsive template for business websites. Let's get it here:" + }, + { + "id": "th_robot_factory", + "name": "Robot Factory", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/robot_factory", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/robot_factory/", + "description": "Best Responsive Free HTML5 Template for a Start-up Factory, industry or company" + }, + { + "id": "th_traveler", + "name": "Traveler", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/traveler", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/traveler/", + "description": "Bootstrap template for travel agency and itinerary websites." + }, + { + "id": "th_avada_agency_pro", + "name": "Avada Agency Pro", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/avada-agency-pro", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/avada-agency-pro/", + "description": "Avada Agency Pro" + }, + { + "id": "th_estartup", + "name": "Estartup", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/estartup", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/estartup/", + "description": "Estartup" + }, + { + "id": "th_creativeagency", + "name": "Creativeagency", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/creativeagency", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/creativeagency/", + "description": "Creativeagency" + }, + { + "id": "th_renessa", + "name": "Renessa", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Renessa", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Renessa/", + "description": "Renessa - A Free Responsive Multipurpose Bootstrap Template for Agency and Personal Website" + }, + { + "id": "th_themelight", + "name": "Themelight", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/themelight", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/themelight/", + "description": "Free Bootstrap Business Template" + }, + { + "id": "th_reveal", + "name": "Reveal", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/reveal", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/reveal/", + "description": "Bootstrap 4 based business template with a compelling design. Download now:" + }, + { + "id": "th_outing", + "name": "Outing", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/outing", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/outing/", + "description": "A highly efficient Bootstrap 4 website template for travel agency. Download here:" + }, + { + "id": "th_b_hero", + "name": "B Hero", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/b-hero", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/b-hero/", + "description": "Business website template built on Bootstrap 4. It's free and mobile-friendly." + }, + { + "id": "th_lawfirm", + "name": "Lawfirm", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/lawfirm", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/lawfirm/", + "description": "Lawfirm" + }, + { + "id": "th_airspace", + "name": "Airspace", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/airspace", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/airspace/", + "description": "Responsive Business Agency Template Free" + }, + { + "id": "th_ebusiness", + "name": "Ebusiness", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/ebusiness", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/ebusiness/", + "description": "Ebusiness" + }, + { + "id": "th_businessbox", + "name": "Businessbox", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/businessbox", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/businessbox/", + "description": "A stunning business template with Bootstrap 4 and multi-page." + }, + { + "id": "th_consulting", + "name": "Consulting", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/consulting", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/consulting/", + "description": "Consulting" + }, + { + "id": "th_sulfer_multipage_business_html5_template", + "name": "Sulfer Multipage Business Html5 Template", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/sulfer-multipage-business-html5-template", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/sulfer-multipage-business-html5-template/", + "description": "Sulfer Multipage Business Html5 Template" + }, + { + "id": "th_go_crepe", + "name": "Go Crepe", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/go-crepe", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/go-crepe/", + "description": "Go Crepe is free HTML5 business template with one page layout." + }, + { + "id": "th_agency", + "name": "Agency", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/agency", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/agency/", + "description": "Multipage Responsive Agency Template" + }, + { + "id": "th_transitive", + "name": "Transitive", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/transitive", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/transitive/", + "description": "Transitive is a free HTML5 business template with parallax and video background. Download now from the link below:" + }, + { + "id": "th_the_seo_company", + "name": "The Seo Company", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/the-seo-company", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/the-seo-company/", + "description": "The Seo Company" + }, + { + "id": "th_design4profit", + "name": "Design4Profit", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Design4Profit", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Design4Profit/", + "description": "Design4Profit - An Elegant Responsive Business Template for large, medium and small company." + }, + { + "id": "th_creative_agency_2", + "name": "Creative Agency 2", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/creative-agency-2", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/creative-agency-2/", + "description": "Creative Agency 2" + }, + { + "id": "th_creative_bootstrap_4", + "name": "Creative Bootstrap 4", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/creative-bootstrap-4", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/creative-bootstrap-4/", + "description": "Get this Bootstrap 4 template to create a compelling business website." + }, + { + "id": "th_creative_star", + "name": "Creative Star", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Creative-STAR", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Creative-STAR/", + "description": "A Free One Page Agency Bootstrap Template " + }, + { + "id": "th_heado", + "name": "Heado", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Heado", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Heado/", + "description": "Free Web Agency Website Template" + }, + { + "id": "th_villaagency", + "name": "Villaagency", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/VillaAgency", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/VillaAgency/", + "description": "Villaagency" + }, + { + "id": "th_alien", + "name": "Alien", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/alien", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/alien/", + "description": "Free Responsive Business Website Template" + }, + { + "id": "th_buildermax", + "name": "Buildermax", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/BuilderMax", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/BuilderMax/", + "description": "Free Responive Business Website Template" + }, + { + "id": "th_boravio_dk", + "name": "Boravio.Dk", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Boravio.dk", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Boravio.dk/", + "description": "Landingpage for a danish startup online shop 💻⚙️" + }, + { + "id": "th_lifesure", + "name": "Lifesure", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/LifeSure", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/LifeSure/", + "description": "Free Business & corporate Website Template" + }, + { + "id": "th_acuas", + "name": "Acuas", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/acuas", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/acuas/", + "description": "Free Bootstrap 5 Business Website Template" + }, + { + "id": "th_travisa", + "name": "Travisa", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Travisa", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Travisa/", + "description": "Free Bootstrap 5 Responsive Business Website Template" + }, + { + "id": "th_investa", + "name": "Investa", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Investa", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Investa/", + "description": "Free Investment Business Website Template" + }, + { + "id": "th_mailler", + "name": "Mailler", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Mailler", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Mailler/", + "description": "Free Multipage Responsive Bootstrap 5 Business Website Template" + }, + { + "id": "th_stocker", + "name": "Stocker", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/stocker", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/stocker/", + "description": "Free Bootstrap 5 Stock Business Website Template" + }, + { + "id": "th_electricca", + "name": "Electricca", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/electricca", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/electricca/", + "description": "Free Bootstrap 5 Business Website Template" + }, + { + "id": "th_plumberz", + "name": "Plumberz", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Plumberz", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Plumberz/", + "description": "Free Bootstrap 5 Business Website Template" + }, + { + "id": "th_flare", + "name": "Flare", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/flare", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/flare/", + "description": "Free Agency Landing Page Template" + }, + { + "id": "th_hvac_new", + "name": "Hvac New", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/hvac-new", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/hvac-new/", + "description": "Free Bootstrap 5 Business Website Template" + }, + { + "id": "th_selecao", + "name": "Selecao", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Selecao", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Selecao/", + "description": "Free Bootstrap 5 Business Website Template" + }, + { + "id": "th_active", + "name": "Active", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/active", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/active/", + "description": "Free Bootstrap 5 Business Website Template" + }, + { + "id": "th_impact", + "name": "Impact", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Impact", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Impact/", + "description": "Free Bootstrap 5 Business Website Template" + }, + { + "id": "th_nova_new", + "name": "Nova New", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/nova-new", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/nova-new/", + "description": "Free Bootstrap 5 Business Website Template" + }, + { + "id": "th_bizland", + "name": "Bizland", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/BizLand", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/BizLand/", + "description": "Free Bootstrap 5 Business Website Template" + }, + { + "id": "th_company", + "name": "Company", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Company", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Company/", + "description": "Free Bootstrap 5 Website Template" + }, + { + "id": "th_romyk", + "name": "Romyk", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Romyk", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Romyk/", + "description": "Free Business Website Template" + }, + { + "id": "th_vesperr", + "name": "Vesperr", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/vesperr", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/vesperr/", + "description": "Download this free agency template from:" + }, + { + "id": "th_estateagency", + "name": "Estateagency", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/estateagency", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/estateagency/", + "description": "Estateagency" + }, + { + "id": "th_inazuma_tailwind", + "name": "Inazuma Tailwind", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/inazuma-tailwind", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/inazuma-tailwind/", + "description": "A company landing page website template built with Tailwind CSS. " + }, + { + "id": "th_startup_nextjs", + "name": "Startup Nextjs", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/startup-nextjs", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/startup-nextjs/", + "description": "Startup Nextjs" + }, + { + "id": "th_business", + "name": "Business", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Business", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Business/", + "description": "Business" + }, + { + "id": "th_pms_investment_services", + "name": "Pms Investment Services", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/pms-investment-services", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/pms-investment-services/", + "description": "PMS Next.js Finance Website Template" + }, + { + "id": "th_agency_tailwind", + "name": "Agency Tailwind", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/agency-tailwind", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/agency-tailwind/", + "description": "Agency - Tailwind Agency landing page Template" + }, + { + "id": "da_agile_agency_free_bootstrap_web_template", + "name": "Agile Agency Free Bootstrap Web Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "agile-agency-free-bootstrap-web-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/agile-agency-free-bootstrap-web-template/", + "description": "Agile Agency Free Bootstrap Web Template" + }, + { + "id": "da_atlanta_free_business_bootstrap_template", + "name": "Atlanta Free Business Bootstrap Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "atlanta-free-business-bootstrap-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/atlanta-free-business-bootstrap-template/", + "description": "Atlanta Free Business Bootstrap Template" + }, + { + "id": "da_businessline_corporate_portfolio_bootstrap_responsive_web", + "name": "Businessline Corporate Portfolio Bootstrap Responsive Web Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "businessline-corporate-portfolio-bootstrap-responsive-web-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/businessline-corporate-portfolio-bootstrap-responsive-web-template/", + "description": "Businessline Corporate Portfolio Bootstrap Responsive Web Template" + }, + { + "id": "da_businessr_corporate_bootstrap_responsive_web_template", + "name": "Businessr Corporate Bootstrap Responsive Web Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "businessr-corporate-bootstrap-responsive-web-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/businessr-corporate-bootstrap-responsive-web-template/", + "description": "Businessr Corporate Bootstrap Responsive Web Template" + }, + { + "id": "da_creative_bee_corporate_free_html5_web_template", + "name": "Creative Bee Corporate Free Html5 Web Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "creative-bee-corporate-free-html5-web-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/creative-bee-corporate-free-html5-web-template/", + "description": "Creative Bee Corporate Free Html5 Web Template" + }, + { + "id": "da_creative_free_responsive_html5_business_template", + "name": "Creative Free Responsive Html5 Business Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "creative-free-responsive-html5-business-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/creative-free-responsive-html5-business-template/", + "description": "Creative Free Responsive Html5 Business Template" + }, + { + "id": "da_darktouch_corporate_portfolio_bootstrap_responsive_web_te", + "name": "Darktouch Corporate Portfolio Bootstrap Responsive Web Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "darktouch-corporate-portfolio-bootstrap-responsive-web-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/darktouch-corporate-portfolio-bootstrap-responsive-web-template/", + "description": "Darktouch Corporate Portfolio Bootstrap Responsive Web Template" + }, + { + "id": "da_enlive_corporate_free_html5_bootstrap_web_template", + "name": "Enlive Corporate Free Html5 Bootstrap Web Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "enlive-corporate-free-html5-bootstrap-web-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/enlive-corporate-free-html5-bootstrap-web-template/", + "description": "Enlive Corporate Free Html5 Bootstrap Web Template" + }, + { + "id": "da_everest_corporate_business_bootstrap_template", + "name": "Everest Corporate Business Bootstrap Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "everest-corporate-business-bootstrap-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/everest-corporate-business-bootstrap-template/", + "description": "Everest Corporate Business Bootstrap Template" + }, + { + "id": "da_frames_corporate_bootstrap_free_html5_template", + "name": "Frames Corporate Bootstrap Free Html5 Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "frames-corporate-bootstrap-free-html5-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/frames-corporate-bootstrap-free-html5-template/", + "description": "Frames Corporate Bootstrap Free Html5 Template" + }, + { + "id": "da_free_bootstrap_template_rockline_business", + "name": "Free Bootstrap Template Rockline Business", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "free-bootstrap-template-rockline-business", + "preview_url": "https://dawidolko.github.io/Website-Templates/free-bootstrap-template-rockline-business/", + "description": "Free Bootstrap Template Rockline Business" + }, + { + "id": "da_kavin_corporate_bootstrap_responsive_web_template", + "name": "Kavin Corporate Bootstrap Responsive Web Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "kavin-corporate-bootstrap-responsive-web-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/kavin-corporate-bootstrap-responsive-web-template/", + "description": "Kavin Corporate Bootstrap Responsive Web Template" + }, + { + "id": "da_moto_business_html5_responsive_web_template", + "name": "Moto Business Html5 Responsive Web Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "moto-business-html5-responsive-web-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/moto-business-html5-responsive-web-template/", + "description": "Moto Business Html5 Responsive Web Template" + }, + { + "id": "da_ninja_business_consulting_html_responsive_web_template", + "name": "Ninja Business Consulting Html Responsive Web Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "ninja-business-consulting-html-responsive-web-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/ninja-business-consulting-html-responsive-web-template/", + "description": "Ninja Business Consulting Html Responsive Web Template" + }, + { + "id": "da_retro_free_consulting_responsive_html5_website_template", + "name": "Retro Free Consulting Responsive Html5 Website Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "retro-free-consulting-responsive-html5-website-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/retro-free-consulting-responsive-html5-website-template/", + "description": "Retro Free Consulting Responsive Html5 Website Template" + }, + { + "id": "da_rocket_business_bootstrap_free_responsive_web_theme", + "name": "Rocket Business Bootstrap Free Responsive Web Theme", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "rocket-business-bootstrap-free-responsive-web-theme", + "preview_url": "https://dawidolko.github.io/Website-Templates/rocket-business-bootstrap-free-responsive-web-theme/", + "description": "Rocket Business Bootstrap Free Responsive Web Theme" + }, + { + "id": "da_startbootstrap_agency_1_0_2", + "name": "Agency 1.0.2", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "startbootstrap-agency-1.0.2", + "preview_url": "https://dawidolko.github.io/Website-Templates/startbootstrap-agency-1.0.2/", + "description": "Agency 1.0.2" + }, + { + "id": "da_swifty_business_html5_website_template", + "name": "Swifty Business Html5 Website Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "swifty-business-html5-website-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/swifty-business-html5-website-template/", + "description": "Swifty Business Html5 Website Template" + }, + { + "id": "da_team_business_flat_bootstrap_html5_template", + "name": "Team Business Flat Bootstrap Html5 Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "team-business-flat-bootstrap-html5-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/team-business-flat-bootstrap-html5-template/", + "description": "Team Business Flat Bootstrap Html5 Template" + }, + { + "id": "da_techking_free_html5_template_for_corporate_business", + "name": "Techking Free Html5 Template For Corporate Business", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "techking-free-html5-template-for-corporate-business", + "preview_url": "https://dawidolko.github.io/Website-Templates/techking-free-html5-template-for-corporate-business/", + "description": "Techking Free Html5 Template For Corporate Business" + }, + { + "id": "da_times_corporate_portfolio_bootstrap_responsive_web_templa", + "name": "Times Corporate Portfolio Bootstrap Responsive Web Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "times-corporate-portfolio-bootstrap-responsive-web-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/times-corporate-portfolio-bootstrap-responsive-web-template/", + "description": "Times Corporate Portfolio Bootstrap Responsive Web Template" + }, + { + "id": "da_vibe_free_html5_template_for_corporate_website", + "name": "Vibe Free Html5 Template For Corporate Website", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "vibe-free-html5-template-for-corporate-website", + "preview_url": "https://dawidolko.github.io/Website-Templates/vibe-free-html5-template-for-corporate-website/", + "description": "Vibe Free Html5 Template For Corporate Website" + }, + { + "id": "da_vibrant_corporate_bootstrap_responsive_website_template", + "name": "Vibrant Corporate Bootstrap Responsive Website Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "vibrant-corporate-bootstrap-responsive-website-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/vibrant-corporate-bootstrap-responsive-website-template/", + "description": "Vibrant Corporate Bootstrap Responsive Website Template" + }, + { + "id": "da_vone_free_business_html5_responsive_website", + "name": "Vone Free Business Html5 Responsive Website", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "vone-free-business-html5-responsive-website", + "preview_url": "https://dawidolko.github.io/Website-Templates/vone-free-business-html5-responsive-website/", + "description": "Vone Free Business Html5 Responsive Website" + }, + { + "id": "da_vteam_a_corporate_multipurpose_free_bootstrap_responsive_", + "name": "Vteam A Corporate Multipurpose Free Bootstrap Responsive Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "vteam-a-corporate-multipurpose-free-bootstrap-responsive-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/vteam-a-corporate-multipurpose-free-bootstrap-responsive-template/", + "description": "Vteam A Corporate Multipurpose Free Bootstrap Responsive Template" + } + ] + }, + { + "id": "ecommerce", + "name": "Интернет-магазины", + "icon": "shopping-cart", + "templates": [ + { + "id": "h5up_overflow", + "name": "Overflow", + "source": "html5up", + "description": "Портал онлайн-магазина", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "overflow", + "preview_url": "https://html5up.net/overflow/" + }, + { + "id": "h5up_minimaxing", + "name": "Minimaxing", + "source": "html5up", + "description": "Минималистичный магазин", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "minimaxing", + "preview_url": "https://html5up.net/minimaxing/" + }, + { + "id": "h5up_multiverse", + "name": "Multiverse", + "source": "html5up", + "description": "Мультифункциональный магазин", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "multiverse", + "preview_url": "https://html5up.net/multiverse/" + }, + { + "id": "h5up_telephasic", + "name": "Telephasic", + "source": "html5up", + "description": "Современный интернет-магазин", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "telephasic", + "preview_url": "https://html5up.net/telephasic/" + }, + { + "id": "h5up_miniport", + "name": "Miniport", + "source": "html5up", + "description": "Компактный каталог товаров", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "miniport", + "preview_url": "https://html5up.net/miniport/" + }, + { + "id": "th_cozastore", + "name": "Cozastore", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/cozastore", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/cozastore/", + "description": "Cozastore" + }, + { + "id": "th_eshopper", + "name": "Eshopper", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/eshopper", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/eshopper/", + "description": "Eshopper" + }, + { + "id": "th_zay_shop", + "name": "Zay Shop", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/zay-shop", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/zay-shop/", + "description": "Zay Shop" + }, + { + "id": "th_shop", + "name": "Shop", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/shop", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/shop/", + "description": "Shop" + }, + { + "id": "th_hexashop", + "name": "Hexashop", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/hexashop", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/hexashop/", + "description": "Hexashop" + }, + { + "id": "th_coloshop", + "name": "Coloshop", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/coloshop", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/coloshop/", + "description": "Coloshop" + }, + { + "id": "th_productly", + "name": "Productly", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/productly", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/productly/", + "description": "Productly" + }, + { + "id": "th_metronic_shop_ui", + "name": "Metronic Shop Ui", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Metronic-Shop-UI", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Metronic-Shop-UI/", + "description": "Metronic Shop Ui" + }, + { + "id": "th_multishop", + "name": "Multishop", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/multishop", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/multishop/", + "description": "Multishop" + }, + { + "id": "th_freshshop", + "name": "Freshshop", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/freshshop", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/freshshop/", + "description": "Freshshop" + }, + { + "id": "th_minishop", + "name": "Minishop", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/minishop", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/minishop/", + "description": "Minishop" + }, + { + "id": "th_shoppers", + "name": "Shoppers", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/shoppers", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/shoppers/", + "description": "Shoppers" + }, + { + "id": "th_adminmart", + "name": "Adminmart", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/adminmart", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/adminmart/", + "description": "Adminmart" + }, + { + "id": "th_product_admin", + "name": "Product Admin", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/product-admin", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/product-admin/", + "description": "Product Admin" + }, + { + "id": "th_liquorstore", + "name": "Liquorstore", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/liquorstore", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/liquorstore/", + "description": "Liquorstore" + }, + { + "id": "th_smartedu", + "name": "Smartedu", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/smartedu", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/smartedu/", + "description": "Smartedu" + }, + { + "id": "th_shopmax", + "name": "Shopmax", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/shopmax", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/shopmax/", + "description": "Shopmax" + }, + { + "id": "th_pillowmart", + "name": "Pillowmart", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/pillowmart", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/pillowmart/", + "description": "Pillowmart" + }, + { + "id": "th_vex_bootstrap_4_free_product_landing_page_template", + "name": "Vex Bootstrap 4 Free Product Landing Page Template", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/vex-Bootstrap-4-Free-product-landing-page-template", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/vex-Bootstrap-4-Free-product-landing-page-template/", + "description": "Free Bootstrap 4 landing page template to download" + }, + { + "id": "th_estore", + "name": "Estore", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/estore", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/estore/", + "description": "Estore" + }, + { + "id": "th_martine", + "name": "Martine", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/martine", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/martine/", + "description": "Martine" + }, + { + "id": "th_evie", + "name": "Evie", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/evie", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/evie/", + "description": "A production-ready theme for your projects with a minimal style guide https://evie.undraw.co" + }, + { + "id": "th_ministore", + "name": "Ministore", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/MiniStore", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/MiniStore/", + "description": "Ministore" + }, + { + "id": "th_coffo", + "name": "Coffo", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Coffo", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Coffo/", + "description": "Free Coffee Shop Website Template" + }, + { + "id": "th_bookly", + "name": "Bookly", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Bookly", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Bookly/", + "description": "Free HTML5 eCommerce Website Template" + }, + { + "id": "th_organic", + "name": "Organic", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/organic", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/organic/", + "description": "Free eCommerce Website template" + }, + { + "id": "th_booksaw", + "name": "Booksaw", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/booksaw", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/booksaw/", + "description": "Free eCommerce Website template" + }, + { + "id": "th_stylish", + "name": "Stylish", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/stylish", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/stylish/", + "description": "Free eCommerce Website template" + }, + { + "id": "th_foodmart", + "name": "Foodmart", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/FoodMart", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/FoodMart/", + "description": "Free Bootstrap 5 eCom Website Template" + }, + { + "id": "th_kaira", + "name": "Kaira", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/kaira", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/kaira/", + "description": "Free Bootstrap 5 eCommerce Website template" + }, + { + "id": "th_nextmerce_nextjs", + "name": "Nextmerce Nextjs", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/nextmerce-nextjs", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/nextmerce-nextjs/", + "description": "NextMerce - Free Next.js eCommerce Template" + }, + { + "id": "th_nordic", + "name": "Nordic", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/nordic", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/nordic/", + "description": "Nordic Store - Minimal ecommerce product listing template" + }, + { + "id": "da_coffee_shop_free_html5_template", + "name": "Coffee Shop Free Html5 Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "coffee-shop-free-html5-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/coffee-shop-free-html5-template/", + "description": "Coffee Shop Free Html5 Template" + }, + { + "id": "da_smart_interior_designs_html5_bootstrap_web_template", + "name": "Smart Interior Designs Html5 Bootstrap Web Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "smart-interior-designs-html5-bootstrap-web-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/smart-interior-designs-html5-bootstrap-web-template/", + "description": "Smart Interior Designs Html5 Bootstrap Web Template" + } + ] + }, + { + "id": "medical", + "name": "Медицина и фитнес", + "icon": "heart", + "templates": [ + { + "id": "lz_medplus_medical", + "name": "MedPlus Medical", + "source": "learning-zone", + "description": "Сайт медицинской клиники", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "medplus-medical", + "preview_url": "https://learning-zone.github.io/website-templates/medplus-medical/" + }, + { + "id": "lz_vcare_hospital", + "name": "VCare Hospital", + "source": "learning-zone", + "description": "Шаблон для больницы", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "vcare-free-html5-template-hospital-website", + "preview_url": "https://learning-zone.github.io/website-templates/vcare-free-html5-template-hospital-website/" + }, + { + "id": "lz_touch_hospital", + "name": "Touch Hospital", + "source": "learning-zone", + "description": "Медицинский центр на Bootstrap", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "touch-hospital-medical-bootstrap-html5-template", + "preview_url": "https://learning-zone.github.io/website-templates/touch-hospital-medical-bootstrap-html5-template/" + }, + { + "id": "lz_fit_healthy_fitness", + "name": "Fit Healthy Fitness", + "source": "learning-zone", + "description": "Фитнес-клуб и спортзал", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "fit-healthy-fitness-and-gym-html5-bootstrap-theme", + "preview_url": "https://learning-zone.github.io/website-templates/fit-healthy-fitness-and-gym-html5-bootstrap-theme/" + }, + { + "id": "lz_fitness_zone", + "name": "Fitness Zone", + "source": "learning-zone", + "description": "Зона фитнеса и тренировок", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "fitness-zone-html5-bootstrap-responsive-web-template", + "preview_url": "https://learning-zone.github.io/website-templates/fitness-zone-html5-bootstrap-responsive-web-template/" + }, + { + "id": "th_gymlife", + "name": "Gymlife", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/gymlife", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/gymlife/", + "description": "Gymlife" + }, + { + "id": "th_medicalcenter", + "name": "Medicalcenter", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/medicalcenter", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/medicalcenter/", + "description": "Medicalcenter" + }, + { + "id": "th_physicaltherapy", + "name": "Physicaltherapy", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/physicaltherapy", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/physicaltherapy/", + "description": "Physicaltherapy" + }, + { + "id": "th_lifecare", + "name": "Lifecare", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/lifecare", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/lifecare/", + "description": "Free website template for hospitals and clinics." + }, + { + "id": "th_health_center", + "name": "Health Center", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/health-center", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/health-center/", + "description": "Health Center" + }, + { + "id": "th_healthcouch", + "name": "Healthcouch", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/healthcouch", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/healthcouch/", + "description": "Healthcouch" + }, + { + "id": "th_yogaflex", + "name": "Yogaflex", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/yogaflex", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/yogaflex/", + "description": "Yogaflex" + }, + { + "id": "th_yogalax", + "name": "Yogalax", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/yogalax", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/yogalax/", + "description": "Yogalax" + }, + { + "id": "th_fitnessclub", + "name": "Fitnessclub", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/fitnessclub", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/fitnessclub/", + "description": "Fitnessclub" + }, + { + "id": "th_healthcoach", + "name": "Healthcoach", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/healthcoach", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/healthcoach/", + "description": "Healthcoach" + }, + { + "id": "th_yoga", + "name": "Yoga", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/yoga", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/yoga/", + "description": "Yoga" + }, + { + "id": "th_top_gym", + "name": "Top Gym", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/top-gym", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/top-gym/", + "description": "Top Gym" + }, + { + "id": "th_xgym", + "name": "Xgym", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/xgym", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/xgym/", + "description": "Xgym" + }, + { + "id": "th_gym2", + "name": "Gym2", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/gym2", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/gym2/", + "description": "Gym2" + }, + { + "id": "th_yoga2", + "name": "Yoga2", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/yoga2", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/yoga2/", + "description": "Yoga2" + }, + { + "id": "th_gymer", + "name": "Gymer", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/gymer", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/gymer/", + "description": "Gymer" + }, + { + "id": "th_gym", + "name": "Gym", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/gym", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/gym/", + "description": "Gym" + }, + { + "id": "th_fitnesstrainer", + "name": "Fitnesstrainer", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/fitnesstrainer", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/fitnesstrainer/", + "description": "Fitnesstrainer" + }, + { + "id": "th_fitness_1", + "name": "Fitness 1", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/fitness-1", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/fitness-1/", + "description": "Free Bootstrap 4 fitness website template" + }, + { + "id": "th_yogasan", + "name": "Yogasan", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Yogasan", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Yogasan/", + "description": "Yogasan" + }, + { + "id": "th_yogaclass", + "name": "Yogaclass", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/yogaClass", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/yogaClass/", + "description": "Yogaclass" + }, + { + "id": "th_medilab", + "name": "Medilab", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/MediLab", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/MediLab/", + "description": "Free Bootstrap 5 Medical Website Template" + }, + { + "id": "th_medicio", + "name": "Medicio", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/MediCio", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/MediCio/", + "description": "Free Bootstrap 5 Medical Website Template" + }, + { + "id": "th_fitness", + "name": "Fitness", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Fitness", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Fitness/", + "description": "Free Bootstrap 5 Fitness Website Template" + }, + { + "id": "th_clinic", + "name": "Clinic", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Clinic", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Clinic/", + "description": "Clinic" + }, + { + "id": "th_fitness_bootstrap", + "name": "Fitness Bootstrap", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Fitness-Bootstrap", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Fitness-Bootstrap/", + "description": "Fitness Bootstrap" + }, + { + "id": "th_dental_pro", + "name": "Dental Pro", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/dental-pro", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/dental-pro/", + "description": "Dental Pro" + }, + { + "id": "da_add_life_health_fitness_free_bootstrap_html5_template", + "name": "Add Life Health Fitness Free Bootstrap Html5 Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "add-life-health-fitness-free-bootstrap-html5-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/add-life-health-fitness-free-bootstrap-html5-template/", + "description": "Add Life Health Fitness Free Bootstrap Html5 Template" + }, + { + "id": "da_fit_healthy_fitness_and_gym_html5_bootstrap_theme", + "name": "Fit Healthy Fitness And Gym Html5 Bootstrap Theme", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "fit-healthy-fitness-and-gym-html5-bootstrap-theme", + "preview_url": "https://dawidolko.github.io/Website-Templates/fit-healthy-fitness-and-gym-html5-bootstrap-theme/", + "description": "Fit Healthy Fitness And Gym Html5 Bootstrap Theme" + }, + { + "id": "da_fitness_zone_html5_bootstrap_responsive_web_template", + "name": "Fitness Zone Html5 Bootstrap Responsive Web Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "fitness-zone-html5-bootstrap-responsive-web-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/fitness-zone-html5-bootstrap-responsive-web-template/", + "description": "Fitness Zone Html5 Bootstrap Responsive Web Template" + }, + { + "id": "da_getdoctor_free_bootstrap_responsive_website_template", + "name": "Getdoctor Free Bootstrap Responsive Website Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "getdoctor-free-bootstrap-responsive-website-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/getdoctor-free-bootstrap-responsive-website-template/", + "description": "Getdoctor Free Bootstrap Responsive Website Template" + }, + { + "id": "da_medplus_medical", + "name": "Medplus Medical", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "medplus-medical", + "preview_url": "https://dawidolko.github.io/Website-Templates/medplus-medical/", + "description": "Medplus Medical" + }, + { + "id": "da_touch_hospital_medical_bootstrap_html5_template", + "name": "Touch Hospital Medical Bootstrap Html5 Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "touch-hospital-medical-bootstrap-html5-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/touch-hospital-medical-bootstrap-html5-template/", + "description": "Touch Hospital Medical Bootstrap Html5 Template" + }, + { + "id": "da_vcare_free_html5_template_hospital_website", + "name": "Vcare Free Html5 Template Hospital Website", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "vcare-free-html5-template-hospital-website", + "preview_url": "https://dawidolko.github.io/Website-Templates/vcare-free-html5-template-hospital-website/", + "description": "Vcare Free Html5 Template Hospital Website" + } + ] + }, + { + "id": "education", + "name": "Образование", + "icon": "book", + "templates": [ + { + "id": "lz_bschool_education", + "name": "B-School Education", + "source": "learning-zone", + "description": "Сайт образовательного учреждения", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "b-school-free-education-html5-website-template", + "preview_url": "https://learning-zone.github.io/website-templates/b-school-free-education-html5-website-template/" + }, + { + "id": "lz_learn_education", + "name": "Learn Education", + "source": "learning-zone", + "description": "Платформа онлайн-обучения", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "learn-educational-free-responsive-web-template", + "preview_url": "https://learning-zone.github.io/website-templates/learn-educational-free-responsive-web-template/" + }, + { + "id": "lz_school_education", + "name": "School Education", + "source": "learning-zone", + "description": "Сайт школы", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "school-educational-html5-template", + "preview_url": "https://learning-zone.github.io/website-templates/school-educational-html5-template/" + }, + { + "id": "lz_victory_education", + "name": "Victory Education", + "source": "learning-zone", + "description": "Образовательный портал", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "victory-educational-institution-free-html5-bootstrap-template", + "preview_url": "https://learning-zone.github.io/website-templates/victory-educational-institution-free-html5-bootstrap-template/" + }, + { + "id": "th_elearning", + "name": "Elearning", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/elearning", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/elearning/", + "description": "Elearning" + }, + { + "id": "th_course", + "name": "Course", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/course", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/course/", + "description": "Course" + }, + { + "id": "th_edusite", + "name": "Edusite", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/edusite", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/edusite/", + "description": "For a top-grade education template, choose EduSite. It's free and responsive." + }, + { + "id": "th_grad_school", + "name": "Grad School", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/grad-school", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/grad-school/", + "description": "Grad School" + }, + { + "id": "th_courses", + "name": "Courses", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/courses", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/courses/", + "description": "Courses" + }, + { + "id": "th_oneschool", + "name": "Oneschool", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/oneschool", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/oneschool/", + "description": "Oneschool" + }, + { + "id": "th_education", + "name": "Education", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/education", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/education/", + "description": "Education" + }, + { + "id": "th_edulogy", + "name": "Edulogy", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/edulogy", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/edulogy/", + "description": "Bootstrap education template free download" + }, + { + "id": "th_tutor", + "name": "Tutor", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/tutor", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/tutor/", + "description": "Tutor" + }, + { + "id": "th_cooking_school", + "name": "Cooking School", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/cooking-school", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/cooking-school/", + "description": "Cooking School" + }, + { + "id": "th_jubilee", + "name": "Jubilee", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/jubilee", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/jubilee/", + "description": "Free Educational Website Template" + }, + { + "id": "th_scholar", + "name": "Scholar", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/scholar", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/scholar/", + "description": "Free Educational Website Template" + }, + { + "id": "th_oinia", + "name": "Oinia", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Oinia", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Oinia/", + "description": "Free Educational Website Template" + }, + { + "id": "th_babycare", + "name": "Babycare", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/BabyCare", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/BabyCare/", + "description": "Free Bootstrap 5 Educational Website Template" + }, + { + "id": "th_mentor", + "name": "Mentor", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Mentor", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Mentor/", + "description": "Free Bootstrap 5 Educational Website Template" + }, + { + "id": "th_profusion", + "name": "Profusion", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Profusion", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Profusion/", + "description": "Free Educational site template" + }, + { + "id": "th_e_learning", + "name": "E Learning", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/E-learning", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/E-learning/", + "description": "E Learning" + }, + { + "id": "th_si_educational_nextjs", + "name": "Si Educational Nextjs", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/si-educational-nextjs", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/si-educational-nextjs/", + "description": "Si Educational Nextjs" + }, + { + "id": "th_si_educational_website_template", + "name": "Si Educational Website Template", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Si-Educational-Website-Template", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Si-Educational-Website-Template/", + "description": "Si Educational Website Template" + }, + { + "id": "th_nextjs_tailwind_course_landing_page", + "name": "Nextjs Tailwind Course Landing Page", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/NextJS-Tailwind-Course-Landing-Page", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/NextJS-Tailwind-Course-Landing-Page/", + "description": "Nextjs Tailwind Course Landing Page" + }, + { + "id": "th_si_education", + "name": "Si Education", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/si-education", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/si-education/", + "description": "Si Educational Free NextJs Landing Page Template" + }, + { + "id": "th_learnhub", + "name": "Learnhub", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/learnhub", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/learnhub/", + "description": "LearnHub- Free eLearning Bootstrap Educational Website Template" + }, + { + "id": "th_purdue", + "name": "Purdue", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/purdue", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/purdue/", + "description": "Purdue – Education & Online Course HTML Template" + }, + { + "id": "th_eduleb", + "name": "Eduleb", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/eduleb", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/eduleb/", + "description": "Eduleb - Education HTML Template" + }, + { + "id": "da_above_educational_bootstrap_responsive_template", + "name": "Above Educational Bootstrap Responsive Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "above-educational-bootstrap-responsive-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/above-educational-bootstrap-responsive-template/", + "description": "Above Educational Bootstrap Responsive Template" + }, + { + "id": "da_b_school_free_education_html5_website_template", + "name": "B School Free Education Html5 Website Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "b-school-free-education-html5-website-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/b-school-free-education-html5-website-template/", + "description": "B School Free Education Html5 Website Template" + }, + { + "id": "da_learn_educational_free_responsive_web_template", + "name": "Learn Educational Free Responsive Web Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "learn-educational-free-responsive-web-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/learn-educational-free-responsive-web-template/", + "description": "Learn Educational Free Responsive Web Template" + }, + { + "id": "da_mentor_free_html5_bootstrap_coming_soon_template", + "name": "Mentor Free Html5 Bootstrap Coming Soon Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "mentor-free-html5-bootstrap-coming-soon-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/mentor-free-html5-bootstrap-coming-soon-template/", + "description": "Mentor Free Html5 Bootstrap Coming Soon Template" + }, + { + "id": "da_school_educational_html5_template", + "name": "School Educational Html5 Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "school-educational-html5-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/school-educational-html5-template/", + "description": "School Educational Html5 Template" + }, + { + "id": "da_victory_educational_institution_free_html5_bootstrap_temp", + "name": "Victory Educational Institution Free Html5 Bootstrap Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "victory-educational-institution-free-html5-bootstrap-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/victory-educational-institution-free-html5-bootstrap-template/", + "description": "Victory Educational Institution Free Html5 Bootstrap Template" + } + ] + }, + { + "id": "portfolio", + "name": "Портфолио и креатив", + "icon": "palette", + "templates": [ + { + "id": "h5up_astral", + "name": "Astral", + "source": "html5up", + "description": "Портфолио с галереей", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "astral", + "preview_url": "https://html5up.net/astral/" + }, + { + "id": "h5up_ethereal", + "name": "Ethereal", + "source": "html5up", + "description": "Портфолио с эффектами", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "ethereal", + "preview_url": "https://html5up.net/ethereal/" + }, + { + "id": "h5up_forty", + "name": "Forty", + "source": "html5up", + "description": "Минималистичное портфолио", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "forty", + "preview_url": "https://html5up.net/forty/" + }, + { + "id": "h5up_fractal", + "name": "Fractal", + "source": "html5up", + "description": "Портфолио с геометрическим дизайном", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "fractal", + "preview_url": "https://html5up.net/fractal/" + }, + { + "id": "h5up_halcyonic", + "name": "Halcyonic", + "source": "html5up", + "description": "Яркое портфолио", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "halcyonic", + "preview_url": "https://html5up.net/halcyonic/" + }, + { + "id": "h5up_highlights", + "name": "Highlights", + "source": "html5up", + "description": "Портфолио с выделением работ", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "highlights", + "preview_url": "https://html5up.net/highlights/" + }, + { + "id": "h5up_lens", + "name": "Lens", + "source": "html5up", + "description": "Фотографический портал", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "lens", + "preview_url": "https://html5up.net/lens/" + }, + { + "id": "h5up_paradigm_shift", + "name": "Paradigm Shift", + "source": "html5up", + "description": "Современное портфолио", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "paradigm-shift", + "preview_url": "https://html5up.net/paradigm-shift/" + }, + { + "id": "h5up_phantom", + "name": "Phantom", + "source": "html5up", + "description": "Портфолио с темной темой", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "phantom", + "preview_url": "https://html5up.net/phantom/" + }, + { + "id": "h5up_photon", + "name": "Photon", + "source": "html5up", + "description": "Фото-портфолио", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "photon", + "preview_url": "https://html5up.net/photon/" + }, + { + "id": "h5up_strata", + "name": "Strata", + "source": "html5up", + "description": "Компактное портфолио", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "strata", + "preview_url": "https://html5up.net/strata/" + }, + { + "id": "h5up_striped", + "name": "Striped", + "source": "html5up", + "description": "Портфолио в полоску", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "striped", + "preview_url": "https://html5up.net/striped/" + }, + { + "id": "lz_iclick_photography", + "name": "iClick Photography", + "source": "learning-zone", + "description": "Портфолио фотографа", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "iclick-photography-bootstrap-free-website-template", + "preview_url": "https://learning-zone.github.io/website-templates/iclick-photography-bootstrap-free-website-template/" + }, + { + "id": "lz_amaze_photography", + "name": "Amaze Photography", + "source": "learning-zone", + "description": "Студия фотографии", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "amaze-photography-bootstrap-html5-template", + "preview_url": "https://learning-zone.github.io/website-templates/amaze-photography-bootstrap-html5-template/" + }, + { + "id": "lz_html5_portfolio", + "name": "HTML5 Portfolio", + "source": "learning-zone", + "description": "Универсальный портфолио", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "html5-portfolio", + "preview_url": "https://learning-zone.github.io/website-templates/html5-portfolio/" + }, + { + "id": "lz_wow_portfolio", + "name": "WOW Portfolio", + "source": "learning-zone", + "description": "Многофункциональное портфолио", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "wow-portfolio-multi-purpose-html5-template", + "preview_url": "https://learning-zone.github.io/website-templates/wow-portfolio-multi-purpose-html5-template/" + }, + { + "id": "lz_me_portfolio", + "name": "ME Portfolio", + "source": "learning-zone", + "description": "Личный портфолио и резюме", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "me-resume-personal-portfolio-responsive-template", + "preview_url": "https://learning-zone.github.io/website-templates/me-resume-personal-portfolio-responsive-template/" + }, + { + "id": "lz_johndoe_portfolio", + "name": "JohnDoe Portfolio", + "source": "learning-zone", + "description": "Портфолио с резюме", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "johndoe-portfolio-resume-bootstrap-template", + "preview_url": "https://learning-zone.github.io/website-templates/johndoe-portfolio-resume-bootstrap-template/" + }, + { + "id": "lz_free_portfolio_sam", + "name": "Free Portfolio", + "source": "learning-zone", + "description": "Адаптивное портфолио", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "free-portfolio-html5-responsive-website-sam", + "preview_url": "https://learning-zone.github.io/website-templates/free-portfolio-html5-responsive-website-sam/" + }, + { + "id": "h5up_read_only", + "name": "Read Only", + "source": "html5up", + "description": "Портфолио творческого агентства", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "read-only", + "preview_url": "https://html5up.net/read-only/" + }, + { + "id": "h5up_future", + "name": "Future", + "source": "html5up", + "description": "Сайт креативного студии", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "future-imperfect", + "preview_url": "https://html5up.net/future-imperfect/" + }, + { + "id": "sb_creative", + "name": "StartBootstrap Creative", + "source": "startbootstrap", + "description": "Креативное агентство", + "repo_url": "https://github.com/StartBootstrap/startbootstrap-creative", + "sparse_path": ".", + "preview_url": "https://startbootstrap.github.io/startbootstrap-creative/" + }, + { + "id": "sb_freelancer", + "name": "StartBootstrap Freelancer", + "source": "startbootstrap", + "description": "Портфолио фрилансера", + "repo_url": "https://github.com/StartBootstrap/startbootstrap-freelancer", + "sparse_path": ".", + "preview_url": "https://startbootstrap.github.io/startbootstrap-freelancer/" + }, + { + "id": "th_the_portfolio", + "name": "The Portfolio", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/the_portfolio", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/the_portfolio/", + "description": "HTML 5 Responsive Personal Website Template" + }, + { + "id": "th_material_dashboard_react", + "name": "Material Dashboard React", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/material-dashboard-react", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/material-dashboard-react/", + "description": "React version of Material Dashboard by Creative Tim" + }, + { + "id": "th_johndoe", + "name": "Johndoe", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/JohnDoe", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/JohnDoe/", + "description": "FREE One Page Responsive Portfolio Template designed with Bootstrap3, HTML5, CSS3 and jQuery." + }, + { + "id": "th_profile", + "name": "Profile", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/profile", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/profile/", + "description": "Resume Bootstrap Template Download Free" + }, + { + "id": "th_darkjoe", + "name": "Darkjoe", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/DarkJoe", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/DarkJoe/", + "description": "Dark Joe - Responsive One Page Personal Website Template with Bootstrap 3" + }, + { + "id": "th_developer", + "name": "Developer", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Developer", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Developer/", + "description": "Developer - Responsive Personal Website Template" + }, + { + "id": "th_photographer", + "name": "Photographer", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Photographer", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Photographer/", + "description": "Photographer - A Responsive One Page Photography Website Template with Bootstrap 3" + }, + { + "id": "th_resume_bootstrap4", + "name": "Resume Bootstrap4", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/resume-bootstrap4", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/resume-bootstrap4/", + "description": "A Bootstrap 4 resume/CV theme created by Start Bootstrap" + }, + { + "id": "th_resume_2", + "name": "Resume 2", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/resume-2", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/resume-2/", + "description": "Resume 2" + }, + { + "id": "th_creative_2", + "name": "Creative 2", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/creative-2", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/creative-2/", + "description": "Creative 2" + }, + { + "id": "th_photographer_2", + "name": "Photographer 2", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/photographer-2", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/photographer-2/", + "description": "Photographer 2" + }, + { + "id": "th_moonlight", + "name": "Moonlight", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/moonlight", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/moonlight/", + "description": "One page template for building a photography or portfolio site." + }, + { + "id": "th_slides_portfolio", + "name": "Slides Portfolio", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/slides-portfolio", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/slides-portfolio/", + "description": "Slides Portfolio" + }, + { + "id": "th_personalportfolio", + "name": "Personalportfolio", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/personalportfolio", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/personalportfolio/", + "description": "Personalportfolio" + }, + { + "id": "th_aircv", + "name": "Aircv", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Aircv", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Aircv/", + "description": "Professional CV Parallax Template" + }, + { + "id": "th_myportfolio", + "name": "Myportfolio", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/myportfolio", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/myportfolio/", + "description": "Myportfolio" + }, + { + "id": "th_personal_portfolio", + "name": "Personal Portfolio", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/personal-portfolio", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/personal-portfolio/", + "description": "Personal Portfolio" + }, + { + "id": "th_albedo_template", + "name": "Albedo Template", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Albedo-Template", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Albedo-Template/", + "description": "Albedo Free HTML Template Powered With Bootstrap 4 for Designer Portfolio" + }, + { + "id": "th_ethereal", + "name": "Ethereal", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/ethereal", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/ethereal/", + "description": "Personal, Portfolio, and Photography HTML Template" + }, + { + "id": "th_porta1", + "name": "Porta1", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/porta1", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/porta1/", + "description": "Porta is a Bootstrap4 minimal portfolio template by CurlyArts, absolutely free for download !" + }, + { + "id": "th_creative", + "name": "Creative", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Creative", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Creative/", + "description": "A Responsive Template for Creative works" + }, + { + "id": "th_now_ui_kit", + "name": "Now Ui Kit", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/now-ui-kit", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/now-ui-kit/", + "description": "Now UI Kit Bootstrap 4 - Designed by Invision. Coded by Creative Tim. Live Demo" + }, + { + "id": "th_portfolio", + "name": "Portfolio", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/portfolio", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/portfolio/", + "description": "Portfolio" + }, + { + "id": "th_creative_bundle_2024", + "name": "Creative Bundle 2024", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/creative-bundle-2024", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/creative-bundle-2024/", + "description": "Creative Bundle 2024" + }, + { + "id": "th_personal_website", + "name": "Personal Website", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Personal-Website", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Personal-Website/", + "description": "My website portfolio" + }, + { + "id": "th_material_kit_react", + "name": "Material Kit React", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/material-kit-react", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/material-kit-react/", + "description": "Material Kit React free and open source by Creative Tim & Distributed by Themewagon." + }, + { + "id": "th_pigra", + "name": "Pigra", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Pigra", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Pigra/", + "description": "Free Bootstrap 5 Portfolio Website Template" + }, + { + "id": "th_joyseno", + "name": "Joyseno", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Joyseno", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Joyseno/", + "description": "Free Responsive Portfolio Website Template" + }, + { + "id": "th_hudson", + "name": "Hudson", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Hudson", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Hudson/", + "description": "Free HTML Portfolio Website Template" + }, + { + "id": "th_ethos", + "name": "Ethos", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Ethos", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Ethos/", + "description": "Free Personal Website Template" + }, + { + "id": "th_archi_new", + "name": "Archi New", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/archi-new", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/archi-new/", + "description": "Free Architecture Portfolio Template" + }, + { + "id": "th_jessica", + "name": "Jessica", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Jessica", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Jessica/", + "description": "Free Bootstrap 5 Portfolio Website Template" + }, + { + "id": "th_iportfolio", + "name": "Iportfolio", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/iPortfolio", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/iPortfolio/", + "description": "Free Bootstrap 5 Portfolio Website Template" + }, + { + "id": "th_myresume", + "name": "Myresume", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/MyResume", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/MyResume/", + "description": "Free Bootstrap 5 Resume Website Template" + }, + { + "id": "th_photofolio", + "name": "Photofolio", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/PhotoFolio", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/PhotoFolio/", + "description": "Free Bootstrap 5 Portfolio Website Template" + }, + { + "id": "th_presento", + "name": "Presento", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Presento", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Presento/", + "description": "Free Bootstrap 5 Portfolio Template" + }, + { + "id": "th_next_js_tailwind_css_portfolio_template", + "name": "Next.Js Tailwind Css Portfolio Template", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Next.js-Tailwind-CSS-Portfolio-Template", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Next.js-Tailwind-CSS-Portfolio-Template/", + "description": "Next.Js Tailwind Css Portfolio Template" + }, + { + "id": "th_resume_nextjs", + "name": "Resume Nextjs", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Resume-Nextjs", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Resume-Nextjs/", + "description": "Resume Nextjs" + }, + { + "id": "th_typefolio_nextjs", + "name": "Typefolio Nextjs", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/typefolio-nextjs", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/typefolio-nextjs/", + "description": "Typefolio – Simple Portfolio Template" + }, + { + "id": "th_ryan", + "name": "Ryan", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/ryan", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/ryan/", + "description": "Ryan - Next JS Portfolio Template" + }, + { + "id": "da_3_col_portfolio", + "name": "3 Col Portfolio", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "3-col-portfolio", + "preview_url": "https://dawidolko.github.io/Website-Templates/3-col-portfolio/", + "description": "3 Col Portfolio" + }, + { + "id": "da_free_portfolio_html5_responsive_website_sam", + "name": "Free Portfolio Html5 Responsive Website Sam", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "free-portfolio-html5-responsive-website-sam", + "preview_url": "https://dawidolko.github.io/Website-Templates/free-portfolio-html5-responsive-website-sam/", + "description": "Free Portfolio Html5 Responsive Website Sam" + }, + { + "id": "da_html5_portfolio", + "name": "Html5 Portfolio", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "html5-portfolio", + "preview_url": "https://dawidolko.github.io/Website-Templates/html5-portfolio/", + "description": "Html5 Portfolio" + }, + { + "id": "da_iam_html5_responsive_portfolio_resume_template", + "name": "Iam Html5 Responsive Portfolio Resume Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "iam-html5-responsive-portfolio-resume-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/iam-html5-responsive-portfolio-resume-template/", + "description": "Iam Html5 Responsive Portfolio Resume Template" + }, + { + "id": "da_john_bootstrap_one_page_html5_free_resume_template", + "name": "John Bootstrap One Page Html5 Free Resume Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "john-bootstrap-one-page-html5-free-resume-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/john-bootstrap-one-page-html5-free-resume-template/", + "description": "John Bootstrap One Page Html5 Free Resume Template" + }, + { + "id": "da_johndoe_portfolio_resume_bootstrap_template", + "name": "Johndoe Portfolio Resume Bootstrap Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "johndoe-portfolio-resume-bootstrap-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/johndoe-portfolio-resume-bootstrap-template/", + "description": "Johndoe Portfolio Resume Bootstrap Template" + }, + { + "id": "da_me_resume_personal_portfolio_responsive_template", + "name": "Me Resume Personal Portfolio Responsive Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "me-resume-personal-portfolio-responsive-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/me-resume-personal-portfolio-responsive-template/", + "description": "Me Resume Personal Portfolio Responsive Template" + }, + { + "id": "da_my_portfolio_two", + "name": "My Portfolio Two", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "my-portfolio-two", + "preview_url": "https://dawidolko.github.io/Website-Templates/my-portfolio-two/", + "description": "My Portfolio Two" + }, + { + "id": "da_portfolio_item", + "name": "Portfolio Item", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "portfolio-item", + "preview_url": "https://dawidolko.github.io/Website-Templates/portfolio-item/", + "description": "Portfolio Item" + }, + { + "id": "da_startbootstrap_freelancer_1_0_2", + "name": "Freelancer 1.0.2", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "startbootstrap-freelancer-1.0.2", + "preview_url": "https://dawidolko.github.io/Website-Templates/startbootstrap-freelancer-1.0.2/", + "description": "Freelancer 1.0.2" + }, + { + "id": "da_startbootstrap_stylish_portfolio_1_0_2", + "name": "Stylish Portfolio 1.0.2", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "startbootstrap-stylish-portfolio-1.0.2", + "preview_url": "https://dawidolko.github.io/Website-Templates/startbootstrap-stylish-portfolio-1.0.2/", + "description": "Stylish Portfolio 1.0.2" + }, + { + "id": "da_stylish_portfolio", + "name": "Stylish Portfolio", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "stylish-portfolio", + "preview_url": "https://dawidolko.github.io/Website-Templates/stylish-portfolio/", + "description": "Stylish Portfolio" + }, + { + "id": "da_wow_portfolio_multi_purpose_html5_template", + "name": "Wow Portfolio Multi Purpose Html5 Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "wow-portfolio-multi-purpose-html5-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/wow-portfolio-multi-purpose-html5-template/", + "description": "Wow Portfolio Multi Purpose Html5 Template" + } + ] + }, + { + "id": "realestate", + "name": "Недвижимость и интерьер", + "icon": "home", + "templates": [ + { + "id": "lz_aerosky_realestate", + "name": "Aerosky Real Estate", + "source": "learning-zone", + "description": "Агентство недвижимости", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "aerosky-real-estate-html-responsive-website-template", + "preview_url": "https://learning-zone.github.io/website-templates/aerosky-real-estate-html-responsive-website-template/" + }, + { + "id": "lz_bootstrap_realestate", + "name": "Bootstrap Real Estate", + "source": "learning-zone", + "description": "Портал продажи недвижимости", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "free-bootstrap-template-real-estate-my-home", + "preview_url": "https://learning-zone.github.io/website-templates/free-bootstrap-template-real-estate-my-home/" + }, + { + "id": "lz_park_city_realestate", + "name": "Park City Real Estate", + "source": "learning-zone", + "description": "Недвижимость в городе", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "park-city-bootstrap-html-real-estate-responsive-template", + "preview_url": "https://learning-zone.github.io/website-templates/park-city-bootstrap-html-real-estate-responsive-template/" + }, + { + "id": "lz_icon_realestate", + "name": "Icon Real Estate", + "source": "learning-zone", + "description": "Застройщики недвижимости", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "icon-real-estate-developers-free-responsive-html-template", + "preview_url": "https://learning-zone.github.io/website-templates/icon-real-estate-developers-free-responsive-html-template/" + }, + { + "id": "lz_real_estate_builders", + "name": "Real Estate Builders", + "source": "learning-zone", + "description": "Строители и девелоперы", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "real-estate-builders-free-responsive-website-templates-adesign", + "preview_url": "https://learning-zone.github.io/website-templates/real-estate-builders-free-responsive-website-templates-adesign/" + }, + { + "id": "h5up_landed", + "name": "Landed", + "source": "html5up", + "description": "Сайт дизайн-студии", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "landed", + "preview_url": "https://html5up.net/landed/" + }, + { + "id": "h5up_strongly_typed", + "name": "Strongly Typed", + "source": "html5up", + "description": "Портфолио типографики", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "strongly-typed", + "preview_url": "https://html5up.net/strongly-typed/" + }, + { + "id": "lz_ideal_interior", + "name": "Ideal Interior Design", + "source": "learning-zone", + "description": "Дизайн интерьера", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "ideal-interior-design-free-bootstrap-website-template", + "preview_url": "https://learning-zone.github.io/website-templates/ideal-interior-design-free-bootstrap-website-template/" + }, + { + "id": "lz_smart_interior", + "name": "Smart Interior Designs", + "source": "learning-zone", + "description": "Интерьерный проект", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "smart-interior-designs-html5-bootstrap-web-template", + "preview_url": "https://learning-zone.github.io/website-templates/smart-interior-designs-html5-bootstrap-web-template/" + }, + { + "id": "lz_relax_interior", + "name": "Relax Interior", + "source": "learning-zone", + "description": "Дизайн жилых помещений", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "relax-interior-free-bootstrap-responsive-website-template", + "preview_url": "https://learning-zone.github.io/website-templates/relax-interior-free-bootstrap-responsive-website-template/" + }, + { + "id": "th_property", + "name": "Property", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/property", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/property/", + "description": "Property" + }, + { + "id": "th_shionhouse", + "name": "Shionhouse", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/shionhouse", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/shionhouse/", + "description": "Shionhouse" + }, + { + "id": "th_datawarehouse", + "name": "Datawarehouse", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/dataWarehouse", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/dataWarehouse/", + "description": "Datawarehouse" + }, + { + "id": "th_homebuilder", + "name": "Homebuilder", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/homebuilder", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/homebuilder/", + "description": "Homebuilder" + }, + { + "id": "th_interior", + "name": "Interior", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/interior", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/interior/", + "description": "Interior" + }, + { + "id": "th_theinterior", + "name": "Theinterior", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/theinterior", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/theinterior/", + "description": "Theinterior" + }, + { + "id": "th_warehouse", + "name": "Warehouse", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/warehouse", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/warehouse/", + "description": "Warehouse" + }, + { + "id": "th_interior_design", + "name": "Interior Design", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/interior-design", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/interior-design/", + "description": "Interior Design" + }, + { + "id": "th_delux_interior", + "name": "Delux Interior", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/delux-interior", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/delux-interior/", + "description": "Delux Interior" + }, + { + "id": "th_upconstruction", + "name": "Upconstruction", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/UpConstruction", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/UpConstruction/", + "description": "Upconstruction" + }, + { + "id": "th_teahouse", + "name": "Teahouse", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/TeaHouse", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/TeaHouse/", + "description": "Teahouse" + }, + { + "id": "th_vaso", + "name": "Vaso", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/vaso", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/vaso/", + "description": "Free bootstrap 5 interior Decore Website Template" + }, + { + "id": "th_property_nextjs", + "name": "Property Nextjs", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/property-nextjs", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/property-nextjs/", + "description": "Property Nextjs" + }, + { + "id": "da_ideal_interior_design_free_bootstrap_website_template", + "name": "Ideal Interior Design Free Bootstrap Website Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "ideal-interior-design-free-bootstrap-website-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/ideal-interior-design-free-bootstrap-website-template/", + "description": "Ideal Interior Design Free Bootstrap Website Template" + }, + { + "id": "da_real_estate_builders_free_responsive_website_templates_ad", + "name": "Real Estate Builders Free Responsive Website Templates Adesign", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "real-estate-builders-free-responsive-website-templates-adesign", + "preview_url": "https://dawidolko.github.io/Website-Templates/real-estate-builders-free-responsive-website-templates-adesign/", + "description": "Real Estate Builders Free Responsive Website Templates Adesign" + }, + { + "id": "da_relax_interior_free_bootstrap_responsive_website_template", + "name": "Relax Interior Free Bootstrap Responsive Website Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "relax-interior-free-bootstrap-responsive-website-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/relax-interior-free-bootstrap-responsive-website-template/", + "description": "Relax Interior Free Bootstrap Responsive Website Template" + }, + { + "id": "da_styleinn_bootstrap_interior_design_website_template", + "name": "Styleinn Bootstrap Interior Design Website Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "styleinn-bootstrap-interior-design-website-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/styleinn-bootstrap-interior-design-website-template/", + "description": "Styleinn Bootstrap Interior Design Website Template" + } + ] + }, + { + "id": "restaurant", + "name": "Рестораны и еда", + "icon": "utensils", + "templates": [ + { + "id": "lz_coffee_shop", + "name": "Coffee Shop", + "source": "learning-zone", + "description": "Сайт кофейни", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "coffee-shop-free-html5-template", + "preview_url": "https://learning-zone.github.io/website-templates/coffee-shop-free-html5-template/" + }, + { + "id": "lz_golden_hotel_restaurant", + "name": "Golden Hotel Restaurant", + "source": "learning-zone", + "description": "Ресторан отеля", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "golden-hotel-free-html5-bootstrap-web-template", + "preview_url": "https://learning-zone.github.io/website-templates/golden-hotel-free-html5-bootstrap-web-template/" + }, + { + "id": "lz_bestro_restaurant", + "name": "Bestro Restaurant", + "source": "learning-zone", + "description": "Шаблон ресторана на Bootstrap", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "bestro-restaurant-bootstrap-html5-template", + "preview_url": "https://learning-zone.github.io/website-templates/bestro-restaurant-bootstrap-html5-template/" + }, + { + "id": "lz_eat_restaurant", + "name": "Eat Restaurant", + "source": "learning-zone", + "description": "Сайт ресторана с меню", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "eat-restaurant-bootstrap-html5-template", + "preview_url": "https://learning-zone.github.io/website-templates/eat-restaurant-bootstrap-html5-template/" + }, + { + "id": "th_restaurant_2", + "name": "Restaurant 2", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/restaurant-2", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/restaurant-2/", + "description": "Restaurant 2" + }, + { + "id": "th_restaurant_html_template", + "name": "Restaurant Html Template", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/restaurant-html-template", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/restaurant-html-template/", + "description": "Restaurant Html Template" + }, + { + "id": "th_pizza", + "name": "Pizza", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/pizza", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/pizza/", + "description": "Pizza" + }, + { + "id": "th_foodee", + "name": "Foodee", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/foodee", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/foodee/", + "description": "Free Restaurant Template Download" + }, + { + "id": "th_vegefoods", + "name": "Vegefoods", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/vegefoods", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/vegefoods/", + "description": "Vegefoods" + }, + { + "id": "th_coffee", + "name": "Coffee", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/coffee", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/coffee/", + "description": "Coffee" + }, + { + "id": "th_meatking_1", + "name": "Meatking 1", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/MeatKing-1", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/MeatKing-1/", + "description": "Meatking - A Restaurant Website Design Template with Bootstrap 3" + }, + { + "id": "th_restaurantly", + "name": "Restaurantly", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/restaurantly", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/restaurantly/", + "description": "Restaurantly" + }, + { + "id": "th_foody2", + "name": "Foody2", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/foody2", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/foody2/", + "description": "Foody2" + }, + { + "id": "th_foodwagon", + "name": "Foodwagon", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/foodwagon", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/foodwagon/", + "description": "Foodwagon" + }, + { + "id": "th_delfood", + "name": "Delfood", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/delfood", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/delfood/", + "description": "Delfood" + }, + { + "id": "th_coffee1", + "name": "Coffee1", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/coffee1", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/coffee1/", + "description": "Coffee1" + }, + { + "id": "th_klassy_cafe", + "name": "Klassy Cafe", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/klassy-cafe", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/klassy-cafe/", + "description": "Klassy Cafe" + }, + { + "id": "th_barberz", + "name": "Barberz", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/barberz", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/barberz/", + "description": "Barberz" + }, + { + "id": "th_food_funday", + "name": "Food Funday", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/food-funday", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/food-funday/", + "description": "Food Funday" + }, + { + "id": "th_grandcoffee", + "name": "Grandcoffee", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/grandcoffee", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/grandcoffee/", + "description": "Grandcoffee" + }, + { + "id": "th_taste", + "name": "Taste", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/taste", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/taste/", + "description": "Bootstrap 4 restaurant template with menu creating option." + }, + { + "id": "th_foodeiblog", + "name": "Foodeiblog", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/foodeiblog", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/foodeiblog/", + "description": "Foodeiblog" + }, + { + "id": "th_allfood", + "name": "Allfood", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/allfood", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/allfood/", + "description": "Allfood" + }, + { + "id": "th_touche", + "name": "Touche", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/touche", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/touche/", + "description": "Free restaurant template that has a eye-soothing design and parallax effects" + }, + { + "id": "th_tasty_recipes", + "name": "Tasty Recipes", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/tasty-recipes", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/tasty-recipes/", + "description": "Tasty Recipes" + }, + { + "id": "th_foodfun", + "name": "Foodfun", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/foodfun", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/foodfun/", + "description": "Foodfun" + }, + { + "id": "th_mamma_s_kitchen", + "name": "Mamma S Kitchen", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Mamma-s-Kitchen", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Mamma-s-Kitchen/", + "description": "A fully responsive restaurant template, developed by bootstrap 3" + }, + { + "id": "th_barberx", + "name": "Barberx", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/BarberX", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/BarberX/", + "description": "Barberx" + }, + { + "id": "th_foody", + "name": "Foody", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/foody", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/foody/", + "description": "Foody" + }, + { + "id": "th_foodque", + "name": "Foodque", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/foodque", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/foodque/", + "description": "A restaurant website template with creative design and fluid layout." + }, + { + "id": "th_meatking", + "name": "Meatking", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/MeatKing", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/MeatKing/", + "description": "Meatking - A Restaurant Website Design Template with Bootstrap 3" + }, + { + "id": "th_restaurant", + "name": "Restaurant", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/restaurant", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/restaurant/", + "description": "Free Tailwind CSS Restaurant Landing Page" + }, + { + "id": "da_bestro_restaurant_bootstrap_html5_template", + "name": "Bestro Restaurant Bootstrap Html5 Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "bestro-restaurant-bootstrap-html5-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/bestro-restaurant-bootstrap-html5-template/", + "description": "Bestro Restaurant Bootstrap Html5 Template" + }, + { + "id": "da_eat_restaurant_bootstrap_html5_template", + "name": "Eat Restaurant Bootstrap Html5 Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "eat-restaurant-bootstrap-html5-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/eat-restaurant-bootstrap-html5-template/", + "description": "Eat Restaurant Bootstrap Html5 Template" + }, + { + "id": "da_free_bootstrap_template_restaurant_website_treehut", + "name": "Free Bootstrap Template Restaurant Website Treehut", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "free-bootstrap-template-restaurant-website-treehut", + "preview_url": "https://dawidolko.github.io/Website-Templates/free-bootstrap-template-restaurant-website-treehut/", + "description": "Free Bootstrap Template Restaurant Website Treehut" + }, + { + "id": "da_simple_sidebar", + "name": "Simple Sidebar", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "simple-sidebar", + "preview_url": "https://dawidolko.github.io/Website-Templates/simple-sidebar/", + "description": "Simple Sidebar" + } + ] + }, + { + "id": "landing", + "name": "Лендинги", + "icon": "rocket", + "templates": [ + { + "id": "h5up_big_picture", + "name": "Big Picture", + "source": "html5up", + "description": "Одностраничный лендинг с полноэкранными изображениями", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "big-picture", + "preview_url": "https://html5up.net/big-picture/" + }, + { + "id": "h5up_eventually", + "name": "Eventually", + "source": "html5up", + "description": "Лендинг со счётчиком запуска", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "eventually", + "preview_url": "https://html5up.net/eventually/" + }, + { + "id": "h5up_escape_velocity", + "name": "Escape Velocity", + "source": "html5up", + "description": "Креативный лендинг с необычным дизайном", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "escape-velocity", + "preview_url": "https://html5up.net/escape-velocity/" + }, + { + "id": "lz_mobile_app_landing", + "name": "Mobile App Landing Page", + "source": "learning-zone", + "description": "Лендинг для мобильного приложения", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "mobile-app-free-one-page-responsive-html5-landing-page", + "preview_url": "https://learning-zone.github.io/website-templates/mobile-app-free-one-page-responsive-html5-landing-page/" + }, + { + "id": "lz_smartapp_landing", + "name": "SmartApp Landing", + "source": "learning-zone", + "description": "Современный лендинг приложения", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "smartapp-free-html5-landing-page", + "preview_url": "https://learning-zone.github.io/website-templates/smartapp-free-html5-landing-page/" + }, + { + "id": "lz_brand_app_landing", + "name": "Brand App Landing", + "source": "learning-zone", + "description": "Лендинг для брендового приложения", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "brand-html5-app-landing-page-responsive-web-template", + "preview_url": "https://learning-zone.github.io/website-templates/brand-html5-app-landing-page-responsive-web-template/" + }, + { + "id": "lz_line_app_landing", + "name": "Line App Landing", + "source": "learning-zone", + "description": "Минималистичный лендинг приложения", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "line-free-app-landing-page-responsive-web-template", + "preview_url": "https://learning-zone.github.io/website-templates/line-free-app-landing-page-responsive-web-template/" + }, + { + "id": "sb_landing_page", + "name": "StartBootstrap Landing Page", + "source": "startbootstrap", + "description": "Универсальный адаптивный лендинг", + "repo_url": "https://github.com/StartBootstrap/startbootstrap-landing-page", + "sparse_path": ".", + "preview_url": "https://startbootstrap.github.io/startbootstrap-landing-page/" + }, + { + "id": "th_imminent", + "name": "Imminent", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Imminent", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Imminent/", + "description": "Free 3D Parallax Responsive Coming Soon Template" + }, + { + "id": "th_flameonepage", + "name": "Flameonepage", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/flameonepage", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/flameonepage/", + "description": "Flameonepage" + }, + { + "id": "th_layla", + "name": "Layla", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/layla", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/layla/", + "description": "Coming Soon Template" + }, + { + "id": "th_awesome", + "name": "Awesome", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Awesome", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Awesome/", + "description": "Awesome - A Free Responsive Coming Soon Template" + }, + { + "id": "th_deskapp2", + "name": "Deskapp2", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/deskapp2", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/deskapp2/", + "description": "Deskapp2" + }, + { + "id": "th_polo", + "name": "Polo", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/polo", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/polo/", + "description": "POLO - Responsive App Landing Page Template with Bootstrap 3" + }, + { + "id": "th_brandi_onepage_html5_business_template", + "name": "Brandi Onepage Html5 Business Template", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/brandi-Onepage-HTML5-Business-Template", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/brandi-Onepage-HTML5-Business-Template/", + "description": "Brandi-Free-One-Page-Responsive-HTML5-Business-Template" + }, + { + "id": "th_mobapp", + "name": "Mobapp", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/mobapp", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/mobapp/", + "description": "MobApp is a Bootstrap 4 app landing template to make your landing page creation more comfortable. It's very bright with colors and ready-to-go to craft a website with simple steps." + }, + { + "id": "th_fitapp", + "name": "Fitapp", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/FitApp", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/FitApp/", + "description": "Fitapp" + }, + { + "id": "th_coming2live", + "name": "Coming2Live", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/coming2live", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/coming2live/", + "description": "10 demos available with this free coming soon website template" + }, + { + "id": "th_small_apps", + "name": "Small Apps", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/small-apps", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/small-apps/", + "description": "Small Apps" + }, + { + "id": "th_count", + "name": "Count", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/count", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/count/", + "description": "Free coming soon website template holding three demo variation. Built with Bootstrap. Download from the link now." + }, + { + "id": "th_wedding", + "name": "Wedding", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/wedding", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/wedding/", + "description": "Wedding is a free HTML website template for wedding and events with countdown timer and engaging design layout" + }, + { + "id": "th_applab_2", + "name": "Applab 2", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/applab_2", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/applab_2/", + "description": "Applab 2" + }, + { + "id": "th_append", + "name": "Append", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/append", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/append/", + "description": "Append" + }, + { + "id": "th_season", + "name": "Season", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Season", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Season/", + "description": "Coming Soon Responsive HTML Template" + }, + { + "id": "th_slides_animated_landing_page", + "name": "Slides Animated Landing Page", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/slides-animated-landing-page", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/slides-animated-landing-page/", + "description": "Slides Animated Landing Page" + }, + { + "id": "th_lucy", + "name": "Lucy", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/lucy", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/lucy/", + "description": "Best Free Responsive Bootstrap App Landing Page Lucy" + }, + { + "id": "th_woolanding", + "name": "Woolanding", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/wooLanding", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/wooLanding/", + "description": "Woolanding" + }, + { + "id": "th_appru", + "name": "Appru", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/appru", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/appru/", + "description": "Appru" + }, + { + "id": "th_appco", + "name": "Appco", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/appco", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/appco/", + "description": "Appco" + }, + { + "id": "th_evento", + "name": "Evento", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Evento", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Evento/", + "description": "A Function Landing Page " + }, + { + "id": "th_lazyfox", + "name": "Lazyfox", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Lazyfox", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Lazyfox/", + "description": "HTML5 Single page landing template" + }, + { + "id": "th_avilon", + "name": "Avilon", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/avilon", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/avilon/", + "description": "Free landing page template made with Bootstrap 4." + }, + { + "id": "th_codrops_scribbler", + "name": "Codrops Scribbler", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/codrops-scribbler", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/codrops-scribbler/", + "description": "A responsive HTML template for coding projects with a clean, user friendly design. Crafted with the latest web technologies, the template is suitable for landing pages and documentations." + }, + { + "id": "th_rain", + "name": "Rain", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Rain", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Rain/", + "description": "Rain - Free Responsive OnePage App Landing Page Template" + }, + { + "id": "th_slides_horizontal_scroll_landing_page", + "name": "Slides Horizontal Scroll Landing Page", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/slides-horizontal-scroll-landing-page", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/slides-horizontal-scroll-landing-page/", + "description": "Slides Horizontal Scroll Landing Page" + }, + { + "id": "th_snappy", + "name": "Snappy", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/snappy", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/snappy/", + "description": "Snappy - A HTML5 photography website template" + }, + { + "id": "th_appetizer", + "name": "Appetizer", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/appetizer", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/appetizer/", + "description": "Appetizer" + }, + { + "id": "th_applab", + "name": "Applab", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/applab", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/applab/", + "description": "Applab" + }, + { + "id": "th_apex_app", + "name": "Apex App", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/apex_app", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/apex_app/", + "description": "Apex App" + }, + { + "id": "th_metronic_one_page_2", + "name": "Metronic One Page 2", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Metronic-One-Page-2", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Metronic-One-Page-2/", + "description": "Metronic One Page 2" + }, + { + "id": "th_webapp", + "name": "Webapp", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/webapp", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/webapp/", + "description": "Webapp" + }, + { + "id": "th_slides_app_landing_page", + "name": "Slides App Landing Page", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/slides-app-landing-page", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/slides-app-landing-page/", + "description": "Slides App Landing Page" + }, + { + "id": "th_appli", + "name": "Appli", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/appli", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/appli/", + "description": "Appli" + }, + { + "id": "th_landing_a", + "name": "Landing A", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/landing-a", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/landing-a/", + "description": "Landing A" + }, + { + "id": "th_nova", + "name": "Nova", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Nova", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Nova/", + "description": "Free one page HTML template for apps showcasing" + }, + { + "id": "th_app", + "name": "App", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/app", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/app/", + "description": "App" + }, + { + "id": "th_lifetrackr", + "name": "Lifetrackr", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/lifetrackr", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/lifetrackr/", + "description": "Responsive Free App Landing Page Template " + }, + { + "id": "th_appley", + "name": "Appley", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/appley", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/appley/", + "description": "Appley" + }, + { + "id": "th_proapp", + "name": "Proapp", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/proapp", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/proapp/", + "description": "Proapp" + }, + { + "id": "th_mobiapp", + "name": "Mobiapp", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/mobiapp", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/mobiapp/", + "description": "Mobiapp" + }, + { + "id": "th_metronic_one_page", + "name": "Metronic One Page", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Metronic-One-Page", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Metronic-One-Page/", + "description": "Metronic One Page" + }, + { + "id": "th_unapp", + "name": "Unapp", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/unapp", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/unapp/", + "description": "Unapp" + }, + { + "id": "th_soft_landing", + "name": "Soft Landing", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/soft-landing", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/soft-landing/", + "description": "Soft Landing" + }, + { + "id": "th_mobile_app", + "name": "Mobile App", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/mobile-app", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/mobile-app/", + "description": "Mobile App" + }, + { + "id": "th_applus", + "name": "Applus", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/applus", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/applus/", + "description": "App Plus Responsive One Page Template" + }, + { + "id": "th_laslesvpn", + "name": "Laslesvpn", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/LaslesVPN", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/LaslesVPN/", + "description": "Bootstrap 5 Landing Page Template" + }, + { + "id": "th_saascandy", + "name": "Saascandy", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/SaasCandy", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/SaasCandy/", + "description": "Saascandy" + }, + { + "id": "th_react_app_template", + "name": "React App Template", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/react-app-template", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/react-app-template/", + "description": "An ideal pre-configured create-react-app template for your next ReactJS Project." + }, + { + "id": "th_imminent_new", + "name": "Imminent New", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/imminent-new", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/imminent-new/", + "description": "Free Coming Soon Page Template" + }, + { + "id": "th_rentiz", + "name": "Rentiz", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/rentiz", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/rentiz/", + "description": "Free HTML Landing Page Template" + }, + { + "id": "th_append_new", + "name": "Append New", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Append-New", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Append-New/", + "description": "Free Bootstrap Website Template" + }, + { + "id": "th_bootslander", + "name": "Bootslander", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Bootslander", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Bootslander/", + "description": "Free Bootstrap 5 Landing Page Template" + }, + { + "id": "th_ilanding", + "name": "Ilanding", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/iLanding", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/iLanding/", + "description": "Free Bootstrap 5 Landing Page Template" + }, + { + "id": "th_onepage", + "name": "Onepage", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/OnePage", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/OnePage/", + "description": "Free Booststrap 5 Website Template" + }, + { + "id": "th_desgy", + "name": "Desgy", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Desgy", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Desgy/", + "description": "Free Tailwind-Next.js Landing Page Template" + }, + { + "id": "th_windmill_saas", + "name": "Windmill Saas", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/windmill-saas", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/windmill-saas/", + "description": "Windmill Saas" + }, + { + "id": "th_nextjs_tailwind_event_landing_page", + "name": "Nextjs Tailwind Event Landing Page", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/nextjs-tailwind-event-landing-page", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/nextjs-tailwind-event-landing-page/", + "description": "Nextjs Tailwind Event Landing Page" + }, + { + "id": "th_nextjs_tailwind_app_presentation_page", + "name": "Nextjs Tailwind App Presentation Page", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/NextJS-Tailwind-App-Presentation-Page", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/NextJS-Tailwind-App-Presentation-Page/", + "description": "Nextjs Tailwind App Presentation Page" + }, + { + "id": "th_saasland", + "name": "Saasland", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/saasland", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/saasland/", + "description": "Saasland" + }, + { + "id": "th_saaspal", + "name": "Saaspal", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/SaaSpal", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/SaaSpal/", + "description": "Saaspal" + }, + { + "id": "th_appvila", + "name": "Appvila", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Appvila", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Appvila/", + "description": "Appvila" + }, + { + "id": "th_saasintro", + "name": "Saasintro", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/SaaSintro", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/SaaSintro/", + "description": "Saasintro" + }, + { + "id": "th_paidin", + "name": "Paidin", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/paidin", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/paidin/", + "description": "Paidin - Free NextJs Landing Page Template with App Directory Routing" + }, + { + "id": "th_landingzero", + "name": "Landingzero", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/landingzero", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/landingzero/", + "description": "Landingzero" + }, + { + "id": "th_laslesvpn_nextjs", + "name": "Laslesvpn Nextjs", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/LaslesVPN-nextjs", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/LaslesVPN-nextjs/", + "description": "LaslesVPN - An Open Source Landingpage For VPN or Apps." + }, + { + "id": "th_rainblur_landing_page", + "name": "Rainblur Landing Page", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Rainblur-Landing-Page", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Rainblur-Landing-Page/", + "description": "Rainblur Landing Page" + }, + { + "id": "th_agent_ai", + "name": "Agent Ai", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/agent-ai", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/agent-ai/", + "description": "AI Agent – Free Next.js Landing Page Template" + }, + { + "id": "th_saas", + "name": "Saas", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/saas", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/saas/", + "description": "Saas - Tailwind One Page Template" + }, + { + "id": "th_lingare", + "name": "Lingare", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/lingare", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/lingare/", + "description": "Lingare - Fashion tailwind landing page" + }, + { + "id": "th_newsletter", + "name": "Newsletter", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/newsletter", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/newsletter/", + "description": "Newsletter - Tailwind landing page Template for your Newsletter" + }, + { + "id": "th_skilline", + "name": "Skilline", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/skilline", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/skilline/", + "description": "Skilline - Landing Page" + }, + { + "id": "da_brand_html5_app_landing_page_responsive_web_template", + "name": "Brand Html5 App Landing Page Responsive Web Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "brand-html5-app-landing-page-responsive-web-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/brand-html5-app-landing-page-responsive-web-template/", + "description": "Brand Html5 App Landing Page Responsive Web Template" + }, + { + "id": "da_clouds_html5_multipurpose_landing_page_template", + "name": "Clouds Html5 Multipurpose Landing Page Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "clouds-html5-multipurpose-landing-page-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/clouds-html5-multipurpose-landing-page-template/", + "description": "Clouds Html5 Multipurpose Landing Page Template" + }, + { + "id": "da_foodz_mobile_app_bootstrap_landing_page", + "name": "Foodz Mobile App Bootstrap Landing Page", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "foodz-mobile-app-bootstrap-landing-page", + "preview_url": "https://dawidolko.github.io/Website-Templates/foodz-mobile-app-bootstrap-landing-page/", + "description": "Foodz Mobile App Bootstrap Landing Page" + }, + { + "id": "da_line_free_app_landing_page_responsive_web_template", + "name": "Line Free App Landing Page Responsive Web Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "line-free-app-landing-page-responsive-web-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/line-free-app-landing-page-responsive-web-template/", + "description": "Line Free App Landing Page Responsive Web Template" + }, + { + "id": "da_mobile_app_free_one_page_responsive_html5_landing_page", + "name": "Mobile App Free One Page Responsive Html5 Landing Page", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "mobile-app-free-one-page-responsive-html5-landing-page", + "preview_url": "https://dawidolko.github.io/Website-Templates/mobile-app-free-one-page-responsive-html5-landing-page/", + "description": "Mobile App Free One Page Responsive Html5 Landing Page" + }, + { + "id": "da_mobile_app_landing_page_html5_template", + "name": "Mobile App Landing Page Html5 Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "mobile-app-landing-page-html5-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/mobile-app-landing-page-html5-template/", + "description": "Mobile App Landing Page Html5 Template" + }, + { + "id": "da_one_page_wonder", + "name": "One Page Wonder", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "one-page-wonder", + "preview_url": "https://dawidolko.github.io/Website-Templates/one-page-wonder/", + "description": "One Page Wonder" + }, + { + "id": "da_skytouch_onepage_bootstrap_responsive_web_template", + "name": "Skytouch Onepage Bootstrap Responsive Web Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "skytouch-onepage-bootstrap-responsive-web-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/skytouch-onepage-bootstrap-responsive-web-template/", + "description": "Skytouch Onepage Bootstrap Responsive Web Template" + }, + { + "id": "da_smartapp_free_html5_landing_page", + "name": "Smartapp Free Html5 Landing Page", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "smartapp-free-html5-landing-page", + "preview_url": "https://dawidolko.github.io/Website-Templates/smartapp-free-html5-landing-page/", + "description": "Smartapp Free Html5 Landing Page" + } + ] + }, + { + "id": "technology", + "name": "Технологии и IT", + "icon": "chart-bar", + "templates": [ + { + "id": "h5up_hyperspace", + "name": "Hyperspace", + "source": "html5up", + "description": "Сайт облачного хостинга", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "hyperspace", + "preview_url": "https://html5up.net/hyperspace/" + }, + { + "id": "h5up_future_imperfect", + "name": "Future Imperfect", + "source": "html5up", + "description": "Блог с боковой панелью", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "future-imperfect", + "preview_url": "https://html5up.net/future-imperfect/" + }, + { + "id": "lz_cloud_hosting", + "name": "Cloud Hosting", + "source": "learning-zone", + "description": "Облачный хостинг", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "cloud-hosting-free-bootstrap-responsive-website-template", + "preview_url": "https://learning-zone.github.io/website-templates/cloud-hosting-free-bootstrap-responsive-website-template/" + }, + { + "id": "lz_fiber_hosting", + "name": "Fiber Hosting", + "source": "learning-zone", + "description": "Высокоскоростной хостинг", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "fiber-hosting-bootstrap-website-template", + "preview_url": "https://learning-zone.github.io/website-templates/fiber-hosting-bootstrap-website-template/" + }, + { + "id": "lz_speed_hosting", + "name": "Speed Hosting", + "source": "learning-zone", + "description": "Быстрый веб-хостинг", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "speed-hosting-bootstrap-free-html5-template", + "preview_url": "https://learning-zone.github.io/website-templates/speed-hosting-bootstrap-free-html5-template/" + }, + { + "id": "lz_idata_hosting", + "name": "IData Hosting", + "source": "learning-zone", + "description": "Хостинг и облачные услуги", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "idata-hosting-free-bootstrap-responsive-website-template", + "preview_url": "https://learning-zone.github.io/website-templates/idata-hosting-free-bootstrap-responsive-website-template/" + }, + { + "id": "h5up_solid_state", + "name": "Solid State", + "source": "html5up", + "description": "Панель управления", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "solid-state", + "preview_url": "https://html5up.net/solid-state/" + }, + { + "id": "h5up_spectral", + "name": "Spectral", + "source": "html5up", + "description": "Администраторская панель", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "spectral", + "preview_url": "https://html5up.net/spectral/" + }, + { + "id": "h5up_stellar", + "name": "Stellar", + "source": "html5up", + "description": "Интерфейс администратора", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "stellar", + "preview_url": "https://html5up.net/stellar/" + }, + { + "id": "h5up_tessellate", + "name": "Tessellate", + "source": "html5up", + "description": "Модульная админ-панель", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "tessellate", + "preview_url": "https://html5up.net/tessellate/" + }, + { + "id": "h5up_txt", + "name": "TXT", + "source": "html5up", + "description": "Текстовый интерфейс управления", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "txt", + "preview_url": "https://html5up.net/txt/" + }, + { + "id": "h5up_verti", + "name": "Verti", + "source": "html5up", + "description": "Вертикальная панель управления", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "verti", + "preview_url": "https://html5up.net/verti/" + }, + { + "id": "h5up_zerofour", + "name": "ZeroFour", + "source": "html5up", + "description": "Современная админ-панель", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "zerofour", + "preview_url": "https://html5up.net/zerofour/" + }, + { + "id": "h5up_parallelism", + "name": "Parallelism", + "source": "html5up", + "description": "Дашборд управления", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "parallelism", + "preview_url": "https://html5up.net/parallelism/" + }, + { + "id": "lz_dream_admin", + "name": "Dream Admin", + "source": "learning-zone", + "description": "Админ-панель на Bootstrap", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "free-bootstrap-admin-template-dream", + "preview_url": "https://learning-zone.github.io/website-templates/free-bootstrap-admin-template-dream/" + }, + { + "id": "lz_sb_admin", + "name": "SB Admin", + "source": "learning-zone", + "description": "Простая админ-панель", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "sb-admin", + "preview_url": "https://learning-zone.github.io/website-templates/sb-admin/" + }, + { + "id": "lz_sb_admin_2", + "name": "SB Admin 2", + "source": "learning-zone", + "description": "Продвинутая админ-панель", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "sb-admin-2", + "preview_url": "https://learning-zone.github.io/website-templates/sb-admin-2/" + }, + { + "id": "lz_insight_admin", + "name": "Insight Admin", + "source": "learning-zone", + "description": "Аналитическая панель управления", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "insight-free-bootstrap-html5-admin-template", + "preview_url": "https://learning-zone.github.io/website-templates/insight-free-bootstrap-html5-admin-template/" + }, + { + "id": "lz_hybrid_admin", + "name": "Hybrid Admin", + "source": "learning-zone", + "description": "Гибридная админ-панель", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "hybrid-bootstrap-admin-template", + "preview_url": "https://learning-zone.github.io/website-templates/hybrid-bootstrap-admin-template/" + }, + { + "id": "sb_admin_panel", + "name": "StartBootstrap SB Admin", + "source": "startbootstrap", + "description": "Универсальная админ-панель", + "repo_url": "https://github.com/StartBootstrap/startbootstrap-sb-admin", + "sparse_path": ".", + "preview_url": "https://startbootstrap.github.io/startbootstrap-sb-admin/" + }, + { + "id": "th_purpleadmin_free_admin_template", + "name": "Purpleadmin Free Admin Template", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/PurpleAdmin-Free-Admin-Template", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/PurpleAdmin-Free-Admin-Template/", + "description": "Purpleadmin Free Admin Template" + }, + { + "id": "th_ready_bootstrap_dashboard", + "name": "Ready Bootstrap Dashboard", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Ready-Bootstrap-Dashboard", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Ready-Bootstrap-Dashboard/", + "description": "Free Bootstrap 4 Admin Dashboard" + }, + { + "id": "th_stellar", + "name": "Stellar", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Stellar", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Stellar/", + "description": "Stellar is completely based on the latest version of Bootstrap 4. Stellar Admin is designed to reflect the simplicity and svelte of the components and UI elements and coded to perfection with well-organized code." + }, + { + "id": "th_adminlte", + "name": "Adminlte", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/adminLTE", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/adminLTE/", + "description": "Adminlte" + }, + { + "id": "th_eliteadminlite", + "name": "Eliteadminlite", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/eliteadminlite", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/eliteadminlite/", + "description": "Eliteadminlite" + }, + { + "id": "th_corona_free_dark_bootstrap_admin_template", + "name": "Corona Free Dark Bootstrap Admin Template", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/corona-free-dark-bootstrap-admin-template", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/corona-free-dark-bootstrap-admin-template/", + "description": "Free dark admin template based on Bootstrap 4." + }, + { + "id": "th_matrix_admin", + "name": "Matrix Admin", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/matrix-admin", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/matrix-admin/", + "description": "Matrix Admin" + }, + { + "id": "th_atlantis_lite", + "name": "Atlantis Lite", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Atlantis-Lite", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Atlantis-Lite/", + "description": "Free Bootstrap 4 Admin Dashboard" + }, + { + "id": "th_modular_admin", + "name": "Modular Admin", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/modular-admin", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/modular-admin/", + "description": "Bootstrap 4 Free Admin Template. " + }, + { + "id": "th_admincast", + "name": "Admincast", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/admincast", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/admincast/", + "description": "Admincast" + }, + { + "id": "th_adminkit", + "name": "Adminkit", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/adminkit", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/adminkit/", + "description": "AdminKit is an free & open-source HTML dashboard & admin template based on Bootstrap 5" + }, + { + "id": "th_ngx_admin", + "name": "Ngx Admin", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/ngx-admin", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/ngx-admin/", + "description": "Admin dashboard template based on Angular 4+, Bootstrap 4 (previously known as ng2-admin)" + }, + { + "id": "th_elaadmin", + "name": "Elaadmin", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/elaadmin", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/elaadmin/", + "description": "A clean & completely free Bootstrap 4 admin dashboard template" + }, + { + "id": "th_sb_admin", + "name": "Sb Admin", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/sb-admin", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/sb-admin/", + "description": "A free, open source, Bootstrap admin theme created by Start Bootstrap" + }, + { + "id": "th_coreui_free_bootstrap_admin_template", + "name": "Coreui Free Bootstrap Admin Template", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/CoreUI-Free-Bootstrap-Admin-Template", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/CoreUI-Free-Bootstrap-Admin-Template/", + "description": "CoreUI is Bootstrap 4 based admin template which is built with Angular2, AngularJS, React.js & Vue.js support." + }, + { + "id": "th_tabler", + "name": "Tabler", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/tabler", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/tabler/", + "description": "Tabler is free and open-source HTML Dashboard UI Kit built on Bootstrap 4" + }, + { + "id": "th_v_dashboard", + "name": "V Dashboard", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/v-dashboard", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/v-dashboard/", + "description": "V Dashboard" + }, + { + "id": "th_polluxui_free_admin_template", + "name": "Polluxui Free Admin Template", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/polluxui-free-admin-template", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/polluxui-free-admin-template/", + "description": "PolluxUI Free Bootstrap Admin Dashboard Template" + }, + { + "id": "th_material_dashboard_2", + "name": "Material Dashboard 2", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/material-dashboard-2", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/material-dashboard-2/", + "description": "Material Dashboard 2" + }, + { + "id": "th_celestialadmin_free_admin_template", + "name": "Celestialadmin Free Admin Template", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/celestialAdmin-free-admin-template", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/celestialAdmin-free-admin-template/", + "description": "Celestial Free Bootstrap Admin Dashboard Template" + }, + { + "id": "th_breeze_free_bootstrap_admin_template", + "name": "Breeze Free Bootstrap Admin Template", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Breeze-Free-Bootstrap-Admin-Template", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Breeze-Free-Bootstrap-Admin-Template/", + "description": "Free admin dashboard with Bootstrap 4" + }, + { + "id": "th_admin_one", + "name": "Admin One", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/admin-one", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/admin-one/", + "description": "Admin One" + }, + { + "id": "th_windmill_dashboard", + "name": "Windmill Dashboard", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/windmill-dashboard", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/windmill-dashboard/", + "description": "Windmill Dashboard" + }, + { + "id": "th_star_admin2_free_admin_template", + "name": "Star Admin2 Free Admin Template", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/star-admin2-free-admin-template", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/star-admin2-free-admin-template/", + "description": "Star-Admin-2- Free-Bootstrap-Admin-Template" + }, + { + "id": "th_ruang_admin", + "name": "Ruang Admin", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/ruang-admin", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/ruang-admin/", + "description": "Ruang Admin" + }, + { + "id": "th_stisla_1", + "name": "Stisla 1", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/stisla-1", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/stisla-1/", + "description": "Free Bootstrap Admin Template" + }, + { + "id": "th_seomaster", + "name": "Seomaster", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/seomaster", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/seomaster/", + "description": "Seomaster" + }, + { + "id": "th_admin", + "name": "Admin", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/admin", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/admin/", + "description": "Admin" + }, + { + "id": "th_argon_dashboard", + "name": "Argon Dashboard", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/argon-dashboard", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/argon-dashboard/", + "description": "Argon Dashboard" + }, + { + "id": "th_digital_1", + "name": "Digital 1", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/digital-1", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/digital-1/", + "description": "Digital 1" + }, + { + "id": "th_seo_dream", + "name": "Seo Dream", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/seo-dream", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/seo-dream/", + "description": "Seo Dream" + }, + { + "id": "th_k_wd_dashboard", + "name": "K Wd Dashboard", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/k-wd-dashboard", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/k-wd-dashboard/", + "description": "K Wd Dashboard" + }, + { + "id": "th_startbootstrap_sb_admin_2", + "name": "Sb Admin 2", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/startbootstrap-sb-admin-2", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/startbootstrap-sb-admin-2/", + "description": "A free, open source, Bootstrap admin theme created by Start Bootstrap" + }, + { + "id": "th_marutiadmin", + "name": "Marutiadmin", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/marutiadmin", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/marutiadmin/", + "description": "Marutiadmin" + }, + { + "id": "th_gradient_able_free_bootstrap_admin_template", + "name": "Gradient Able Free Bootstrap Admin Template", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Gradient-Able-free-bootstrap-admin-template", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Gradient-Able-free-bootstrap-admin-template/", + "description": "Gradient Able Free Bootstrap Admin Template" + }, + { + "id": "th_plus_admin", + "name": "Plus Admin", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/plus-admin", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/plus-admin/", + "description": "Plus Admin" + }, + { + "id": "th_adminbsbmaterialdesign", + "name": "Adminbsbmaterialdesign", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/AdminBSBMaterialDesign", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/AdminBSBMaterialDesign/", + "description": "Free Bootstrap 3 admin dashboard template made with Material Design." + }, + { + "id": "th_kiaalap", + "name": "Kiaalap", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/kiaalap", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/kiaalap/", + "description": "Free admin dashboard template" + }, + { + "id": "th_seogram", + "name": "Seogram", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/seogram", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/seogram/", + "description": "Seogram" + }, + { + "id": "th_elegantadminlite", + "name": "Elegantadminlite", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/elegantadminlite", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/elegantadminlite/", + "description": "Elegantadminlite" + }, + { + "id": "th_concept", + "name": "Concept", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/concept", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/concept/", + "description": "Free Bootstrap 4 admin dashboard template" + }, + { + "id": "th_themekit", + "name": "Themekit", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/themekit", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/themekit/", + "description": "Bootstrap 4 admin template." + }, + { + "id": "th_adminx", + "name": "Adminx", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/AdminX", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/AdminX/", + "description": "AdminX – a free and open source admin control panel based on Bootstrap 4.x" + }, + { + "id": "th_ecohosting", + "name": "Ecohosting", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/ecohosting", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/ecohosting/", + "description": "Ecohosting" + }, + { + "id": "th_adminpro", + "name": "Adminpro", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/adminpro", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/adminpro/", + "description": "Adminpro" + }, + { + "id": "th_webhostingservice", + "name": "Webhostingservice", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/webhostingservice", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/webhostingservice/", + "description": "Webhostingservice" + }, + { + "id": "th_seos", + "name": "Seos", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/seos", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/seos/", + "description": "Seos" + }, + { + "id": "th_octopus", + "name": "Octopus", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/octopus", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/octopus/", + "description": "Free Bootstrap admin dashboard template" + }, + { + "id": "th_adminwrap", + "name": "Adminwrap", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/adminwrap", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/adminwrap/", + "description": "Adminwrap" + }, + { + "id": "th_soft_tech", + "name": "Soft Tech", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/soft-tech", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/soft-tech/", + "description": "Soft Tech" + }, + { + "id": "th_notika", + "name": "Notika", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/notika", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/notika/", + "description": "Free Bootstrap admin dashboard" + }, + { + "id": "th_chameleon_admin", + "name": "Chameleon Admin", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/chameleon-admin", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/chameleon-admin/", + "description": "Chameleon Admin" + }, + { + "id": "th_jeweler", + "name": "Jeweler", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/jeweler", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/jeweler/", + "description": "Free Bootstrap admin dashboard template" + }, + { + "id": "th_now_ui_dashboard", + "name": "Now Ui Dashboard", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/now-ui-dashboard", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/now-ui-dashboard/", + "description": "Now Ui Dashboard" + }, + { + "id": "th_srtdash", + "name": "Srtdash", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/srtdash", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/srtdash/", + "description": "Free admin dashboard template" + }, + { + "id": "th_flaxseo", + "name": "Flaxseo", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/flaxseo", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/flaxseo/", + "description": "Flaxseo" + }, + { + "id": "th_light_dashboard", + "name": "Light Dashboard", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/light-dashboard", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/light-dashboard/", + "description": "Light Bootstrap Dashboard is an admin dashboard template designed to be beautiful and simple. " + }, + { + "id": "th_nice_admin", + "name": "Nice Admin", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/nice-admin", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/nice-admin/", + "description": "Nice Admin" + }, + { + "id": "th_monster_lite", + "name": "Monster Lite", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/monster-lite", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/monster-lite/", + "description": "Free Admin Dashboard Template Based On Bootstrap 4" + }, + { + "id": "th_xtreme_admin", + "name": "Xtreme Admin", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/xtreme-admin", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/xtreme-admin/", + "description": "Xtreme Admin" + }, + { + "id": "th_admin_4b", + "name": "Admin 4B", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/admin-4b", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/admin-4b/", + "description": "Bootstrap 4 Admin Template." + }, + { + "id": "th_material_lite", + "name": "Material Lite", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/material-lite", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/material-lite/", + "description": "Responsive Admin Dashboard Template" + }, + { + "id": "th_intechnic", + "name": "Intechnic", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/intechnic", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/intechnic/", + "description": "Intechnic" + }, + { + "id": "th_maxitechture", + "name": "Maxitechture", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/maxitechture", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/maxitechture/", + "description": "Maxitechture" + }, + { + "id": "th_hightech", + "name": "Hightech", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/hightech", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/hightech/", + "description": "Hightech" + }, + { + "id": "th_seogo", + "name": "Seogo", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/seogo", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/seogo/", + "description": "Seogo" + }, + { + "id": "th_hostcloud", + "name": "Hostcloud", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/hostcloud", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/hostcloud/", + "description": "Hostcloud" + }, + { + "id": "th_hosting", + "name": "Hosting", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/hosting", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/hosting/", + "description": "Hosting" + }, + { + "id": "th_greatseo", + "name": "Greatseo", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/greatseo", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/greatseo/", + "description": "Greatseo" + }, + { + "id": "th_datarc", + "name": "Datarc", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Datarc", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Datarc/", + "description": "Datarc" + }, + { + "id": "th_dashboard", + "name": "Dashboard", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/dashboard", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/dashboard/", + "description": "Dashboard" + }, + { + "id": "th_software", + "name": "Software", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Software", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Software/", + "description": "Software" + }, + { + "id": "th_modernize", + "name": "Modernize", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Modernize", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Modernize/", + "description": "admin dashboard template" + }, + { + "id": "th_sneat", + "name": "Sneat", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/sneat", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/sneat/", + "description": "Free Vuetify Vuejs 3 Admin Template" + }, + { + "id": "th_hightechit", + "name": "Hightechit", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/HighTechIT", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/HighTechIT/", + "description": "Hightechit" + }, + { + "id": "th_materialdashboard2", + "name": "Materialdashboard2", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/MaterialDashboard2", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/MaterialDashboard2/", + "description": "Materialdashboard2" + }, + { + "id": "th_dashdarkx", + "name": "Dashdarkx", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/dashdarkX", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/dashdarkX/", + "description": "React Material-UI dark admin dashboard template" + }, + { + "id": "th_modernize_mui_admin", + "name": "Modernize Mui Admin", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/modernize-mui-admin", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/modernize-mui-admin/", + "description": "Modernize Mui Admin" + }, + { + "id": "th_argon_dashboard_material_ui", + "name": "Argon Dashboard Material Ui", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/argon-dashboard-material-ui", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/argon-dashboard-material-ui/", + "description": "This is the Material UI version of the Argon Dashboard React." + }, + { + "id": "th_carpatin_dashboard_free", + "name": "Carpatin Dashboard Free", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/carpatin-dashboard-free", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/carpatin-dashboard-free/", + "description": "Carpatin Dashboard Free" + }, + { + "id": "th_volt_react_dashboard", + "name": "Volt React Dashboard", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/volt-react-dashboard", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/volt-react-dashboard/", + "description": "Free and open source React.js admin dashboard template and UI library based on Bootstrap 5" + }, + { + "id": "th_react_typescript_dashboard", + "name": "React Typescript Dashboard", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/react-typescript-dashboard", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/react-typescript-dashboard/", + "description": "The first project I used Typescript. Very useful! I developed a dashboard using the data grid in MUI and Recharts charts." + }, + { + "id": "th_react_dashboard_material", + "name": "React Dashboard Material", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/react-dashboard-material", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/react-dashboard-material/", + "description": "React Dashboard Material" + }, + { + "id": "th_mantis_free_react_admin_template", + "name": "Mantis Free React Admin Template", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/mantis-free-react-admin-template", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/mantis-free-react-admin-template/", + "description": "Mantis is React Dashboard Template having combine tone of 2 popular react component library - MUI and Ant Design principles." + }, + { + "id": "th_vision_ui_dashboard_react", + "name": "Vision Ui Dashboard React", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/vision-ui-dashboard-react", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/vision-ui-dashboard-react/", + "description": "Vision Ui Dashboard React" + }, + { + "id": "th_soft_ui_dashboard_react", + "name": "Soft Ui Dashboard React", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/soft-ui-dashboard-react", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/soft-ui-dashboard-react/", + "description": "Soft UI Dashboard React - Free Dashboard using React and Material UI" + }, + { + "id": "th_kaiadmin_lite", + "name": "Kaiadmin Lite", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/kaiadmin-lite", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/kaiadmin-lite/", + "description": "Free and Open-source Bootstrap 5 Admin Dashboard Template" + }, + { + "id": "th_seodash", + "name": "Seodash", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/SEODash", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/SEODash/", + "description": "Free Bootstrap 5 Admin Dashboard Website Template" + }, + { + "id": "th_commercialbundle2025", + "name": "Commercialbundle2025", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/CommercialBundle2025", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/CommercialBundle2025/", + "description": "50 Free Admin & eCom Templates for 2025" + }, + { + "id": "th_mantis_bootstrap", + "name": "Mantis Bootstrap", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Mantis-Bootstrap", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Mantis-Bootstrap/", + "description": "Free Bootstrap 5 Admin Template" + }, + { + "id": "th_staradmin_free_bootstrap_admin_template", + "name": "Staradmin Free Bootstrap Admin Template", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/StarAdmin-Free-Bootstrap-Admin-Template", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/StarAdmin-Free-Bootstrap-Admin-Template/", + "description": "A Free Responsive Admin Dashboard Template Built With Bootstrap 4. Elegant UI Theme for Your Web App!" + }, + { + "id": "th_materialdash_admin", + "name": "Materialdash Admin", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/MaterialDash-Admin", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/MaterialDash-Admin/", + "description": "Materialdash Admin" + }, + { + "id": "th_black_dashboard", + "name": "Black Dashboard", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/black-dashboard", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/black-dashboard/", + "description": "Black Dashboard" + }, + { + "id": "th_flexy_bootstrap_lite", + "name": "Flexy Bootstrap Lite", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/flexy-bootstrap-lite", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/flexy-bootstrap-lite/", + "description": "Flexy Admin Lite is a Free Modern Bootstrap 5 WebApp & Admin Dashboard Html Template elegant design, clean and organised code." + }, + { + "id": "th_materially_free_react_admin_template", + "name": "Materially Free React Admin Template", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/materially-free-react-admin-template", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/materially-free-react-admin-template/", + "description": "Free version of Materially admin template" + }, + { + "id": "th_mantis_vuejs_admintemplate", + "name": "Mantis Vuejs Admintemplate", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Mantis-vuejs-AdminTemplate", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Mantis-vuejs-AdminTemplate/", + "description": "Mantis Vue and Vuetify free admin template" + }, + { + "id": "th_argon_dashboard_tailwind", + "name": "Argon Dashboard Tailwind", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/argon-dashboard-tailwind", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/argon-dashboard-tailwind/", + "description": "Argon Dashboard Tailwind - Free and OpenSource TailwindCSS Dashboard" + }, + { + "id": "th_dashboardkit", + "name": "Dashboardkit", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/DashboardKit", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/DashboardKit/", + "description": "Dashboardkit" + }, + { + "id": "th_soft_ui_dashboard_tailwind", + "name": "Soft Ui Dashboard Tailwind", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Soft-UI-Dashboard-Tailwind", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Soft-UI-Dashboard-Tailwind/", + "description": "Soft Ui Dashboard Tailwind" + }, + { + "id": "th_tailadmin", + "name": "Tailadmin", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/TailAdmin", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/TailAdmin/", + "description": "https://themewagon.github.io/TailAdmin/" + }, + { + "id": "th_tailadmin_nextjs", + "name": "Tailadmin Nextjs", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/tailadmin-nextjs", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/tailadmin-nextjs/", + "description": "Tailadmin Nextjs" + }, + { + "id": "th_tailadmin_vuejs", + "name": "Tailadmin Vuejs", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/tailadmin-vuejs", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/tailadmin-vuejs/", + "description": "Tailadmin Vuejs" + }, + { + "id": "th_aurora_free", + "name": "Aurora Free", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/aurora-free", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/aurora-free/", + "description": "Aurora Free React Material-UI Admin Template." + }, + { + "id": "th_matdash_nextjs", + "name": "Matdash Nextjs", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/matdash-nextjs", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/matdash-nextjs/", + "description": "MatDash Free Tailwind Next.js Admin Template" + }, + { + "id": "th_material_dashboard_tailwind_old", + "name": "Material Dashboard Tailwind Old ", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/material-dashboard-tailwind-old-", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/material-dashboard-tailwind-old-/", + "description": "Material Dashboard Tailwind Old " + }, + { + "id": "th_duralux_admin", + "name": "Duralux Admin", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Duralux-admin", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Duralux-admin/", + "description": "Duralux - CRM Admin & Dashboard HTML Template" + }, + { + "id": "th_material_dashboard_shadcn_vue", + "name": "Material Dashboard Shadcn Vue", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/material-dashboard-shadcn-vue", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/material-dashboard-shadcn-vue/", + "description": "Material Dashboard Shadcn Vue – A modern CRM template for Vue developers" + }, + { + "id": "th_material_tailwind_dashboard_react", + "name": "Material Tailwind Dashboard React", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/material-tailwind-dashboard-react", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/material-tailwind-dashboard-react/", + "description": "Material Tailwind Dashboard React" + }, + { + "id": "th_smart_home", + "name": "Smart Home", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/smart-home", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/smart-home/", + "description": "Smart Home Dashboard Template – Next.js Admin UI" + }, + { + "id": "th_inapp", + "name": "Inapp", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/inapp", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/inapp/", + "description": "InApp Free Inventory Admin Dashboard Template" + }, + { + "id": "th_dasher", + "name": "Dasher", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/dasher", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/dasher/", + "description": "Dasher UI - Free Bootstrap 5 Admin Dashboard Template" + }, + { + "id": "da_cloud_hosting_free_bootstrap_responsive_website_template", + "name": "Cloud Hosting Free Bootstrap Responsive Website Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "cloud-hosting-free-bootstrap-responsive-website-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/cloud-hosting-free-bootstrap-responsive-website-template/", + "description": "Cloud Hosting Free Bootstrap Responsive Website Template" + }, + { + "id": "da_fiber_hosting_bootstrap_website_template", + "name": "Fiber Hosting Bootstrap Website Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "fiber-hosting-bootstrap-website-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/fiber-hosting-bootstrap-website-template/", + "description": "Fiber Hosting Bootstrap Website Template" + }, + { + "id": "da_free_bootstrap_admin_template_dream", + "name": "Free Bootstrap Admin Template Dream", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "free-bootstrap-admin-template-dream", + "preview_url": "https://dawidolko.github.io/Website-Templates/free-bootstrap-admin-template-dream/", + "description": "Free Bootstrap Admin Template Dream" + }, + { + "id": "da_hybrid_bootstrap_admin_template", + "name": "Hybrid Bootstrap Admin Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "hybrid-bootstrap-admin-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/hybrid-bootstrap-admin-template/", + "description": "Hybrid Bootstrap Admin Template" + }, + { + "id": "da_idata_hosting_free_bootstrap_responsive_website_template", + "name": "Idata Hosting Free Bootstrap Responsive Website Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "idata-hosting-free-bootstrap-responsive-website-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/idata-hosting-free-bootstrap-responsive-website-template/", + "description": "Idata Hosting Free Bootstrap Responsive Website Template" + }, + { + "id": "da_insight_free_bootstrap_html5_admin_template", + "name": "Insight Free Bootstrap Html5 Admin Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "insight-free-bootstrap-html5-admin-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/insight-free-bootstrap-html5-admin-template/", + "description": "Insight Free Bootstrap Html5 Admin Template" + }, + { + "id": "da_matrix_free_bootstrap_admin_template", + "name": "Matrix Free Bootstrap Admin Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "matrix-free-bootstrap-admin-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/matrix-free-bootstrap-admin-template/", + "description": "Matrix Free Bootstrap Admin Template" + }, + { + "id": "da_sb_admin_2", + "name": "Sb Admin 2", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "sb-admin-2", + "preview_url": "https://dawidolko.github.io/Website-Templates/sb-admin-2/", + "description": "Sb Admin 2" + }, + { + "id": "da_sb_admin", + "name": "Sb Admin", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "sb-admin", + "preview_url": "https://dawidolko.github.io/Website-Templates/sb-admin/", + "description": "Sb Admin" + }, + { + "id": "da_speed_hosting_bootstrap_free_html5_template", + "name": "Speed Hosting Bootstrap Free Html5 Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "speed-hosting-bootstrap-free-html5-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/speed-hosting-bootstrap-free-html5-template/", + "description": "Speed Hosting Bootstrap Free Html5 Template" + }, + { + "id": "da_startbootstrap_sb_admin_1_0_2", + "name": "Sb Admin 1.0.2", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "startbootstrap-sb-admin-1.0.2", + "preview_url": "https://dawidolko.github.io/Website-Templates/startbootstrap-sb-admin-1.0.2/", + "description": "Sb Admin 1.0.2" + }, + { + "id": "da_startbootstrap_sb_admin_2_1_0_5", + "name": "Sb Admin 2 1.0.5", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "startbootstrap-sb-admin-2-1.0.5", + "preview_url": "https://dawidolko.github.io/Website-Templates/startbootstrap-sb-admin-2-1.0.5/", + "description": "Sb Admin 2 1.0.5" + }, + { + "id": "da_tech_city_free_coming_soon_bootstrap_responsive_template", + "name": "Tech City Free Coming Soon Bootstrap Responsive Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "tech-city-free-coming-soon-bootstrap-responsive-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/tech-city-free-coming-soon-bootstrap-responsive-template/", + "description": "Tech City Free Coming Soon Bootstrap Responsive Template" + }, + { + "id": "da_techro", + "name": "Techro", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "techro", + "preview_url": "https://dawidolko.github.io/Website-Templates/techro/", + "description": "Techro" + } + ] + }, + { + "id": "blog", + "name": "Блоги и журналы", + "icon": "book", + "templates": [ + { + "id": "h5up_dopetrope", + "name": "Dopetrope", + "source": "html5up", + "description": "Шаблон для медиа-блога", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "dopetrope", + "preview_url": "https://html5up.net/dopetrope/" + }, + { + "id": "h5up_massively", + "name": "Massively", + "source": "html5up", + "description": "Блог с выделенными постами", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "massively", + "preview_url": "https://html5up.net/massively/" + }, + { + "id": "h5up_story", + "name": "Story", + "source": "html5up", + "description": "Шаблон для рассказов и статей", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "story", + "preview_url": "https://html5up.net/story/" + }, + { + "id": "sb_clean_blog", + "name": "StartBootstrap Clean Blog", + "source": "startbootstrap", + "description": "Чистый и простой блог", + "repo_url": "https://github.com/StartBootstrap/startbootstrap-clean-blog", + "sparse_path": ".", + "preview_url": "https://startbootstrap.github.io/startbootstrap-clean-blog/" + }, + { + "id": "th_aznews", + "name": "Aznews", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/aznews", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/aznews/", + "description": "Aznews" + }, + { + "id": "th_news", + "name": "News", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/news", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/news/", + "description": "News" + }, + { + "id": "th_biznews", + "name": "Biznews", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/biznews", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/biznews/", + "description": "Biznews" + }, + { + "id": "th_magnews2", + "name": "Magnews2", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/magnews2", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/magnews2/", + "description": "Magnews2" + }, + { + "id": "th_magz", + "name": "Magz", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Magz", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Magz/", + "description": "Free magazine template based on Bootstrap3, HTML5 & CSS3. " + }, + { + "id": "th_blogger", + "name": "Blogger", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/blogger", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/blogger/", + "description": "Blogger" + }, + { + "id": "th_newsbox", + "name": "Newsbox", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/newsbox", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/newsbox/", + "description": "Newsbox" + }, + { + "id": "th_24_news", + "name": "24 News", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/24-news", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/24-news/", + "description": "24 News" + }, + { + "id": "th_themeblog", + "name": "Themeblog", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/ThemeBlog", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/ThemeBlog/", + "description": "Fully responsive and unique blog template made by Bootstrap" + }, + { + "id": "th_awesome_magazine", + "name": "Awesome Magazine", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/awesome-magazine", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/awesome-magazine/", + "description": "Awesome Magazine" + }, + { + "id": "th_massively", + "name": "Massively", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/massively", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/massively/", + "description": "Free Responsive HTML5 Bootstrap Template for Blogging" + }, + { + "id": "th_newspot", + "name": "Newspot", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/newspot", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/newspot/", + "description": "Newspot" + }, + { + "id": "th_newsoft", + "name": "Newsoft", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/newsoft", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/newsoft/", + "description": "Newsoft" + }, + { + "id": "th_blogy", + "name": "Blogy", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/blogy", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/blogy/", + "description": "Blogy" + }, + { + "id": "th_newsers", + "name": "Newsers", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Newsers", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Newsers/", + "description": "Newsers" + }, + { + "id": "th_blogge", + "name": "Blogge", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Blogge", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Blogge/", + "description": "Free HTML Blogging Website Template" + }, + { + "id": "th_zenblog", + "name": "Zenblog", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/ZenBlog", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/ZenBlog/", + "description": "Free Bootstrap 5 Blogging Website Template" + }, + { + "id": "th_nextjs_tailwind_blog_posts_page", + "name": "Nextjs Tailwind Blog Posts Page", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/nextjs-tailwind-blog-posts-page", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/nextjs-tailwind-blog-posts-page/", + "description": "Nextjs Tailwind Blog Posts Page" + }, + { + "id": "th_nextjs_blog_posts_page", + "name": "Nextjs Blog Posts Page", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/nextjs-blog-posts-page", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/nextjs-blog-posts-page/", + "description": "Nextjs Blog Posts Page" + }, + { + "id": "th_bookworm", + "name": "Bookworm", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Bookworm", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Bookworm/", + "description": "Bookworm – a minimal multi-author free nextjs blog template." + }, + { + "id": "th_tailwind_nextjs_starter_blog", + "name": "Tailwind Nextjs Starter Blog", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Tailwind-Nextjs-Starter-Blog", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Tailwind-Nextjs-Starter-Blog/", + "description": "Tailwind Nextjs Starter Blog – a Next.js, Tailwind CSS blogging starter template." + }, + { + "id": "da_startbootstrap_clean_blog_1_0_2", + "name": "Clean Blog 1.0.2", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "startbootstrap-clean-blog-1.0.2", + "preview_url": "https://dawidolko.github.io/Website-Templates/startbootstrap-clean-blog-1.0.2/", + "description": "Clean Blog 1.0.2" + } + ] + }, + { + "id": "travel", + "name": "Путешествия", + "icon": "home", + "templates": [ + { + "id": "h5up_prologue", + "name": "Prologue", + "source": "html5up", + "description": "Одностраничный портал путешествий", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "prologue", + "preview_url": "https://html5up.net/prologue/" + }, + { + "id": "h5up_twenty", + "name": "Twenty", + "source": "html5up", + "description": "Туристический портал", + "repo_url": "https://github.com/zce/html5up", + "sparse_path": "twenty", + "preview_url": "https://html5up.net/twenty/" + }, + { + "id": "lz_traveller", + "name": "Traveller", + "source": "learning-zone", + "description": "Туристическое агентство", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "traveller-bootstrap-responsive-web-template", + "preview_url": "https://learning-zone.github.io/website-templates/traveller-bootstrap-responsive-web-template/" + }, + { + "id": "sb_grayscale", + "name": "StartBootstrap Grayscale", + "source": "startbootstrap", + "description": "Портал путешествий", + "repo_url": "https://github.com/StartBootstrap/startbootstrap-grayscale", + "sparse_path": ".", + "preview_url": "https://startbootstrap.github.io/startbootstrap-grayscale/" + }, + { + "id": "th_adventure", + "name": "Adventure", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/adventure", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/adventure/", + "description": "Adventure" + }, + { + "id": "th_travelista", + "name": "Travelista", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/travelista", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/travelista/", + "description": "Travelista" + }, + { + "id": "th_tour", + "name": "Tour", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/tour", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/tour/", + "description": "Tour" + }, + { + "id": "th_wooxtravel", + "name": "Wooxtravel", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/wooxtravel", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/wooxtravel/", + "description": "Wooxtravel" + }, + { + "id": "th_hotelier", + "name": "Hotelier", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/hotelier", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/hotelier/", + "description": "Hotelier" + }, + { + "id": "th_gotrip", + "name": "Gotrip", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/gotrip", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/gotrip/", + "description": "Gotrip" + }, + { + "id": "th_travelo", + "name": "Travelo", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/travelo", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/travelo/", + "description": "Travelo" + }, + { + "id": "th_tripbiz", + "name": "Tripbiz", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/tripbiz", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/tripbiz/", + "description": "Tripbiz" + }, + { + "id": "th_star_hotels", + "name": "Star Hotels", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/star-hotels", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/star-hotels/", + "description": "Star Hotels" + }, + { + "id": "th_travelix", + "name": "Travelix", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/travelix", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/travelix/", + "description": "Travelix" + }, + { + "id": "th_hotel", + "name": "Hotel", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/hotel", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/hotel/", + "description": "Hotel" + }, + { + "id": "th_vacation_rental", + "name": "Vacation Rental", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/vacation-rental", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/vacation-rental/", + "description": "Vacation Rental" + }, + { + "id": "th_trips", + "name": "Trips", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/trips", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/trips/", + "description": "Trips" + }, + { + "id": "th_luxury_hotel", + "name": "Luxury Hotel", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/luxury-hotel", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/luxury-hotel/", + "description": "Luxury Hotel" + }, + { + "id": "th_vacation", + "name": "Vacation", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/vacation", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/vacation/", + "description": "Vacation" + }, + { + "id": "th_travelers", + "name": "Travelers", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/travelers", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/travelers/", + "description": "Travelers" + }, + { + "id": "th_trip_spot", + "name": "Trip Spot", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/trip-spot", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/trip-spot/", + "description": "Trip Spot" + }, + { + "id": "th_mercury_travel", + "name": "Mercury Travel", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/mercury-travel", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/mercury-travel/", + "description": "Mercury Travel" + }, + { + "id": "th_travellers", + "name": "Travellers", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Travellers", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Travellers/", + "description": "Travellers" + }, + { + "id": "th_travela", + "name": "Travela", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/travela", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/travela/", + "description": "Travela" + }, + { + "id": "th_mellow", + "name": "Mellow", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/mellow", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/mellow/", + "description": "Free Bootstrap 5 Hotel Website Template" + }, + { + "id": "th_telly", + "name": "Telly", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/telly", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/telly/", + "description": "Free Bootstrap 5 Hotel Website Template" + }, + { + "id": "th_roadtrip", + "name": "Roadtrip", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/RoadTrip", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/RoadTrip/", + "description": "HTML5 & CSS3 Template" + }, + { + "id": "th_bustraveller", + "name": "Bustraveller", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/BusTraveller", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/BusTraveller/", + "description": "Bustraveller" + }, + { + "id": "th_tournest", + "name": "Tournest", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/tournest", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/tournest/", + "description": "Tournest" + }, + { + "id": "da_golden_hotel_free_html5_bootstrap_web_template", + "name": "Golden Hotel Free Html5 Bootstrap Web Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "golden-hotel-free-html5-bootstrap-web-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/golden-hotel-free-html5-bootstrap-web-template/", + "description": "Golden Hotel Free Html5 Bootstrap Web Template" + }, + { + "id": "da_traveller_bootstrap_responsive_web_template", + "name": "Traveller Bootstrap Responsive Web Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "traveller-bootstrap-responsive-web-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/traveller-bootstrap-responsive-web-template/", + "description": "Traveller Bootstrap Responsive Web Template" + } + ] + }, + { + "id": "beauty", + "name": "Красота и свадьбы", + "icon": "palette", + "templates": [ + { + "id": "lz_beauty_salon", + "name": "Beauty Salon", + "source": "learning-zone", + "description": "Салон красоты", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "beauty-salon-bootstrap-html5-template", + "preview_url": "https://learning-zone.github.io/website-templates/beauty-salon-bootstrap-html5-template/" + }, + { + "id": "lz_lovely_wedding", + "name": "Lovely Wedding", + "source": "learning-zone", + "description": "Свадебное агентство", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "lovely-wedding-bootstrap-free-website-template", + "preview_url": "https://learning-zone.github.io/website-templates/lovely-wedding-bootstrap-free-website-template/" + }, + { + "id": "lz_best_wedding", + "name": "Best Wedding", + "source": "learning-zone", + "description": "Портал свадебных услуг", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "the-best-wedding-free-bootstrap-template", + "preview_url": "https://learning-zone.github.io/website-templates/the-best-wedding-free-bootstrap-template/" + }, + { + "id": "lz_wedding_bells", + "name": "Wedding Bells", + "source": "learning-zone", + "description": "Звон свадебных колоколов", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "wedding-bells-free-responsive-html5-template", + "preview_url": "https://learning-zone.github.io/website-templates/wedding-bells-free-responsive-html5-template/" + }, + { + "id": "th_malefashion", + "name": "Malefashion", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/malefashion", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/malefashion/", + "description": "Malefashion" + }, + { + "id": "th_space_dynamic", + "name": "Space Dynamic", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/space-dynamic", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/space-dynamic/", + "description": "Space Dynamic" + }, + { + "id": "th_haircare", + "name": "Haircare", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/haircare", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/haircare/", + "description": "Haircare" + }, + { + "id": "th_salon", + "name": "Salon", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Salon", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Salon/", + "description": "Free website template for hair salon, hairdressing, barber shop, and beauty salon" + }, + { + "id": "th_man_hair_salon", + "name": "Man Hair Salon", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Man-Hair-Salon", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Man-Hair-Salon/", + "description": "Man Hair Salon" + }, + { + "id": "th_labspa2", + "name": "Labspa2", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/labspa2", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/labspa2/", + "description": "Labspa2" + }, + { + "id": "th_sparsh", + "name": "Sparsh", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/sparsh", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/sparsh/", + "description": "Sparsh" + }, + { + "id": "th_hipstyle", + "name": "Hipstyle", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/hipstyle", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/hipstyle/", + "description": "Hipstyle" + }, + { + "id": "th_whitespace", + "name": "Whitespace", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/whitespace", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/whitespace/", + "description": "Whitespace" + }, + { + "id": "th_hotspace", + "name": "Hotspace", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/hotspace", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/hotspace/", + "description": "Hotspace" + }, + { + "id": "th_beauty", + "name": "Beauty", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/beauty", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/beauty/", + "description": "Beauty" + }, + { + "id": "th_the_real_wedding", + "name": "The Real Wedding", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/the-real-wedding", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/the-real-wedding/", + "description": "The Real Wedding" + }, + { + "id": "th_haircut", + "name": "Haircut", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/haircut", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/haircut/", + "description": "Haircut" + }, + { + "id": "th_space", + "name": "Space", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/space", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/space/", + "description": "Space" + }, + { + "id": "th_style", + "name": "Style", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/style", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/style/", + "description": "Style" + }, + { + "id": "th_hairnic", + "name": "Hairnic", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/hairnic", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/hairnic/", + "description": "Hairnic" + }, + { + "id": "th_sparlex", + "name": "Sparlex", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/sparlex", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/sparlex/", + "description": "Sparlex" + }, + { + "id": "th_lifestylemag", + "name": "Lifestylemag", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/LifeStyleMag", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/LifeStyleMag/", + "description": "Lifestylemag" + }, + { + "id": "th_lifestyle", + "name": "Lifestyle", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/lifestyle", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/lifestyle/", + "description": "Lifestyle Free Website Template" + }, + { + "id": "th_salone", + "name": "Salone", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Salone", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Salone/", + "description": "Salone" + }, + { + "id": "th_perfectcut", + "name": "Perfectcut", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/perfectcut", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/perfectcut/", + "description": "Perfectcut – Beauty Salon Website template" + }, + { + "id": "da_aroma_beauty_and_spa_responsive_bootstrap_template", + "name": "Aroma Beauty And Spa Responsive Bootstrap Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "aroma-beauty-and-spa-responsive-bootstrap-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/aroma-beauty-and-spa-responsive-bootstrap-template/", + "description": "Aroma Beauty And Spa Responsive Bootstrap Template" + }, + { + "id": "da_beauty_salon_bootstrap_html5_template", + "name": "Beauty Salon Bootstrap Html5 Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "beauty-salon-bootstrap-html5-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/beauty-salon-bootstrap-html5-template/", + "description": "Beauty Salon Bootstrap Html5 Template" + }, + { + "id": "da_lovely_wedding_bootstrap_free_website_template", + "name": "Lovely Wedding Bootstrap Free Website Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "lovely-wedding-bootstrap-free-website-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/lovely-wedding-bootstrap-free-website-template/", + "description": "Lovely Wedding Bootstrap Free Website Template" + }, + { + "id": "da_photo_style_two", + "name": "Photo Style Two", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "photo-style-two", + "preview_url": "https://dawidolko.github.io/Website-Templates/photo-style-two/", + "description": "Photo Style Two" + }, + { + "id": "da_the_best_wedding_free_bootstrap_template", + "name": "The Best Wedding Free Bootstrap Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "the-best-wedding-free-bootstrap-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/the-best-wedding-free-bootstrap-template/", + "description": "The Best Wedding Free Bootstrap Template" + }, + { + "id": "da_wedding_bells_free_responsive_html5_template", + "name": "Wedding Bells Free Responsive Html5 Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "wedding-bells-free-responsive-html5-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/wedding-bells-free-responsive-html5-template/", + "description": "Wedding Bells Free Responsive Html5 Template" + } + ] + }, + { + "id": "automotive", + "name": "Авто и транспорт", + "icon": "chart-bar", + "templates": [ + { + "id": "lz_car_zone", + "name": "Car Zone", + "source": "learning-zone", + "description": "Автосалон и продажа машин", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "car-zone-automobile-bootstrap-responsive-web-template", + "preview_url": "https://learning-zone.github.io/website-templates/car-zone-automobile-bootstrap-responsive-web-template/" + }, + { + "id": "lz_car_care", + "name": "Car Care", + "source": "learning-zone", + "description": "Автосервис и уход за авто", + "repo_url": "https://github.com/learning-zone/website-templates", + "sparse_path": "car-care-auto-mobile-html5-bootstrap-web-template", + "preview_url": "https://learning-zone.github.io/website-templates/car-care-auto-mobile-html5-bootstrap-web-template/" + }, + { + "id": "th_carbook", + "name": "Carbook", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/carbook", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/carbook/", + "description": "Carbook" + }, + { + "id": "th_dentcare", + "name": "Dentcare", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/dentcare", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/dentcare/", + "description": "Dentcare" + }, + { + "id": "th_carserv", + "name": "Carserv", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/carserv", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/carserv/", + "description": "Carserv" + }, + { + "id": "th_dentacare", + "name": "Dentacare", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/dentacare", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/dentacare/", + "description": "Dentacare" + }, + { + "id": "th_logistics", + "name": "Logistics", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/logistics", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/logistics/", + "description": "Logistics" + }, + { + "id": "th_medic_care", + "name": "Medic Care", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/medic-care", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/medic-care/", + "description": "Medic Care" + }, + { + "id": "th_caremed", + "name": "Caremed", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/caremed", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/caremed/", + "description": "Caremed" + }, + { + "id": "th_transportz", + "name": "Transportz", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/transportz", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/transportz/", + "description": "Transportz" + }, + { + "id": "th_legalcare", + "name": "Legalcare", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/legalcare", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/legalcare/", + "description": "Legalcare" + }, + { + "id": "th_carrent", + "name": "Carrent", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/carrent", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/carrent/", + "description": "Carrent" + }, + { + "id": "th_drcare", + "name": "Drcare", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/drcare", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/drcare/", + "description": "Drcare" + }, + { + "id": "th_carwash", + "name": "Carwash", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/carwash", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/carwash/", + "description": "Carwash" + }, + { + "id": "th_lawncare", + "name": "Lawncare", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/lawncare", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/lawncare/", + "description": "Lawncare" + }, + { + "id": "th_caraft", + "name": "Caraft", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/caraft", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/caraft/", + "description": "Caraft" + }, + { + "id": "th_taxi", + "name": "Taxi", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/taxi", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/taxi/", + "description": "Taxi" + }, + { + "id": "th_autorepair", + "name": "Autorepair", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/autorepair", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/autorepair/", + "description": "Autorepair" + }, + { + "id": "th_unicare", + "name": "Unicare", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/unicare", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/unicare/", + "description": "Unicare" + }, + { + "id": "th_autoroad", + "name": "Autoroad", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/autoroad", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/autoroad/", + "description": "Autoroad" + }, + { + "id": "th_carrentals", + "name": "Carrentals", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/carrentals", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/carrentals/", + "description": "Carrentals" + }, + { + "id": "th_cargo", + "name": "Cargo", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/cargo", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/cargo/", + "description": "Cargo" + }, + { + "id": "th_medcare", + "name": "Medcare", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/medcare", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/medcare/", + "description": "Medcare" + }, + { + "id": "th_cardboard", + "name": "Cardboard", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/cardboard", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/cardboard/", + "description": "Cardboard" + }, + { + "id": "th_careo", + "name": "Careo", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/careo", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/careo/", + "description": "Careo" + }, + { + "id": "th_card", + "name": "Card", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/card", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/card/", + "description": "Card" + }, + { + "id": "th_transportation", + "name": "Transportation", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/transportation", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/transportation/", + "description": "Transportation" + }, + { + "id": "th_guide", + "name": "Guide", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/guide", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/guide/", + "description": "Free Logistic Transport Website template" + }, + { + "id": "th_primecare", + "name": "Primecare", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/PrimeCare", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/PrimeCare/", + "description": "Primecare" + }, + { + "id": "th_carint", + "name": "Carint", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Carint", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Carint/", + "description": "Carint" + }, + { + "id": "th_freshcart", + "name": "Freshcart", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/freshcart", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/freshcart/", + "description": "Freshcart" + }, + { + "id": "da_car_care_auto_mobile_html5_bootstrap_web_template", + "name": "Car Care Auto Mobile Html5 Bootstrap Web Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "car-care-auto-mobile-html5-bootstrap-web-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/car-care-auto-mobile-html5-bootstrap-web-template/", + "description": "Car Care Auto Mobile Html5 Bootstrap Web Template" + }, + { + "id": "da_car_repair_html5_bootstrap_template", + "name": "Car Repair Html5 Bootstrap Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "car-repair-html5-bootstrap-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/car-repair-html5-bootstrap-template/", + "description": "Car Repair Html5 Bootstrap Template" + }, + { + "id": "da_car_zone_automobile_bootstrap_responsive_web_template", + "name": "Car Zone Automobile Bootstrap Responsive Web Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "car-zone-automobile-bootstrap-responsive-web-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/car-zone-automobile-bootstrap-responsive-web-template/", + "description": "Car Zone Automobile Bootstrap Responsive Web Template" + } + ] + }, + { + "id": "entertainment", + "name": "Развлечения и события", + "icon": "rocket", + "templates": [ + { + "id": "th_eventon_christmas", + "name": "Eventon Christmas", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/eventon-christmas", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/eventon-christmas/", + "description": "Eventon Christmas" + }, + { + "id": "th_one_music", + "name": "One Music", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/one-music", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/one-music/", + "description": "One Music" + }, + { + "id": "th_game_warrior", + "name": "Game Warrior", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/game-warrior", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/game-warrior/", + "description": "Game Warrior" + }, + { + "id": "th_eventcon", + "name": "Eventcon", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/eventcon", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/eventcon/", + "description": "Eventcon" + }, + { + "id": "th_endgame", + "name": "Endgame", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/endgame", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/endgame/", + "description": "Endgame" + }, + { + "id": "th_theevent", + "name": "Theevent", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/theevent", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/theevent/", + "description": "Theevent" + }, + { + "id": "th_musico", + "name": "Musico", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/musico", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/musico/", + "description": "Musico" + }, + { + "id": "th_sports", + "name": "Sports", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/sports", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/sports/", + "description": "Sports" + }, + { + "id": "th_solmusic", + "name": "Solmusic", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/solmusic", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/solmusic/", + "description": "Solmusic" + }, + { + "id": "th_sportz", + "name": "Sportz", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/sportz", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/sportz/", + "description": "Sportz" + }, + { + "id": "th_eventalk", + "name": "Eventalk", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/eventalk", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/eventalk/", + "description": "Eventalk" + }, + { + "id": "th_music1", + "name": "Music1", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/music1", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/music1/", + "description": "Music1" + }, + { + "id": "th_eventz", + "name": "Eventz", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/eventz", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/eventz/", + "description": "Eventz" + }, + { + "id": "th_eventre", + "name": "Eventre", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/eventre", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/eventre/", + "description": "Eventre" + }, + { + "id": "th_esportsteam", + "name": "Esportsteam", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/esportsteam", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/esportsteam/", + "description": "Esportsteam" + }, + { + "id": "th_avalon", + "name": "Avalon", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/avalon", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/avalon/", + "description": "It's a one page template specially crafted for event and conference websites. Get this free template from ThemeWaogn." + }, + { + "id": "th_sportsfit", + "name": "Sportsfit", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/sportsfit", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/sportsfit/", + "description": "Sportsfit" + }, + { + "id": "th_sports_coach", + "name": "Sports Coach", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/sports-coach", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/sports-coach/", + "description": "A Responsive Template for Online Coaching" + }, + { + "id": "th_trailer_time", + "name": "Trailer Time", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/trailer-time", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/trailer-time/", + "description": "This website was designed to allow viewers complete access to all movie and tv series trailers. It was created using React + MUI" + }, + { + "id": "th_waterland", + "name": "Waterland", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/WaterLand", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/WaterLand/", + "description": "Free Entertainment Website Template" + }, + { + "id": "da_delite_music_html5_bootstrap_responsive_web_template", + "name": "Delite Music Html5 Bootstrap Responsive Web Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "delite-music-html5-bootstrap-responsive-web-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/delite-music-html5-bootstrap-responsive-web-template/", + "description": "Delite Music Html5 Bootstrap Responsive Web Template" + } + ] + }, + { + "id": "nonprofit", + "name": "Благотворительность", + "icon": "heart", + "templates": [ + { + "id": "th_rango", + "name": "Rango", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/rango", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/rango/", + "description": "Rango" + }, + { + "id": "th_charityworks", + "name": "Charityworks", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/charityworks", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/charityworks/", + "description": "Charityworks" + }, + { + "id": "th_charity", + "name": "Charity", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Charity", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Charity/", + "description": "Charity" + }, + { + "id": "th_dingo", + "name": "Dingo", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/dingo", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/dingo/", + "description": "Dingo" + }, + { + "id": "th_bingo", + "name": "Bingo", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/bingo", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/bingo/", + "description": "Bingo" + }, + { + "id": "th_foundation", + "name": "Foundation", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/foundation", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/foundation/", + "description": "Foundation" + }, + { + "id": "th_hangover", + "name": "Hangover", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/hangover", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/hangover/", + "description": "Another cool HTML Responsive Template" + }, + { + "id": "th_sungo", + "name": "Sungo", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/sungo", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/sungo/", + "description": "Sungo – Ecology & Solar Energy HTML Template" + } + ] + }, + { + "id": "photography", + "name": "Фотография", + "icon": "palette", + "templates": [ + { + "id": "th_training_studio", + "name": "Training Studio", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/training-studio", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/training-studio/", + "description": "Training Studio" + }, + { + "id": "th_photosen", + "name": "Photosen", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/photosen", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/photosen/", + "description": "Photosen" + }, + { + "id": "th_photozone", + "name": "Photozone", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/photozone", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/photozone/", + "description": "Photozone" + }, + { + "id": "th_dronephotography", + "name": "Dronephotography", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/dronephotography", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/dronephotography/", + "description": "Dronephotography" + }, + { + "id": "th_mostudio", + "name": "Mostudio", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/mostudio", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/mostudio/", + "description": "Mostudio" + }, + { + "id": "th_photography", + "name": "Photography", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/photography", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/photography/", + "description": "Photography Template" + }, + { + "id": "th_photography_cl", + "name": "Photography Cl", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Photography-CL", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Photography-CL/", + "description": "Photography Cl" + }, + { + "id": "th_photogallery", + "name": "Photogallery", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/photogallery", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/photogallery/", + "description": "Photogallery" + }, + { + "id": "th_photoshoot", + "name": "Photoshoot", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/photoshoot", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/photoshoot/", + "description": "Photoshoot" + }, + { + "id": "th_photogenic", + "name": "Photogenic", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/photogenic", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/photogenic/", + "description": "Photogenic" + }, + { + "id": "th_artstudio", + "name": "Artstudio", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/artstudio", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/artstudio/", + "description": "Artstudio" + }, + { + "id": "th_studio", + "name": "Studio", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/studio", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/studio/", + "description": "Studio" + }, + { + "id": "th_photo", + "name": "Photo", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/photo", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/photo/", + "description": "Photo" + }, + { + "id": "th_cleanphotography", + "name": "Cleanphotography", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/cleanphotography", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/cleanphotography/", + "description": "Cleanphotography" + }, + { + "id": "th_role", + "name": "Role", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Role", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Role/", + "description": "Free Responsive Bootstrap 4 Photography Website Template" + }, + { + "id": "th_studiova", + "name": "Studiova", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Studiova", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Studiova/", + "description": "Studiova" + }, + { + "id": "th_istudio", + "name": "Istudio", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/iStudio", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/iStudio/", + "description": "Istudio" + }, + { + "id": "da_amaze_photography_bootstrap_html5_template", + "name": "Amaze Photography Bootstrap Html5 Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "amaze-photography-bootstrap-html5-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/amaze-photography-bootstrap-html5-template/", + "description": "Amaze Photography Bootstrap Html5 Template" + }, + { + "id": "da_css3_photo_two", + "name": "Css3 Photo Two", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "css3-photo-two", + "preview_url": "https://dawidolko.github.io/Website-Templates/css3-photo-two/", + "description": "Css3 Photo Two" + }, + { + "id": "da_iclick_photography_bootstrap_free_website_template", + "name": "Iclick Photography Bootstrap Free Website Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "iclick-photography-bootstrap-free-website-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/iclick-photography-bootstrap-free-website-template/", + "description": "Iclick Photography Bootstrap Free Website Template" + }, + { + "id": "da_photo_art", + "name": "Photo Art", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "photo-art", + "preview_url": "https://dawidolko.github.io/Website-Templates/photo-art/", + "description": "Photo Art" + }, + { + "id": "da_scenic_photo", + "name": "Scenic Photo", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "scenic-photo", + "preview_url": "https://dawidolko.github.io/Website-Templates/scenic-photo/", + "description": "Scenic Photo" + } + ] + }, + { + "id": "legal", + "name": "Юридические", + "icon": "briefcase", + "templates": [ + { + "id": "th_justice", + "name": "Justice", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Justice", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Justice/", + "description": "Justice" + }, + { + "id": "th_lawyer", + "name": "Lawyer", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/lawyer", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/lawyer/", + "description": "Lawyer" + }, + { + "id": "th_justlaw", + "name": "Justlaw", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/justlaw", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/justlaw/", + "description": "Justlaw" + }, + { + "id": "th_texas_lawyer_2", + "name": "Texas Lawyer 2", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Texas-Lawyer-2", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Texas-Lawyer-2/", + "description": "Texas Lawyer 2" + }, + { + "id": "th_thelawyer", + "name": "Thelawyer", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/thelawyer", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/thelawyer/", + "description": "Thelawyer" + }, + { + "id": "th_star_law", + "name": "Star Law", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/star-law", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/star-law/", + "description": "Star Law" + }, + { + "id": "th_lawride", + "name": "Lawride", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/lawride", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/lawride/", + "description": "Lawride" + }, + { + "id": "th_ariclaw", + "name": "Ariclaw", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/ariclaw", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/ariclaw/", + "description": "Ariclaw" + }, + { + "id": "th_lawful", + "name": "Lawful", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/lawful", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/lawful/", + "description": "Lawful" + } + ] + }, + { + "id": "other", + "name": "Разное", + "icon": "chart-bar", + "templates": [ + { + "id": "th_gulp_npm_mainfiles", + "name": "Gulp Npm Mainfiles", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/gulp-npm-mainfiles", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/gulp-npm-mainfiles/", + "description": "Push \"mainfiles\" from node_modules to a specific folder" + }, + { + "id": "th_rellax", + "name": "Rellax", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/rellax", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/rellax/", + "description": "jQuery Rellax Plugin - Parallax awesomeness" + }, + { + "id": "th_ava", + "name": "Ava", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/ava", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/ava/", + "description": "Ava" + }, + { + "id": "th_100_template_list", + "name": "100 Template List", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/100-template-list", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/100-template-list/", + "description": "100 Template List" + }, + { + "id": "th_electro", + "name": "Electro", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/electro", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/electro/", + "description": "Electro" + }, + { + "id": "th_mazer", + "name": "Mazer", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/mazer", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/mazer/", + "description": "Mazer" + }, + { + "id": "th_titan", + "name": "Titan", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/titan", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/titan/", + "description": "Multipurpose HTML5 Website Template" + }, + { + "id": "th_ogani", + "name": "Ogani", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/ogani", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/ogani/", + "description": "Ogani" + }, + { + "id": "th_garo_estate", + "name": "Garo Estate", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/garo-estate", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/garo-estate/", + "description": "Garo Estate" + }, + { + "id": "th_darkpan", + "name": "Darkpan", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/darkpan", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/darkpan/", + "description": "Darkpan" + }, + { + "id": "th_constra", + "name": "Constra", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/constra", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/constra/", + "description": "Constra" + }, + { + "id": "th_amado", + "name": "Amado", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/amado", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/amado/", + "description": "Amado" + }, + { + "id": "th_timer_html", + "name": "Timer Html", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/timer-html", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/timer-html/", + "description": "Timer Html" + }, + { + "id": "th_purple_react", + "name": "Purple React", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/purple-react", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/purple-react/", + "description": "Purple React" + }, + { + "id": "th_dashmin", + "name": "Dashmin", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/dashmin", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/dashmin/", + "description": "Dashmin" + }, + { + "id": "th_anime", + "name": "Anime", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/anime", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/anime/", + "description": "Anime" + }, + { + "id": "th_awesome1", + "name": "Awesome1", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/awesome1", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/awesome1/", + "description": "Awesome1" + }, + { + "id": "th_skydash", + "name": "Skydash", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/skydash", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/skydash/", + "description": "Skydash" + }, + { + "id": "th_dashtreme", + "name": "Dashtreme", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/dashtreme", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/dashtreme/", + "description": "Dashtreme" + }, + { + "id": "th_atlas", + "name": "Atlas", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/atlas", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/atlas/", + "description": "Atlas" + }, + { + "id": "th_asentus", + "name": "Asentus", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Asentus", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Asentus/", + "description": "Asentus" + }, + { + "id": "th_awesplash", + "name": "Awesplash", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/awesplash", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/awesplash/", + "description": "Awesplash" + }, + { + "id": "th_flusk", + "name": "Flusk", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Flusk", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Flusk/", + "description": "Flusk - A Responsive Multi-purpose Website Template with bootstrap 3" + }, + { + "id": "th_restoran", + "name": "Restoran", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/restoran", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/restoran/", + "description": "Restoran" + }, + { + "id": "th_notes_html_template", + "name": "Notes Html Template", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/notes-html-template", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/notes-html-template/", + "description": "Notes Html Template" + }, + { + "id": "th_arsha", + "name": "Arsha", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/arsha", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/arsha/", + "description": "Arsha" + }, + { + "id": "th_quixlab", + "name": "Quixlab", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/quixlab", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/quixlab/", + "description": "Quixlab" + }, + { + "id": "th_profile_bootstrap", + "name": "Profile Bootstrap", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/profile-bootstrap", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/profile-bootstrap/", + "description": "Profile Bootstrap" + }, + { + "id": "th_made", + "name": "Made", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/made", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/made/", + "description": "Made" + }, + { + "id": "th_royal", + "name": "Royal", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/royal", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/royal/", + "description": "Royal" + }, + { + "id": "th_meetme", + "name": "Meetme", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/meetme", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/meetme/", + "description": "Meetme" + }, + { + "id": "th_wish", + "name": "Wish", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/wish", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/wish/", + "description": "Wish" + }, + { + "id": "th_karl", + "name": "Karl", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/karl", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/karl/", + "description": "Karl" + }, + { + "id": "th_clark", + "name": "Clark", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/clark", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/clark/", + "description": "Clark" + }, + { + "id": "th_sept", + "name": "Sept", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/sept", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/sept/", + "description": "Sept" + }, + { + "id": "th_megakit", + "name": "Megakit", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/megakit", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/megakit/", + "description": "Megakit" + }, + { + "id": "th_white_pro", + "name": "White Pro", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/white_pro", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/white_pro/", + "description": "White Pro" + }, + { + "id": "th_devfolio", + "name": "Devfolio", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/devfolio", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/devfolio/", + "description": "Devfolio" + }, + { + "id": "th_cyborg", + "name": "Cyborg", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/cyborg", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/cyborg/", + "description": "Cyborg" + }, + { + "id": "th_cryptocoin", + "name": "Cryptocoin", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/CryptoCoin", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/CryptoCoin/", + "description": "Cryptocoin" + }, + { + "id": "th_proman", + "name": "Proman", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/proman", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/proman/", + "description": "Proman" + }, + { + "id": "th_jackson", + "name": "Jackson", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/jackson", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/jackson/", + "description": "Jackson" + }, + { + "id": "th_original", + "name": "Original", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/original", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/original/", + "description": "Original" + }, + { + "id": "th_elegant", + "name": "Elegant", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/elegant", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/elegant/", + "description": "Elegant" + }, + { + "id": "th_material_able", + "name": "Material Able", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/material_able", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/material_able/", + "description": "Material Able" + }, + { + "id": "th_tale", + "name": "Tale", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/tale", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/tale/", + "description": "Tale" + }, + { + "id": "th_finanza", + "name": "Finanza", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/finanza", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/finanza/", + "description": "Finanza" + }, + { + "id": "th_industrio", + "name": "Industrio", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Industrio", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Industrio/", + "description": "Industrio" + }, + { + "id": "th_furn", + "name": "Furn.", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/furn.", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/furn./", + "description": "Furn." + }, + { + "id": "th_elate", + "name": "Elate", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/elate", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/elate/", + "description": "Elate" + }, + { + "id": "th_boxer", + "name": "Boxer", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/boxer", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/boxer/", + "description": "Boxer" + }, + { + "id": "th_click", + "name": "Click", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/click", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/click/", + "description": "Responsive Site" + }, + { + "id": "th_famms", + "name": "Famms", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/famms", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/famms/", + "description": "Famms" + }, + { + "id": "th_arcade", + "name": "Arcade", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/arcade", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/arcade/", + "description": "Arcade" + }, + { + "id": "th_edu_meeting", + "name": "Edu Meeting", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/edu-meeting", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/edu-meeting/", + "description": "Edu Meeting" + }, + { + "id": "th_able_pro", + "name": "Able Pro", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/able_pro", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/able_pro/", + "description": "Able Pro" + }, + { + "id": "th_jobfinderportal", + "name": "Jobfinderportal", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/jobfinderportal", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/jobfinderportal/", + "description": "Jobfinderportal" + }, + { + "id": "th_pluto", + "name": "Pluto", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/pluto", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/pluto/", + "description": "Pluto" + }, + { + "id": "th_free_bundle_2022", + "name": "Free Bundle 2022", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/free-bundle-2022", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/free-bundle-2022/", + "description": "Free Bundle 2022" + }, + { + "id": "th_a_world", + "name": "A World", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/a-world", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/a-world/", + "description": "A World" + }, + { + "id": "th_gardener", + "name": "Gardener", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/gardener", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/gardener/", + "description": "Gardener" + }, + { + "id": "th_milky", + "name": "Milky", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/milky", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/milky/", + "description": "Milky" + }, + { + "id": "th_prixima", + "name": "Prixima", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/prixima", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/prixima/", + "description": "Prixima" + }, + { + "id": "th_flat_able", + "name": "Flat Able", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/flat_able", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/flat_able/", + "description": "Flat Able" + }, + { + "id": "th_solartec", + "name": "Solartec", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/solartec", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/solartec/", + "description": "Solartec" + }, + { + "id": "th_fruitkha", + "name": "Fruitkha", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/fruitkha", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/fruitkha/", + "description": "Fruitkha" + }, + { + "id": "th_karma", + "name": "Karma", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/karma", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/karma/", + "description": "Karma" + }, + { + "id": "th_eflyer", + "name": "Eflyer", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/eflyer", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/eflyer/", + "description": "Eflyer" + }, + { + "id": "th_focus_2", + "name": "Focus 2", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/focus-2", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/focus-2/", + "description": "Focus 2" + }, + { + "id": "th_100_template_bundle", + "name": "100 Template Bundle", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/100-template-bundle", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/100-template-bundle/", + "description": "100 Template Bundle" + }, + { + "id": "th_ashion", + "name": "Ashion", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/ashion", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/ashion/", + "description": "Ashion" + }, + { + "id": "th_baker", + "name": "Baker", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/baker", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/baker/", + "description": "Baker" + }, + { + "id": "th_drivin", + "name": "Drivin", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/drivin", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/drivin/", + "description": "Drivin" + }, + { + "id": "th_keto", + "name": "Keto", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/keto", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/keto/", + "description": "Keto" + }, + { + "id": "th_videograph", + "name": "Videograph", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/videograph", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/videograph/", + "description": "Videograph" + }, + { + "id": "th_kidkinder", + "name": "Kidkinder", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/kidkinder", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/kidkinder/", + "description": "Kidkinder" + }, + { + "id": "th_patrix", + "name": "Patrix", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/patrix", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/patrix/", + "description": "Patrix" + }, + { + "id": "th_majestic_2", + "name": "Majestic 2", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/majestic-2", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/majestic-2/", + "description": "Majestic 2" + }, + { + "id": "th_novena", + "name": "Novena", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/novena", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/novena/", + "description": "Novena" + }, + { + "id": "th_chain", + "name": "Chain", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/chain", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/chain/", + "description": "Chain" + }, + { + "id": "th_mega_able", + "name": "Mega Able", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/mega_able", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/mega_able/", + "description": "Mega Able" + }, + { + "id": "th_mark", + "name": "Mark", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/mark", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/mark/", + "description": "Mark" + }, + { + "id": "th_stride", + "name": "Stride", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/stride", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/stride/", + "description": "Stride" + }, + { + "id": "th_makan", + "name": "Makan", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/makan", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/makan/", + "description": "Makan" + }, + { + "id": "th_sona", + "name": "Sona", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/sona", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/sona/", + "description": "Sona" + }, + { + "id": "th_kider", + "name": "Kider", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/kider", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/kider/", + "description": "Kider" + }, + { + "id": "th_dgcom", + "name": "Dgcom", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/dgcom", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/dgcom/", + "description": "Dgcom" + }, + { + "id": "th_chariteam", + "name": "Chariteam", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/chariteam", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/chariteam/", + "description": "Chariteam" + }, + { + "id": "th_insure", + "name": "Insure", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/insure", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/insure/", + "description": "Insure" + }, + { + "id": "th_connect_plus", + "name": "Connect Plus", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/connect-plus", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/connect-plus/", + "description": "Connect Plus" + }, + { + "id": "th_satner", + "name": "Satner", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/satner", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/satner/", + "description": "Satner" + }, + { + "id": "th_boldo", + "name": "Boldo", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/boldo", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/boldo/", + "description": "Boldo" + }, + { + "id": "th_footwear", + "name": "Footwear", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/footwear", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/footwear/", + "description": "Footwear" + }, + { + "id": "th_bizconsult", + "name": "Bizconsult", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/bizconsult", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/bizconsult/", + "description": "Bizconsult" + }, + { + "id": "th_unfold", + "name": "Unfold", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/unfold", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/unfold/", + "description": "Unfold" + }, + { + "id": "th_spica", + "name": "Spica", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/spica", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/spica/", + "description": "Spica" + }, + { + "id": "th_zoofari", + "name": "Zoofari", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/zoofari", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/zoofari/", + "description": "Zoofari" + }, + { + "id": "th_arkitektur", + "name": "Arkitektur", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/arkitektur", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/arkitektur/", + "description": "Arkitektur" + }, + { + "id": "th_soccer", + "name": "Soccer", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/soccer", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/soccer/", + "description": "Soccer" + }, + { + "id": "th_pavo", + "name": "Pavo", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/pavo", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/pavo/", + "description": "Pavo" + }, + { + "id": "th_montana", + "name": "Montana", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/montana", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/montana/", + "description": "Montana" + }, + { + "id": "th_apex", + "name": "Apex", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/apex", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/apex/", + "description": "Apex" + }, + { + "id": "th_gohub", + "name": "Gohub", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/gohub", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/gohub/", + "description": "Gohub" + }, + { + "id": "th_tinydash", + "name": "Tinydash", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/tinydash", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/tinydash/", + "description": "Tinydash" + }, + { + "id": "th_watch_2", + "name": "Watch 2", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/watch-2", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/watch-2/", + "description": "Watch 2" + }, + { + "id": "th_mirko", + "name": "Mirko", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/mirko", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/mirko/", + "description": "Mirko" + }, + { + "id": "th_securex", + "name": "Securex", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/securex", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/securex/", + "description": "Securex" + }, + { + "id": "th_pharma", + "name": "Pharma", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/pharma", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/pharma/", + "description": "Pharma" + }, + { + "id": "th_sogo", + "name": "Sogo", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/sogo", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/sogo/", + "description": "Sogo" + }, + { + "id": "th_crypto", + "name": "Crypto", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/crypto", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/crypto/", + "description": "Crypto" + }, + { + "id": "th_courier", + "name": "Courier", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/courier", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/courier/", + "description": "Courier" + }, + { + "id": "th_clyde", + "name": "Clyde", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/clyde", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/clyde/", + "description": "Clyde" + }, + { + "id": "th_jadoo", + "name": "Jadoo", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/jadoo", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/jadoo/", + "description": "Jadoo" + }, + { + "id": "th_get_ready", + "name": "Get Ready", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/get-ready", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/get-ready/", + "description": "Get Ready" + }, + { + "id": "th_nomad_force", + "name": "Nomad Force", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/nomad-force", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/nomad-force/", + "description": "Nomad Force" + }, + { + "id": "th_djoz", + "name": "Djoz", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Djoz", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Djoz/", + "description": "DJoz-Free Template " + }, + { + "id": "th_cakezone", + "name": "Cakezone", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/cakezone", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/cakezone/", + "description": "Cakezone" + }, + { + "id": "th_logistica", + "name": "Logistica", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/logistica", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/logistica/", + "description": "Logistica" + }, + { + "id": "th_aranoz", + "name": "Aranoz", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/aranoz", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/aranoz/", + "description": "Aranoz" + }, + { + "id": "th_aircon", + "name": "Aircon", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/aircon", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/aircon/", + "description": "Aircon" + }, + { + "id": "th_welfare", + "name": "Welfare", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/welfare", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/welfare/", + "description": "Welfare" + }, + { + "id": "th_orthoc", + "name": "Orthoc", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/orthoc", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/orthoc/", + "description": "Orthoc" + }, + { + "id": "th_frutika", + "name": "Frutika", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/frutika", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/frutika/", + "description": "Frutika" + }, + { + "id": "th_eiser", + "name": "Eiser", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/eiser", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/eiser/", + "description": "Eiser" + }, + { + "id": "th_faster", + "name": "Faster", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/faster", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/faster/", + "description": "Faster" + }, + { + "id": "th_soffer", + "name": "Soffer", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/soffer", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/soffer/", + "description": "Soffer" + }, + { + "id": "th_voler", + "name": "Voler", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/voler", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/voler/", + "description": "Voler" + }, + { + "id": "th_klinik", + "name": "Klinik", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/klinik", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/klinik/", + "description": "Klinik" + }, + { + "id": "th_live_doc", + "name": "Live Doc", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/live-doc", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/live-doc/", + "description": "Live Doc" + }, + { + "id": "th_logisticexpress", + "name": "Logisticexpress", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/logisticexpress", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/logisticexpress/", + "description": "Logisticexpress" + }, + { + "id": "th_builerz", + "name": "Builerz", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/builerz", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/builerz/", + "description": "Builerz" + }, + { + "id": "th_ace", + "name": "Ace", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/ace", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/ace/", + "description": "Ace" + }, + { + "id": "th_kards", + "name": "Kards", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/kards", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/kards/", + "description": "Kards" + }, + { + "id": "th_quantum_able", + "name": "Quantum Able", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/quantum_able", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/quantum_able/", + "description": "Quantum Able" + }, + { + "id": "th_guruable", + "name": "Guruable", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/guruable", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/guruable/", + "description": "Guruable" + }, + { + "id": "th_elen", + "name": "Elen", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/elen", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/elen/", + "description": "Elen" + }, + { + "id": "th_violet", + "name": "Violet", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/violet", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/violet/", + "description": "Violet" + }, + { + "id": "th_webuni", + "name": "Webuni", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/webuni", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/webuni/", + "description": "Webuni" + }, + { + "id": "th_kiddos", + "name": "Kiddos", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/kiddos", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/kiddos/", + "description": "Kiddos" + }, + { + "id": "th_jobentry", + "name": "Jobentry", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/jobentry", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/jobentry/", + "description": "Jobentry" + }, + { + "id": "th_webuild", + "name": "Webuild", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/webuild", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/webuild/", + "description": "Webuild" + }, + { + "id": "th_direngine", + "name": "Direngine", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/direngine", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/direngine/", + "description": "Direngine" + }, + { + "id": "th_painter", + "name": "Painter", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/painter", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/painter/", + "description": "Painter" + }, + { + "id": "th_onix", + "name": "Onix", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/onix", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/onix/", + "description": "Onix" + }, + { + "id": "th_deluxe", + "name": "Deluxe", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/deluxe", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/deluxe/", + "description": "Deluxe" + }, + { + "id": "th_aroma", + "name": "Aroma", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/aroma", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/aroma/", + "description": "Aroma" + }, + { + "id": "th_landmark", + "name": "Landmark", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/landmark", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/landmark/", + "description": "Landmark" + }, + { + "id": "th_zacson", + "name": "Zacson", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/zacson", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/zacson/", + "description": "Zacson" + }, + { + "id": "th_academics", + "name": "Academics", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/academics", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/academics/", + "description": "Academics" + }, + { + "id": "th_sterial", + "name": "Sterial", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/sterial", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/sterial/", + "description": "Sterial" + }, + { + "id": "th_eduwell", + "name": "Eduwell", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/eduwell", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/eduwell/", + "description": "Eduwell" + }, + { + "id": "th_pacific", + "name": "Pacific", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/pacific", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/pacific/", + "description": "Pacific" + }, + { + "id": "th_volt", + "name": "Volt", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/volt", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/volt/", + "description": "Volt" + }, + { + "id": "th_elma", + "name": "Elma", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/elma", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/elma/", + "description": "Elma" + }, + { + "id": "th_astro_motion", + "name": "Astro Motion", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/astro-motion", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/astro-motion/", + "description": "Astro Motion" + }, + { + "id": "th_knight", + "name": "Knight", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/knight", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/knight/", + "description": "Knight" + }, + { + "id": "th_tasteit", + "name": "Tasteit", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/tasteit", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/tasteit/", + "description": "Tasteit" + }, + { + "id": "th_enlight", + "name": "Enlight", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/enlight", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/enlight/", + "description": "Enlight" + }, + { + "id": "th_essence", + "name": "Essence", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/essence", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/essence/", + "description": "Essence" + }, + { + "id": "th_thegrill", + "name": "Thegrill", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/thegrill", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/thegrill/", + "description": "Thegrill" + }, + { + "id": "th_woody", + "name": "Woody", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/woody", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/woody/", + "description": "Woody" + }, + { + "id": "th_tropika", + "name": "Tropika", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/tropika", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/tropika/", + "description": "Tropika" + }, + { + "id": "th_balay", + "name": "Balay", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/balay", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/balay/", + "description": "Balay" + }, + { + "id": "th_kiddy", + "name": "Kiddy", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/kiddy", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/kiddy/", + "description": "Kiddy" + }, + { + "id": "th_traffico", + "name": "Traffico", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/traffico", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/traffico/", + "description": "Traffico" + }, + { + "id": "th_cake", + "name": "Cake", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/cake", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/cake/", + "description": "Cake" + }, + { + "id": "th_delicious", + "name": "Delicious", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/delicious", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/delicious/", + "description": "Delicious" + }, + { + "id": "th_sprout", + "name": "Sprout", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Sprout", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Sprout/", + "description": "Comimg Soon Template" + }, + { + "id": "th_job_listing", + "name": "Job Listing", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/job-listing", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/job-listing/", + "description": "Job Listing" + }, + { + "id": "th_fox", + "name": "Fox", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/fox", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/fox/", + "description": "Fox" + }, + { + "id": "th_phozogy", + "name": "Phozogy", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/phozogy", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/phozogy/", + "description": "Phozogy" + }, + { + "id": "th_noah", + "name": "Noah", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/noah", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/noah/", + "description": "Noah" + }, + { + "id": "th_guruable2", + "name": "Guruable2", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/guruable2", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/guruable2/", + "description": "Guruable2" + }, + { + "id": "th_accounting", + "name": "Accounting", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/accounting", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/accounting/", + "description": "Accounting" + }, + { + "id": "th_ensurance", + "name": "Ensurance", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/ensurance", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/ensurance/", + "description": "Ensurance" + }, + { + "id": "th_alazea", + "name": "Alazea", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/alazea", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/alazea/", + "description": "Alazea" + }, + { + "id": "th_sonar", + "name": "Sonar", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/sonar", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/sonar/", + "description": "Sonar" + }, + { + "id": "th_rezume", + "name": "Rezume", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/rezume", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/rezume/", + "description": "Rezume" + }, + { + "id": "th_fashi", + "name": "Fashi", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/fashi", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/fashi/", + "description": "Fashi" + }, + { + "id": "th_voyage_2", + "name": "Voyage 2", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/voyage-2", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/voyage-2/", + "description": "Voyage 2" + }, + { + "id": "th_docmed", + "name": "Docmed", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/docmed", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/docmed/", + "description": "Docmed" + }, + { + "id": "th_charifit", + "name": "Charifit", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/charifit", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/charifit/", + "description": "Charifit" + }, + { + "id": "th_loan", + "name": "Loan", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/loan", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/loan/", + "description": "Loan" + }, + { + "id": "th_digimedia", + "name": "Digimedia", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/digimedia", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/digimedia/", + "description": "Digimedia" + }, + { + "id": "th_studylab", + "name": "Studylab", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/studylab", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/studylab/", + "description": "Studylab" + }, + { + "id": "th_seapalace", + "name": "Seapalace", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/seapalace", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/seapalace/", + "description": "Seapalace" + }, + { + "id": "th_corso", + "name": "Corso", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/corso", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/corso/", + "description": "Corso" + }, + { + "id": "th_tasty", + "name": "Tasty", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/tasty", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/tasty/", + "description": "Tasty" + }, + { + "id": "th_timezone", + "name": "Timezone", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/timezone", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/timezone/", + "description": "Timezone" + }, + { + "id": "th_collab", + "name": "Collab", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/collab", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/collab/", + "description": "Collab" + }, + { + "id": "th_megakit_2", + "name": "Megakit 2", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/megakit-2", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/megakit-2/", + "description": "Megakit 2" + }, + { + "id": "th_eclipse", + "name": "Eclipse", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/eclipse", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/eclipse/", + "description": "Eclipse" + }, + { + "id": "th_epnweb", + "name": "Epnweb", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/epnweb", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/epnweb/", + "description": "Epnweb" + }, + { + "id": "th_brber", + "name": "Brber", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/brber", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/brber/", + "description": "Brber" + }, + { + "id": "th_tivo", + "name": "Tivo", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/tivo", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/tivo/", + "description": "Tivo" + }, + { + "id": "th_jobboard", + "name": "Jobboard", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/jobboard", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/jobboard/", + "description": "Jobboard" + }, + { + "id": "th_servion", + "name": "Servion", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/servion", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/servion/", + "description": "Servion" + }, + { + "id": "th_jony", + "name": "Jony", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/jony", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/jony/", + "description": "Jony" + }, + { + "id": "th_theplaza", + "name": "Theplaza", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/theplaza", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/theplaza/", + "description": "Theplaza" + }, + { + "id": "th_manup", + "name": "Manup", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/manup", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/manup/", + "description": "Manup" + }, + { + "id": "th_material_kit_2", + "name": "Material Kit 2", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/material-kit-2", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/material-kit-2/", + "description": "Material Kit 2" + }, + { + "id": "th_edustage", + "name": "Edustage", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/edustage", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/edustage/", + "description": "Edustage" + }, + { + "id": "th_believe", + "name": "Believe", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/believe", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/believe/", + "description": "Believe" + }, + { + "id": "th_desi_2", + "name": "Desi 2", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/desi-2", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/desi-2/", + "description": "Desi 2" + }, + { + "id": "th_medic", + "name": "Medic", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/medic", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/medic/", + "description": "Medic" + }, + { + "id": "th_gemdev", + "name": "Gemdev", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/gemdev", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/gemdev/", + "description": "Gemdev" + }, + { + "id": "th_vizew", + "name": "Vizew", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/vizew", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/vizew/", + "description": "Vizew" + }, + { + "id": "th_little_squirrel", + "name": "Little Squirrel", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/little-squirrel", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/little-squirrel/", + "description": "Little Squirrel" + }, + { + "id": "th_etrain", + "name": "Etrain", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/etrain", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/etrain/", + "description": "Etrain" + }, + { + "id": "th_marshmallow", + "name": "Marshmallow", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/marshmallow", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/marshmallow/", + "description": "Marshmallow" + }, + { + "id": "th_laundry", + "name": "Laundry", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/laundry", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/laundry/", + "description": "Laundry" + }, + { + "id": "th_ezuca", + "name": "Ezuca", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/ezuca", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/ezuca/", + "description": "Ezuca" + }, + { + "id": "th_tulen", + "name": "Tulen", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/tulen", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/tulen/", + "description": "Tulen" + }, + { + "id": "th_spify", + "name": "Spify", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/spify", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/spify/", + "description": "Spify" + }, + { + "id": "th_ronaldo", + "name": "Ronaldo", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/ronaldo", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/ronaldo/", + "description": "Ronaldo" + }, + { + "id": "th_fundraiser", + "name": "Fundraiser", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/fundraiser", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/fundraiser/", + "description": "Fundraiser" + }, + { + "id": "th_sevi", + "name": "Sevi", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/sevi", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/sevi/", + "description": "Sevi" + }, + { + "id": "th_safia", + "name": "Safia", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Safia", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Safia/", + "description": "Safia" + }, + { + "id": "th_educenter", + "name": "Educenter", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/educenter", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/educenter/", + "description": "Educenter" + }, + { + "id": "th_rhea", + "name": "Rhea", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Rhea", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Rhea/", + "description": "Rhea" + }, + { + "id": "th_equipo", + "name": "Equipo", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/equipo", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/equipo/", + "description": "Equipo" + }, + { + "id": "th_dtox", + "name": "Dtox", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/dtox", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/dtox/", + "description": "Dtox" + }, + { + "id": "th_nitro2", + "name": "Nitro2", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/nitro2", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/nitro2/", + "description": "Nitro2" + }, + { + "id": "th_klean", + "name": "Klean", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/klean", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/klean/", + "description": "Live link" + }, + { + "id": "th_villa", + "name": "Villa", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/villa", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/villa/", + "description": "Villa" + }, + { + "id": "th_harbor_lights", + "name": "Harbor Lights", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/harbor-lights", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/harbor-lights/", + "description": "Harbor Lights" + }, + { + "id": "th_beko", + "name": "Beko", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/beko", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/beko/", + "description": "Beko" + }, + { + "id": "th_melan", + "name": "Melan", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/melan", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/melan/", + "description": "Melan" + }, + { + "id": "th_greenhost", + "name": "Greenhost", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/greenhost", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/greenhost/", + "description": "Greenhost" + }, + { + "id": "th_edu_prix", + "name": "Edu Prix", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/edu-prix", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/edu-prix/", + "description": "Edu Prix" + }, + { + "id": "th_winkel", + "name": "Winkel", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/winkel", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/winkel/", + "description": "Winkel" + }, + { + "id": "th_burgerking", + "name": "Burgerking", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/burgerking", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/burgerking/", + "description": "Burgerking" + }, + { + "id": "th_next_page", + "name": "Next Page", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/next-page", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/next-page/", + "description": "Next Page" + }, + { + "id": "th_marian", + "name": "Marian", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/marian", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/marian/", + "description": "Marian" + }, + { + "id": "th_azzara", + "name": "Azzara", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/azzara", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/azzara/", + "description": "Azzara" + }, + { + "id": "th_nubis", + "name": "Nubis", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/nubis", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/nubis/", + "description": "Nubis" + }, + { + "id": "th_petsitting", + "name": "Petsitting", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/petsitting", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/petsitting/", + "description": "Petsitting" + }, + { + "id": "th_bitcypo", + "name": "Bitcypo", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/bitcypo", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/bitcypo/", + "description": "Bitcypo" + }, + { + "id": "th_feliciano", + "name": "Feliciano", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/feliciano", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/feliciano/", + "description": "Feliciano" + }, + { + "id": "th_givehope", + "name": "Givehope", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/givehope", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/givehope/", + "description": "Givehope" + }, + { + "id": "th_unicat", + "name": "Unicat", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/unicat", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/unicat/", + "description": "Unicat" + }, + { + "id": "th_classimax", + "name": "Classimax", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/classimax", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/classimax/", + "description": "Bootstrap 4 classified ad website template" + }, + { + "id": "th_palatin", + "name": "Palatin", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/palatin", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/palatin/", + "description": "Palatin" + }, + { + "id": "th_aler", + "name": "Aler", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/aler", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/aler/", + "description": "Aler" + }, + { + "id": "th_motto", + "name": "Motto", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/motto", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/motto/", + "description": "Motto" + }, + { + "id": "th_wilcon", + "name": "Wilcon", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/wilcon", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/wilcon/", + "description": "Wilcon" + }, + { + "id": "th_browny", + "name": "Browny", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/browny", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/browny/", + "description": "Browny" + }, + { + "id": "th_raising", + "name": "Raising", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/raising", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/raising/", + "description": "Raising" + }, + { + "id": "th_art_museum", + "name": "Art Museum", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/art-museum", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/art-museum/", + "description": "Art Museum" + }, + { + "id": "th_elegence", + "name": "Elegence", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/elegence", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/elegence/", + "description": "Elegence" + }, + { + "id": "th_medico", + "name": "Medico", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/medico", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/medico/", + "description": "Medico" + }, + { + "id": "th_pharmative", + "name": "Pharmative", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/pharmative", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/pharmative/", + "description": "Pharmative" + }, + { + "id": "th_adward", + "name": "Adward", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/adward", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/adward/", + "description": "Adward" + }, + { + "id": "th_homeland", + "name": "Homeland", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/homeland", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/homeland/", + "description": "Homeland" + }, + { + "id": "th_hvac", + "name": "Hvac", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/hvac", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/hvac/", + "description": "Hvac" + }, + { + "id": "th_known", + "name": "Known", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/known", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/known/", + "description": "Known" + }, + { + "id": "th_elearn", + "name": "Elearn", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/elearn", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/elearn/", + "description": "Elearn" + }, + { + "id": "th_medlife", + "name": "Medlife", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/medlife", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/medlife/", + "description": "Medlife" + }, + { + "id": "th_alimie", + "name": "Alimie", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/alimie", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/alimie/", + "description": "Alimie" + }, + { + "id": "th_burger", + "name": "Burger", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/burger", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/burger/", + "description": "Burger" + }, + { + "id": "th_zinc", + "name": "Zinc", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/zinc", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/zinc/", + "description": "Zinc" + }, + { + "id": "th_executive", + "name": "Executive", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/executive", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/executive/", + "description": "Executive" + }, + { + "id": "th_revo", + "name": "Revo", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/revo", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/revo/", + "description": "Revo" + }, + { + "id": "th_neumorphism_ui", + "name": "Neumorphism Ui", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/neumorphism-ui", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/neumorphism-ui/", + "description": "Neumorphism Ui" + }, + { + "id": "th_lifetrakr", + "name": "Lifetrakr", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/lifetrakr", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/lifetrakr/", + "description": "Lifetrakr" + }, + { + "id": "th_webhost", + "name": "Webhost", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/webhost", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/webhost/", + "description": "Webhost" + }, + { + "id": "th_ecoverde", + "name": "Ecoverde", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/ecoverde", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/ecoverde/", + "description": "Ecoverde" + }, + { + "id": "th_mona", + "name": "Mona", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/mona", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/mona/", + "description": "Mona" + }, + { + "id": "th_sensive", + "name": "Sensive", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/sensive", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/sensive/", + "description": "Sensive" + }, + { + "id": "th_vacayhome", + "name": "Vacayhome", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/vacayhome", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/vacayhome/", + "description": "Vacayhome" + }, + { + "id": "th_depot", + "name": "Depot", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/depot", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/depot/", + "description": "Depot" + }, + { + "id": "th_rage", + "name": "Rage", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/rage", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/rage/", + "description": "Rage" + }, + { + "id": "th_theriver", + "name": "Theriver", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/theriver", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/theriver/", + "description": "Theriver" + }, + { + "id": "th_devfolio2", + "name": "Devfolio2", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/devfolio2", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/devfolio2/", + "description": "Devfolio2" + }, + { + "id": "th_yummy", + "name": "Yummy", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/yummy", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/yummy/", + "description": "Yummy" + }, + { + "id": "th_genius", + "name": "Genius", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/genius", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/genius/", + "description": "Genius" + }, + { + "id": "th_landscaper", + "name": "Landscaper", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/landscaper", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/landscaper/", + "description": "Landscaper" + }, + { + "id": "th_evans", + "name": "Evans", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/evans", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/evans/", + "description": "Evans" + }, + { + "id": "th_zoufarm", + "name": "Zoufarm", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/zouFarm", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/zouFarm/", + "description": "Zoufarm" + }, + { + "id": "th_places", + "name": "Places", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/places", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/places/", + "description": "Places" + }, + { + "id": "th_eatery_new", + "name": "Eatery New", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/eatery-new", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/eatery-new/", + "description": "Eatery New" + }, + { + "id": "th_edumark", + "name": "Edumark", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/edumark", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/edumark/", + "description": "Edumark" + }, + { + "id": "th_specer", + "name": "Specer", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/specer", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/specer/", + "description": "Specer" + }, + { + "id": "th_agenda", + "name": "Agenda", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/agenda", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/agenda/", + "description": "Agenda" + }, + { + "id": "th_square", + "name": "Square", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/square", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/square/", + "description": "Square" + }, + { + "id": "th_logic", + "name": "Logic", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/logic", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/logic/", + "description": "Logic" + }, + { + "id": "th_roberto", + "name": "Roberto", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/roberto", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/roberto/", + "description": "Roberto" + }, + { + "id": "th_world", + "name": "World", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/world", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/world/", + "description": "World" + }, + { + "id": "th_royal2", + "name": "Royal2", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/royal2", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/royal2/", + "description": "Royal2" + }, + { + "id": "th_minimus", + "name": "Minimus", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/minimus", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/minimus/", + "description": "Minimus" + }, + { + "id": "th_jober_desk", + "name": "Jober Desk", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/jober-desk", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/jober-desk/", + "description": "Jober Desk" + }, + { + "id": "th_caviar", + "name": "Caviar", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/caviar", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/caviar/", + "description": "Caviar" + }, + { + "id": "th_stated", + "name": "Stated", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/stated", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/stated/", + "description": "Stated" + }, + { + "id": "th_podca", + "name": "Podca", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/podca", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/podca/", + "description": "Podca" + }, + { + "id": "th_pretty", + "name": "Pretty", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/pretty", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/pretty/", + "description": "Pretty" + }, + { + "id": "th_cryptos", + "name": "Cryptos", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/cryptos", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/cryptos/", + "description": "Cryptos" + }, + { + "id": "th_roofing", + "name": "Roofing", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/roofing", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/roofing/", + "description": "Roofing" + }, + { + "id": "th_eduland", + "name": "Eduland", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/eduland", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/eduland/", + "description": "Eduland" + }, + { + "id": "th_nupital", + "name": "Nupital", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Nupital", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Nupital/", + "description": "Nupital" + }, + { + "id": "th_marvel", + "name": "Marvel", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/marvel", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/marvel/", + "description": "Marvel" + }, + { + "id": "th_ronin", + "name": "Ronin", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/ronin", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/ronin/", + "description": "Ronin" + }, + { + "id": "th_dimension", + "name": "Dimension", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/dimension", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/dimension/", + "description": "Dimension" + }, + { + "id": "th_sunshine", + "name": "Sunshine", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/sunshine", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/sunshine/", + "description": "Sunshine" + }, + { + "id": "th_infinity", + "name": "Infinity", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/infinity", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/infinity/", + "description": "Infinity" + }, + { + "id": "th_findstate", + "name": "Findstate", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/findstate", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/findstate/", + "description": "Findstate" + }, + { + "id": "th_humanresources", + "name": "Humanresources", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/humanresources", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/humanresources/", + "description": "Humanresources" + }, + { + "id": "th_mosaic", + "name": "Mosaic", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/mosaic", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/mosaic/", + "description": "Mosaic" + }, + { + "id": "th_fanadesh", + "name": "Fanadesh", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/fanadesh", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/fanadesh/", + "description": "Fanadesh" + }, + { + "id": "th_boto", + "name": "Boto", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/boto", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/boto/", + "description": "Boto" + }, + { + "id": "th_purple_buzz", + "name": "Purple Buzz", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/purple-buzz", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/purple-buzz/", + "description": "Purple Buzz" + }, + { + "id": "th_covido", + "name": "Covido", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/covido", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/covido/", + "description": "Covido" + }, + { + "id": "th_waterboat", + "name": "Waterboat", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/waterboat", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/waterboat/", + "description": "Waterboat" + }, + { + "id": "th_mdb", + "name": "Mdb", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/mdb", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/mdb/", + "description": "Mdb" + }, + { + "id": "th_elit", + "name": "Elit", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/elit", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/elit/", + "description": "Elit" + }, + { + "id": "th_credit", + "name": "Credit", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/credit", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/credit/", + "description": "Credit" + }, + { + "id": "th_logis", + "name": "Logis", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/logis", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/logis/", + "description": "Logis" + }, + { + "id": "th_wiser", + "name": "Wiser", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/wiser", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/wiser/", + "description": "Wiser" + }, + { + "id": "th_author_colorlib", + "name": "Author Colorlib", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/author-colorlib", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/author-colorlib/", + "description": "Author Colorlib" + }, + { + "id": "th_dente", + "name": "Dente", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/dente", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/dente/", + "description": "Dente" + }, + { + "id": "th_staging", + "name": "Staging", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/staging", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/staging/", + "description": "Staging" + }, + { + "id": "th_rettro", + "name": "Rettro", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/rettro", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/rettro/", + "description": "Rettro" + }, + { + "id": "th_jobest", + "name": "Jobest", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/jobest", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/jobest/", + "description": "Jobest" + }, + { + "id": "th_yavin", + "name": "Yavin", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/yavin", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/yavin/", + "description": "Yavin" + }, + { + "id": "th_bino", + "name": "Bino", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/bino", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/bino/", + "description": "Bino" + }, + { + "id": "th_logistico", + "name": "Logistico", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/logistico", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/logistico/", + "description": "Logistico" + }, + { + "id": "th_mediplus", + "name": "Mediplus", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/mediplus", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/mediplus/", + "description": "Mediplus" + }, + { + "id": "th_jonson", + "name": "Jonson", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/jonson", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/jonson/", + "description": "Jonson" + }, + { + "id": "th_majestic", + "name": "Majestic", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/majestic", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/majestic/", + "description": "Majestic" + }, + { + "id": "th_hesed", + "name": "Hesed", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/hesed", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/hesed/", + "description": "Hesed" + }, + { + "id": "th_louie", + "name": "Louie", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/louie", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/louie/", + "description": "Louie" + }, + { + "id": "th_bizpro1", + "name": "Bizpro1", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/bizpro1", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/bizpro1/", + "description": "Bizpro1" + }, + { + "id": "th_miri_ui", + "name": "Miri Ui", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/miri-ui", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/miri-ui/", + "description": "Miri Ui" + }, + { + "id": "th_arclabs", + "name": "Arclabs", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/arclabs", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/arclabs/", + "description": "Arclabs" + }, + { + "id": "th_bocor", + "name": "Bocor", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/bocor", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/bocor/", + "description": "Bocor" + }, + { + "id": "th_royalestate", + "name": "Royalestate", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/royalestate", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/royalestate/", + "description": "Royalestate" + }, + { + "id": "th_stayhome", + "name": "Stayhome", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/stayhome", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/stayhome/", + "description": "Stayhome" + }, + { + "id": "th_conference_cl", + "name": "Conference Cl", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/conference-CL", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/conference-CL/", + "description": "Conference Cl" + }, + { + "id": "th_civic", + "name": "Civic", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/civic", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/civic/", + "description": "Civic" + }, + { + "id": "th_listing", + "name": "Listing", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/listing", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/listing/", + "description": "Listing" + }, + { + "id": "th_epitome", + "name": "Epitome", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/epitome", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/epitome/", + "description": "Epitome" + }, + { + "id": "th_nikki", + "name": "Nikki", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/nikki", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/nikki/", + "description": "Nikki" + }, + { + "id": "th_ahana", + "name": "Ahana", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/ahana", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/ahana/", + "description": "Ahana" + }, + { + "id": "th_garage", + "name": "Garage", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/garage", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/garage/", + "description": "Garage" + }, + { + "id": "th_kairos", + "name": "Kairos", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/kairos", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/kairos/", + "description": "Kairos" + }, + { + "id": "th_environmentalorganization", + "name": "Environmentalorganization", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/environmentalorganization", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/environmentalorganization/", + "description": "Environmentalorganization" + }, + { + "id": "th_safs", + "name": "Safs", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/safs", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/safs/", + "description": "Safs" + }, + { + "id": "th_activitar", + "name": "Activitar", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/activitar", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/activitar/", + "description": "Activitar" + }, + { + "id": "th_kross", + "name": "Kross", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/kross", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/kross/", + "description": "Kross" + }, + { + "id": "th_exclusivity", + "name": "Exclusivity", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/exclusivity", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/exclusivity/", + "description": "For demo and downloads go to this link:" + }, + { + "id": "th_vortex", + "name": "Vortex", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/vortex", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/vortex/", + "description": "Vortex" + }, + { + "id": "th_invits", + "name": "Invits", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/invits", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/invits/", + "description": "Invits" + }, + { + "id": "th_lifecoach", + "name": "Lifecoach", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/lifecoach", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/lifecoach/", + "description": "Lifecoach" + }, + { + "id": "th_pato", + "name": "Pato", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/pato", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/pato/", + "description": "Pato" + }, + { + "id": "th_job_board_2", + "name": "Job Board 2", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/job-board-2", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/job-board-2/", + "description": "Job Board 2" + }, + { + "id": "th_yummy2", + "name": "Yummy2", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/yummy2", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/yummy2/", + "description": "Yummy2" + }, + { + "id": "th_aesthetic", + "name": "Aesthetic", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/aesthetic", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/aesthetic/", + "description": "Aesthetic" + }, + { + "id": "th_joson", + "name": "Joson", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/joson", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/joson/", + "description": "Joson" + }, + { + "id": "th_deerhost", + "name": "Deerhost", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/deerhost", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/deerhost/", + "description": "Deerhost" + }, + { + "id": "th_wed", + "name": "Wed", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/wed", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/wed/", + "description": "Wed" + }, + { + "id": "th_gutim", + "name": "Gutim", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/gutim", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/gutim/", + "description": "Gutim" + }, + { + "id": "th_dominic", + "name": "Dominic", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/dominic", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/dominic/", + "description": "Dominic" + }, + { + "id": "th_watch", + "name": "Watch", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/watch", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/watch/", + "description": "Watch" + }, + { + "id": "th_medino", + "name": "Medino", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/medino", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/medino/", + "description": "Medino" + }, + { + "id": "th_drpro", + "name": "Drpro", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/drpro", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/drpro/", + "description": "Drpro" + }, + { + "id": "th_savory", + "name": "Savory", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Savory", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Savory/", + "description": "Savory" + }, + { + "id": "th_mixtape", + "name": "Mixtape", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/mixtape", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/mixtape/", + "description": "Mixtape" + }, + { + "id": "th_ninestars", + "name": "Ninestars", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/ninestars", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/ninestars/", + "description": "Ninestars" + }, + { + "id": "th_meranda", + "name": "Meranda", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/meranda", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/meranda/", + "description": "Meranda" + }, + { + "id": "th_be_one", + "name": "Be One", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/be_one", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/be_one/", + "description": "Be One" + }, + { + "id": "th_cohost", + "name": "Cohost", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/cohost", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/cohost/", + "description": "Cohost" + }, + { + "id": "th_directing", + "name": "Directing", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/directing", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/directing/", + "description": "Directing" + }, + { + "id": "th_moderna", + "name": "Moderna", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/moderna", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/moderna/", + "description": "Moderna" + }, + { + "id": "th_trafalgar", + "name": "Trafalgar", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/trafalgar", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/trafalgar/", + "description": "Trafalgar" + }, + { + "id": "th_christmas_email", + "name": "Christmas Email", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/christmas-email", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/christmas-email/", + "description": "Christmas Email - A Responsive Christmas Email Template to increase your Christmas sells instantly!" + }, + { + "id": "th_kanun", + "name": "Kanun", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/kanun", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/kanun/", + "description": "Kanun" + }, + { + "id": "th_skillhunt", + "name": "Skillhunt", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/skillhunt", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/skillhunt/", + "description": "Skillhunt" + }, + { + "id": "th_medi", + "name": "Medi", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/medi", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/medi/", + "description": "Medi" + }, + { + "id": "th_buson", + "name": "Buson", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/buson", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/buson/", + "description": "Buson" + }, + { + "id": "th_listrace", + "name": "Listrace", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/listrace", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/listrace/", + "description": "Listrace" + }, + { + "id": "th_sublime", + "name": "Sublime", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/sublime", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/sublime/", + "description": "Sublime" + }, + { + "id": "th_banker", + "name": "Banker", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/banker", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/banker/", + "description": "Banker" + }, + { + "id": "th_loanday", + "name": "Loanday", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/loanday", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/loanday/", + "description": "Loanday" + }, + { + "id": "th_arbano", + "name": "Arbano", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/arbano", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/arbano/", + "description": "Arbano" + }, + { + "id": "th_citylisting", + "name": "Citylisting", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/citylisting", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/citylisting/", + "description": "Citylisting" + }, + { + "id": "th_avo", + "name": "Avo", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/avo", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/avo/", + "description": "Avo" + }, + { + "id": "th_beyond", + "name": "Beyond", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/beyond", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/beyond/", + "description": "Beyond" + }, + { + "id": "th_andrea", + "name": "Andrea", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/andrea", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/andrea/", + "description": "Andrea" + }, + { + "id": "th_kindle", + "name": "Kindle", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/kindle", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/kindle/", + "description": "Kindle" + }, + { + "id": "th_simple", + "name": "Simple", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/simple", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/simple/", + "description": "Simple" + }, + { + "id": "th_snapshot", + "name": "Snapshot", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/snapshot", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/snapshot/", + "description": "Snapshot" + }, + { + "id": "th_energy1", + "name": "Energy1", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/energy1", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/energy1/", + "description": "Energy1" + }, + { + "id": "th_flatter", + "name": "Flatter", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/flatter", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/flatter/", + "description": "Flatter" + }, + { + "id": "th_dentist", + "name": "Dentist", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/dentist", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/dentist/", + "description": "Dentist" + }, + { + "id": "th_look", + "name": "Look", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/look", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/look/", + "description": "Look" + }, + { + "id": "th_bizcraft", + "name": "Bizcraft", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/bizcraft", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/bizcraft/", + "description": "Bizcraft" + }, + { + "id": "th_itsolution", + "name": "Itsolution", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/itsolution", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/itsolution/", + "description": "Itsolution" + }, + { + "id": "th_stamina", + "name": "Stamina", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/stamina", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/stamina/", + "description": "Stamina" + }, + { + "id": "th_loans2go", + "name": "Loans2Go", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/loans2go", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/loans2go/", + "description": "Loans2Go" + }, + { + "id": "th_invention", + "name": "Invention", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Invention", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Invention/", + "description": "Invention" + }, + { + "id": "th_marco", + "name": "Marco", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/marco", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/marco/", + "description": "Marco" + }, + { + "id": "th_uptown", + "name": "Uptown", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/uptown", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/uptown/", + "description": "Uptown" + }, + { + "id": "th_advanture_2", + "name": "Advanture 2", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/advanture-2", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/advanture-2/", + "description": "Advanture 2" + }, + { + "id": "th_major", + "name": "Major", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/major", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/major/", + "description": "Major" + }, + { + "id": "th_workout", + "name": "Workout", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/workout", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/workout/", + "description": "Workout" + }, + { + "id": "th_apart", + "name": "Apart", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/apart", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/apart/", + "description": "Apart" + }, + { + "id": "th_wemeet", + "name": "Wemeet", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/wemeet", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/wemeet/", + "description": "Wemeet" + }, + { + "id": "th_dreams", + "name": "Dreams", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/dreams", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/dreams/", + "description": "Dreams" + }, + { + "id": "th_learnit", + "name": "Learnit", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/learnit", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/learnit/", + "description": "Learnit" + }, + { + "id": "th_stylistic", + "name": "Stylistic", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/stylistic", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/stylistic/", + "description": "Stylistic" + }, + { + "id": "th_snipp", + "name": "Snipp", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/snipp", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/snipp/", + "description": "Snipp" + }, + { + "id": "th_hazze", + "name": "Hazze", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/hazze", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/hazze/", + "description": "Hazze" + }, + { + "id": "th_scaffold", + "name": "Scaffold", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/scaffold", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/scaffold/", + "description": "Scaffold" + }, + { + "id": "th_covid", + "name": "Covid", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/covid", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/covid/", + "description": "Covid" + }, + { + "id": "th_amazon_ebook", + "name": "Amazon Ebook", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Amazon-eBook", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Amazon-eBook/", + "description": "Responsive eBook Template" + }, + { + "id": "th_voyage", + "name": "Voyage", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/voyage", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/voyage/", + "description": "Voyage" + }, + { + "id": "th_sentra", + "name": "Sentra", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/sentra", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/sentra/", + "description": "Sentra" + }, + { + "id": "th_eden", + "name": "Eden", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/eden", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/eden/", + "description": "Eden" + }, + { + "id": "th_amin", + "name": "Amin", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/amin", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/amin/", + "description": "Amin" + }, + { + "id": "th_ecoland", + "name": "Ecoland", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/ecoland", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/ecoland/", + "description": "Ecoland" + }, + { + "id": "th_author", + "name": "Author", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/author", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/author/", + "description": "Author" + }, + { + "id": "th_constructo", + "name": "Constructo", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/constructo", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/constructo/", + "description": "Constructo" + }, + { + "id": "th_zogin", + "name": "Zogin", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/zogin", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/zogin/", + "description": "Zogin" + }, + { + "id": "th_ninom", + "name": "Ninom", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/ninom", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/ninom/", + "description": "Ninom" + }, + { + "id": "th_hiroto", + "name": "Hiroto", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/hiroto", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/hiroto/", + "description": "Hiroto- Free Template" + }, + { + "id": "th_chocolux", + "name": "Chocolux", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/chocolux", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/chocolux/", + "description": "Chocolux" + }, + { + "id": "th_alotan", + "name": "Alotan", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/alotan", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/alotan/", + "description": "Alotan" + }, + { + "id": "th_thevenue", + "name": "Thevenue", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/thevenue", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/thevenue/", + "description": "Thevenue" + }, + { + "id": "th_aievari", + "name": "Aievari", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/aievari", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/aievari/", + "description": "Aievari" + }, + { + "id": "th_scenic", + "name": "Scenic", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/scenic", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/scenic/", + "description": "Scenic" + }, + { + "id": "th_swipe", + "name": "Swipe", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/swipe", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/swipe/", + "description": "Swipe" + }, + { + "id": "th_callie", + "name": "Callie", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/callie", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/callie/", + "description": "Callie" + }, + { + "id": "th_humanity", + "name": "Humanity", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/humanity", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/humanity/", + "description": "Multi Page Non-Profit Template " + }, + { + "id": "th_eatery", + "name": "Eatery", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/eatery", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/eatery/", + "description": "Eatery" + }, + { + "id": "th_luxe", + "name": "Luxe", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/luxe", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/luxe/", + "description": "Luxe" + }, + { + "id": "th_anipat", + "name": "Anipat", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/anipat", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/anipat/", + "description": "Anipat" + }, + { + "id": "th_pexcon", + "name": "Pexcon", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/pexcon", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/pexcon/", + "description": "Pexcon" + }, + { + "id": "th_chiropractic", + "name": "Chiropractic", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/chiropractic", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/chiropractic/", + "description": "Chiropractic" + }, + { + "id": "th_book", + "name": "Book", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/book", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/book/", + "description": "Book" + }, + { + "id": "th_oxomi", + "name": "Oxomi", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/oxomi", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/oxomi/", + "description": "Oxomi" + }, + { + "id": "th_energen", + "name": "Energen", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/energen", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/energen/", + "description": "Energen" + }, + { + "id": "th_judge", + "name": "Judge", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/judge", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/judge/", + "description": "Judge" + }, + { + "id": "th_cyption", + "name": "Cyption", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/cyption", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/cyption/", + "description": "Cyption" + }, + { + "id": "th_fables", + "name": "Fables", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/fables", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/fables/", + "description": "Multipurpose Corporation free HTML5 Template" + }, + { + "id": "th_lingua", + "name": "Lingua", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/lingua", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/lingua/", + "description": "Lingua" + }, + { + "id": "th_kusina", + "name": "Kusina", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/kusina", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/kusina/", + "description": "Kusina" + }, + { + "id": "th_counselor", + "name": "Counselor", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/counselor", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/counselor/", + "description": "Counselor" + }, + { + "id": "th_grunt", + "name": "Grunt", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/grunt", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/grunt/", + "description": "Grunt" + }, + { + "id": "th_elderly", + "name": "Elderly", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/elderly", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/elderly/", + "description": "Elderly" + }, + { + "id": "th_quickloud", + "name": "Quickloud", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/quickloud", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/quickloud/", + "description": "Quickloud" + }, + { + "id": "th_landie", + "name": "Landie", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/landie", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/landie/", + "description": "Landie" + }, + { + "id": "th_treviso", + "name": "Treviso", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/treviso", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/treviso/", + "description": "Treviso" + }, + { + "id": "th_consult", + "name": "Consult", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/consult", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/consult/", + "description": "Consult" + }, + { + "id": "th_nitro", + "name": "Nitro", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/nitro", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/nitro/", + "description": "Nitro" + }, + { + "id": "th_journey", + "name": "Journey", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/journey", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/journey/", + "description": "Journey" + }, + { + "id": "th_typerite", + "name": "Typerite", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/typerite", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/typerite/", + "description": "Typerite" + }, + { + "id": "th_realtors", + "name": "Realtors", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/realtors", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/realtors/", + "description": "Realtors" + }, + { + "id": "th_cassi", + "name": "Cassi", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/cassi", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/cassi/", + "description": "Cassi" + }, + { + "id": "th_sierra", + "name": "Sierra", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/sierra", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/sierra/", + "description": "Sierra" + }, + { + "id": "th_fresh", + "name": "Fresh", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/fresh", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/fresh/", + "description": "Fresh" + }, + { + "id": "th_softland", + "name": "Softland", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/softland", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/softland/", + "description": "Softland" + }, + { + "id": "th_resta", + "name": "Resta", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/resta", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/resta/", + "description": "Resta" + }, + { + "id": "th_newbiz", + "name": "Newbiz", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/newbiz", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/newbiz/", + "description": "Newbiz" + }, + { + "id": "th_uza", + "name": "Uza", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/uza", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/uza/", + "description": "Uza" + }, + { + "id": "th_harbor", + "name": "Harbor", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/harbor", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/harbor/", + "description": "Harbor" + }, + { + "id": "th_linkweb", + "name": "Linkweb", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/linkweb", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/linkweb/", + "description": "Linkweb" + }, + { + "id": "th_south", + "name": "South", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/south", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/south/", + "description": "South" + }, + { + "id": "th_wow", + "name": "Wow", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/wow", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/wow/", + "description": "Wow" + }, + { + "id": "th_neos", + "name": "Neos", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/neos", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/neos/", + "description": "Neos" + }, + { + "id": "th_bueno", + "name": "Bueno", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/bueno", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/bueno/", + "description": "Bueno" + }, + { + "id": "th_pressure_washing", + "name": "Pressure Washing", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/pressure-washing", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/pressure-washing/", + "description": "Pressure Washing" + }, + { + "id": "th_mamba", + "name": "Mamba", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/mamba", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/mamba/", + "description": "Mamba" + }, + { + "id": "th_multipurpose", + "name": "Multipurpose", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/multipurpose", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/multipurpose/", + "description": "Multipurpose" + }, + { + "id": "th_rhino", + "name": "Rhino", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/rhino", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/rhino/", + "description": "Rhino" + }, + { + "id": "th_personify", + "name": "Personify", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/personify", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/personify/", + "description": "Personify" + }, + { + "id": "th_metronic_frontend", + "name": "Metronic Frontend", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Metronic-Frontend", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Metronic-Frontend/", + "description": "Metronic Frontend" + }, + { + "id": "th_sight", + "name": "Sight", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/SIGHT", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/SIGHT/", + "description": "Free Responsive Web Template" + }, + { + "id": "th_skwela", + "name": "Skwela", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/skwela", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/skwela/", + "description": "Skwela" + }, + { + "id": "th_acupuncture", + "name": "Acupuncture", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/acupuncture", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/acupuncture/", + "description": "Acupuncture" + }, + { + "id": "th_azenta", + "name": "Azenta", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/azenta", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/azenta/", + "description": "Azenta" + }, + { + "id": "th_onlineedu", + "name": "Onlineedu", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/onlineedu", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/onlineedu/", + "description": "Onlineedu" + }, + { + "id": "th_surogou", + "name": "Surogou", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/surogou", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/surogou/", + "description": "Surogou" + }, + { + "id": "th_menztailor", + "name": "Menztailor", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/menztailor", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/menztailor/", + "description": "Menztailor" + }, + { + "id": "th_factory", + "name": "Factory", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Factory", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Factory/", + "description": "Factory" + }, + { + "id": "th_imperial", + "name": "Imperial", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/imperial", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/imperial/", + "description": "Imperial" + }, + { + "id": "th_pure1", + "name": "Pure1", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/pure1", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/pure1/", + "description": "Pure1" + }, + { + "id": "th_industire", + "name": "Industire", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/industire", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/industire/", + "description": "Industire" + }, + { + "id": "th_resto", + "name": "Resto", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/resto", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/resto/", + "description": "Resto" + }, + { + "id": "th_woodrox", + "name": "Woodrox", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/woodrox", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/woodrox/", + "description": "Woodrox" + }, + { + "id": "th_blanca", + "name": "Blanca", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/blanca", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/blanca/", + "description": "Blanca" + }, + { + "id": "th_archlab", + "name": "Archlab", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/archlab", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/archlab/", + "description": "Archlab" + }, + { + "id": "th_consulotion", + "name": "Consulotion", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/consulotion", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/consulotion/", + "description": "Consulotion" + }, + { + "id": "th_braxit", + "name": "Braxit", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/braxit", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/braxit/", + "description": "Braxit" + }, + { + "id": "th_comport", + "name": "Comport", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/comport", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/comport/", + "description": "Comport" + }, + { + "id": "th_instylr", + "name": "Instylr", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/instylr", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/instylr/", + "description": "Instylr" + }, + { + "id": "th_virtualassistant", + "name": "Virtualassistant", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/virtualassistant", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/virtualassistant/", + "description": "Virtualassistant" + }, + { + "id": "th_stisla", + "name": "Stisla", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/stisla", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/stisla/", + "description": "HTML5 and CSS3 Template Based on Bootstrap 4" + }, + { + "id": "th_coaching", + "name": "Coaching", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/coaching", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/coaching/", + "description": "Coaching" + }, + { + "id": "th_eatwell", + "name": "Eatwell", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/eatwell", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/eatwell/", + "description": "Eatwell" + }, + { + "id": "th_more", + "name": "More", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/more", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/more/", + "description": "More" + }, + { + "id": "th_hepta", + "name": "Hepta", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/hepta", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/hepta/", + "description": "Hepta" + }, + { + "id": "th_nest", + "name": "Nest", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/nest", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/nest/", + "description": "Nest" + }, + { + "id": "th_rabbit", + "name": "Rabbit", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/rabbit", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/rabbit/", + "description": "A responsive HTML 5 Template" + }, + { + "id": "th_regna", + "name": "Regna", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/regna", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/regna/", + "description": "Regna" + }, + { + "id": "th_busicol", + "name": "Busicol", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/busicol", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/busicol/", + "description": "Busicol" + }, + { + "id": "th_book_keeping", + "name": "Book Keeping", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/book-keeping", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/book-keeping/", + "description": "Book Keeping" + }, + { + "id": "th_rapid", + "name": "Rapid", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/rapid", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/rapid/", + "description": "Rapid" + }, + { + "id": "th_br", + "name": "Br", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/br", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/br/", + "description": "Br" + }, + { + "id": "th_redplanet", + "name": "Redplanet", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/redplanet", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/redplanet/", + "description": "Redplanet" + }, + { + "id": "th_teaser", + "name": "Teaser", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/teaser", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/teaser/", + "description": "Teaser" + }, + { + "id": "th_archs", + "name": "Archs", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/archs", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/archs/", + "description": "Archs" + }, + { + "id": "th_snow", + "name": "Snow", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/snow", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/snow/", + "description": "Snow" + }, + { + "id": "th_steve", + "name": "Steve", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/steve", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/steve/", + "description": "Steve" + }, + { + "id": "th_paradigm_shift", + "name": "Paradigm Shift", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/paradigm-shift", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/paradigm-shift/", + "description": "Paradigm Shift" + }, + { + "id": "th_basco", + "name": "Basco", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/basco", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/basco/", + "description": "Basco" + }, + { + "id": "th_favison", + "name": "Favison", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/favison", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/favison/", + "description": "Favison" + }, + { + "id": "th_brainwave", + "name": "Brainwave", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/brainwave", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/brainwave/", + "description": "Brainwave" + }, + { + "id": "th_namari", + "name": "Namari", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/namari", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/namari/", + "description": "Namari" + }, + { + "id": "th_occupy", + "name": "Occupy", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/occupy", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/occupy/", + "description": "Occupy" + }, + { + "id": "th_consulto", + "name": "Consulto", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/consulto", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/consulto/", + "description": "Consulto" + }, + { + "id": "th_initio", + "name": "Initio", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Initio", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Initio/", + "description": "Initio" + }, + { + "id": "th_ararat", + "name": "Ararat", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/ararat", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/ararat/", + "description": "Ararat" + }, + { + "id": "th_quest", + "name": "Quest", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/quest", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/quest/", + "description": "Quest" + }, + { + "id": "th_fitnezz", + "name": "Fitnezz", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/fitnezz", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/fitnezz/", + "description": "Fitnezz" + }, + { + "id": "th_pivot", + "name": "Pivot", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/pivot", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/pivot/", + "description": "Pivot" + }, + { + "id": "th_spectral", + "name": "Spectral", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/spectral", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/spectral/", + "description": "Spectral" + }, + { + "id": "th_digilab", + "name": "Digilab", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/digilab", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/digilab/", + "description": "Digilab" + }, + { + "id": "th_bee", + "name": "Bee", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/bee", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/bee/", + "description": "Bee" + }, + { + "id": "th_juli", + "name": "Juli", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/juli", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/juli/", + "description": "Juli" + }, + { + "id": "th_hus", + "name": "Hus", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/hus", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/hus/", + "description": "Hus" + }, + { + "id": "th_luxury", + "name": "Luxury", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Luxury", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Luxury/", + "description": "Luxury - An Elegant Responsive One Page Bootstrap Template " + }, + { + "id": "th_webiz", + "name": "Webiz", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/webiz", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/webiz/", + "description": "Webiz" + }, + { + "id": "th_city_real_estate", + "name": "City Real Estate", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/city-real-estate", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/city-real-estate/", + "description": "City Real Estate" + }, + { + "id": "th_dream_pulse", + "name": "Dream Pulse", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/dream-pulse", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/dream-pulse/", + "description": "Dream Pulse" + }, + { + "id": "th_create", + "name": "Create", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/create", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/create/", + "description": "Create" + }, + { + "id": "th_yaseen", + "name": "Yaseen", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/yaseen", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/yaseen/", + "description": "Yaseen" + }, + { + "id": "th_confer", + "name": "Confer", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/confer", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/confer/", + "description": "Confer" + }, + { + "id": "th_kd", + "name": "Kd", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/kd", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/kd/", + "description": "Kd" + }, + { + "id": "th_doni", + "name": "Doni", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/doni", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/doni/", + "description": "Doni" + }, + { + "id": "th_mical", + "name": "Mical", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/mical", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/mical/", + "description": "Mical" + }, + { + "id": "th_credo", + "name": "Credo", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/credo", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/credo/", + "description": "Credo" + }, + { + "id": "th_burnout", + "name": "Burnout", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/burnout", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/burnout/", + "description": "Burnout" + }, + { + "id": "th_neat", + "name": "Neat", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/neat", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/neat/", + "description": "Neat" + }, + { + "id": "th_razor", + "name": "Razor", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/razor", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/razor/", + "description": "Razor" + }, + { + "id": "th_bloscot", + "name": "Bloscot", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/bloscot", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/bloscot/", + "description": "Bloscot" + }, + { + "id": "th_brighton", + "name": "Brighton", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/brighton", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/brighton/", + "description": "Brighton" + }, + { + "id": "th_medisen", + "name": "Medisen", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/medisen", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/medisen/", + "description": "Medisen" + }, + { + "id": "th_lattes", + "name": "Lattes", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/lattes", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/lattes/", + "description": "Lattes" + }, + { + "id": "th_stuff", + "name": "Stuff", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/stuff", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/stuff/", + "description": "Stuff" + }, + { + "id": "th_diffuso", + "name": "Diffuso", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/diffuso", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/diffuso/", + "description": "Diffuso" + }, + { + "id": "th_solution", + "name": "Solution", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/solution", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/solution/", + "description": "Solution" + }, + { + "id": "th_industrial", + "name": "Industrial", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/industrial", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/industrial/", + "description": "Industrial" + }, + { + "id": "th_lifeleck", + "name": "Lifeleck", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/lifeleck", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/lifeleck/", + "description": "Lifeleck" + }, + { + "id": "th_proshoot", + "name": "Proshoot", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/proshoot", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/proshoot/", + "description": "Proshoot" + }, + { + "id": "th_fastes", + "name": "Fastes", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/fastes", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/fastes/", + "description": "Fastes" + }, + { + "id": "th_foste", + "name": "Foste", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/foste", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/foste/", + "description": "Foste" + }, + { + "id": "th_imagine", + "name": "Imagine", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/imagine", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/imagine/", + "description": "Imagine" + }, + { + "id": "th_mini_profile", + "name": "Mini Profile", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/mini-profile", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/mini-profile/", + "description": "Mini Profile" + }, + { + "id": "th_bell", + "name": "Bell", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/bell", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/bell/", + "description": "Bell" + }, + { + "id": "th_convid", + "name": "Convid", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/convid", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/convid/", + "description": "Convid" + }, + { + "id": "th_gazette", + "name": "Gazette", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/gazette", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/gazette/", + "description": "Gazette" + }, + { + "id": "th_real_estate", + "name": "Real Estate", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/real-estate", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/real-estate/", + "description": "Real Estate" + }, + { + "id": "th_pilates", + "name": "Pilates", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Pilates", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Pilates/", + "description": "Life Coach - A Responsive, One Page Coaching Website Template for Consultant, Coaches and Instructors " + }, + { + "id": "th_halen", + "name": "Halen", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/halen", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/halen/", + "description": "Halen" + }, + { + "id": "th_nexus", + "name": "Nexus", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/nexus", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/nexus/", + "description": "Nexus" + }, + { + "id": "th_fantasy", + "name": "Fantasy", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/fantasy", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/fantasy/", + "description": "Fantasy" + }, + { + "id": "th_funder", + "name": "Funder", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/funder", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/funder/", + "description": "Funder" + }, + { + "id": "th_heaven", + "name": "Heaven", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/heaven", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/heaven/", + "description": "Heaven" + }, + { + "id": "th_bluesky", + "name": "Bluesky", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/bluesky", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/bluesky/", + "description": "Bluesky" + }, + { + "id": "th_me", + "name": "Me", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/me", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/me/", + "description": "Me" + }, + { + "id": "th_mighty", + "name": "Mighty", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/mighty", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/mighty/", + "description": "Mighty" + }, + { + "id": "th_crossfit_2", + "name": "Crossfit 2", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/crossfit-2", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/crossfit-2/", + "description": "Crossfit 2" + }, + { + "id": "th_bbs", + "name": "Bbs", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/bbs", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/bbs/", + "description": "Bbs" + }, + { + "id": "th_hostza", + "name": "Hostza", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/hostza", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/hostza/", + "description": "Hostza" + }, + { + "id": "th_safario", + "name": "Safario", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/safario", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/safario/", + "description": "Safario" + }, + { + "id": "th_maxibiz", + "name": "Maxibiz", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/maxibiz", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/maxibiz/", + "description": "Maxibiz" + }, + { + "id": "th_minimis", + "name": "Minimis", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/minimis", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/minimis/", + "description": "Minimis" + }, + { + "id": "th_dolphin", + "name": "Dolphin", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/dolphin", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/dolphin/", + "description": "Dolphin" + }, + { + "id": "th_karmo", + "name": "Karmo", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/karmo", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/karmo/", + "description": "Karmo" + }, + { + "id": "th_softy_pinko", + "name": "Softy Pinko", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/softy-pinko", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/softy-pinko/", + "description": "Softy Pinko" + }, + { + "id": "th_notary", + "name": "Notary", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/notary", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/notary/", + "description": "Notary" + }, + { + "id": "th_foto", + "name": "Foto", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/foto", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/foto/", + "description": "Foto" + }, + { + "id": "th_cellon", + "name": "Cellon", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/cellon", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/cellon/", + "description": "Cellon" + }, + { + "id": "th_crafted", + "name": "Crafted", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/crafted", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/crafted/", + "description": "Crafted" + }, + { + "id": "th_diggo", + "name": "Diggo", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/diggo", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/diggo/", + "description": "Diggo" + }, + { + "id": "th_nickie", + "name": "Nickie", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/nickie", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/nickie/", + "description": "Nickie" + }, + { + "id": "th_jumper", + "name": "Jumper", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/jumper", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/jumper/", + "description": "Jumper" + }, + { + "id": "th_glint2", + "name": "Glint2", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/glint2", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/glint2/", + "description": "Glint2" + }, + { + "id": "th_wordify", + "name": "Wordify", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/wordify", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/wordify/", + "description": "Wordify" + }, + { + "id": "th_blk_design_system", + "name": "Blk Design System", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/blk-design-system", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/blk-design-system/", + "description": "Blk Design System" + }, + { + "id": "th_like", + "name": "Like", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/like", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/like/", + "description": "Like" + }, + { + "id": "th_ionize", + "name": "Ionize", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/ionize", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/ionize/", + "description": "Ionize" + }, + { + "id": "th_meditative", + "name": "Meditative", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/meditative", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/meditative/", + "description": "Meditative" + }, + { + "id": "th_archi", + "name": "Archi", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/archi", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/archi/", + "description": "Archi" + }, + { + "id": "th_remake", + "name": "Remake", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/remake", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/remake/", + "description": "Remake" + }, + { + "id": "th_revive", + "name": "Revive", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/revive", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/revive/", + "description": "Revive" + }, + { + "id": "th_dreams_2", + "name": "Dreams 2", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/dreams-2", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/dreams-2/", + "description": "Dreams 2" + }, + { + "id": "th_new_age", + "name": "New Age", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/new-age", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/new-age/", + "description": "New Age" + }, + { + "id": "th_dazzle", + "name": "Dazzle", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/dazzle", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/dazzle/", + "description": "Dazzle" + }, + { + "id": "th_insertion", + "name": "Insertion", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/insertion", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/insertion/", + "description": "Insertion" + }, + { + "id": "th_hola", + "name": "Hola", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/hola", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/hola/", + "description": "Hola" + }, + { + "id": "th_clickaholic", + "name": "Clickaholic", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/clickaholic", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/clickaholic/", + "description": "Clickaholic" + }, + { + "id": "th_itsy", + "name": "Itsy", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/itsy", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/itsy/", + "description": "Itsy" + }, + { + "id": "th_pro_line", + "name": "Pro Line", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/pro-line", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/pro-line/", + "description": "Pro Line" + }, + { + "id": "th_elegance", + "name": "Elegance", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/elegance", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/elegance/", + "description": "Elegance" + }, + { + "id": "th_marga", + "name": "Marga", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/marga", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/marga/", + "description": "Marga" + }, + { + "id": "th_handyman", + "name": "Handyman", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/handyman", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/handyman/", + "description": "Handyman" + }, + { + "id": "th_precon", + "name": "Precon", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/precon", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/precon/", + "description": "Precon" + }, + { + "id": "th_intot", + "name": "Intot", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/intot", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/intot/", + "description": "Intot" + }, + { + "id": "th_racks", + "name": "Racks", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/racks", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/racks/", + "description": "Racks" + }, + { + "id": "th_buildex", + "name": "Buildex", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/buildex", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/buildex/", + "description": "Buildex" + }, + { + "id": "th_monday", + "name": "Monday", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/monday", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/monday/", + "description": "Monday" + }, + { + "id": "th_expert", + "name": "Expert", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/expert", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/expert/", + "description": "Expert" + }, + { + "id": "th_jimmy", + "name": "Jimmy", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/jimmy", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/jimmy/", + "description": "Jimmy" + }, + { + "id": "th_jd_1", + "name": "Jd 1", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/jd-1", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/jd-1/", + "description": "Jd 1" + }, + { + "id": "th_cocoon", + "name": "Cocoon", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/cocoon", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/cocoon/", + "description": "Cocoon" + }, + { + "id": "th_bato", + "name": "Bato", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/bato", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/bato/", + "description": "Bato" + }, + { + "id": "th_oak", + "name": "Oak", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/oak", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/oak/", + "description": "Oak" + }, + { + "id": "th_meal", + "name": "Meal", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/meal", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/meal/", + "description": "Meal" + }, + { + "id": "th_pixel", + "name": "Pixel", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/pixel", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/pixel/", + "description": "Pixel" + }, + { + "id": "th_feast", + "name": "Feast", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/feast", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/feast/", + "description": "Feast" + }, + { + "id": "th_kreative", + "name": "Kreative", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/kreative", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/kreative/", + "description": "Kreative" + }, + { + "id": "th_standout", + "name": "Standout", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/standout", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/standout/", + "description": "Standout" + }, + { + "id": "th_art_factory", + "name": "Art Factory", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/art-factory", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/art-factory/", + "description": "Art Factory" + }, + { + "id": "th_dizzi", + "name": "Dizzi", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/dizzi", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/dizzi/", + "description": "Dizzi" + }, + { + "id": "th_oneder", + "name": "Oneder", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/oneder", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/oneder/", + "description": "Oneder" + }, + { + "id": "th_meghna", + "name": "Meghna", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/meghna", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/meghna/", + "description": "Meghna" + }, + { + "id": "th_lorahost", + "name": "Lorahost", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/lorahost", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/lorahost/", + "description": "Lorahost" + }, + { + "id": "th_vanilla", + "name": "Vanilla", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/vanilla", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/vanilla/", + "description": "Vanilla" + }, + { + "id": "th_plataforma", + "name": "Plataforma", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/plataforma", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/plataforma/", + "description": "Plataforma" + }, + { + "id": "th_podcast", + "name": "Podcast", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/podcast", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/podcast/", + "description": "Podcast" + }, + { + "id": "th_argon", + "name": "Argon", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/argon", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/argon/", + "description": "Argon" + }, + { + "id": "th_consula", + "name": "Consula", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/consula", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/consula/", + "description": "Consula" + }, + { + "id": "th_magnum", + "name": "Magnum", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/magnum", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/magnum/", + "description": "Magnum" + }, + { + "id": "th_amplify", + "name": "Amplify", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/amplify", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/amplify/", + "description": "Amplify" + }, + { + "id": "th_ostacor", + "name": "Ostacor", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Ostacor", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Ostacor/", + "description": "Ostacor" + }, + { + "id": "th_shahala", + "name": "Shahala", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/shahala", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/shahala/", + "description": "Shahala" + }, + { + "id": "th_cruise", + "name": "Cruise", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/cruise", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/cruise/", + "description": "Cruise" + }, + { + "id": "th_lazy_kit", + "name": "Lazy Kit", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/lazy-kit", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/lazy-kit/", + "description": "Lazy Kit" + }, + { + "id": "th_unearth", + "name": "Unearth", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/unearth", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/unearth/", + "description": "Unearth" + }, + { + "id": "th_pro", + "name": "Pro", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/pro", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/pro/", + "description": "Pro" + }, + { + "id": "th_comply", + "name": "Comply", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/comply", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/comply/", + "description": "Comply" + }, + { + "id": "th_bigwing", + "name": "Bigwing", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/bigwing", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/bigwing/", + "description": "Bigwing" + }, + { + "id": "th_metal", + "name": "Metal", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/metal", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/metal/", + "description": "Metal" + }, + { + "id": "th_moon", + "name": "Moon", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/moon", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/moon/", + "description": "Moon" + }, + { + "id": "th_mosh", + "name": "Mosh", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/mosh", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/mosh/", + "description": "Mosh" + }, + { + "id": "th_solid_state", + "name": "Solid State", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Solid-State", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Solid-State/", + "description": "Solid State" + }, + { + "id": "th_interact", + "name": "Interact", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/interact", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/interact/", + "description": "Interact" + }, + { + "id": "th_ghughu", + "name": "Ghughu", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/ghughu", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/ghughu/", + "description": "Ghughu" + }, + { + "id": "th_adalot", + "name": "Adalot", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/adalot", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/adalot/", + "description": "Adalot" + }, + { + "id": "th_green_special", + "name": "Green Special", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/green-special", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/green-special/", + "description": "Green Special" + }, + { + "id": "th_buri", + "name": "Buri", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/buri", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/buri/", + "description": "Buri" + }, + { + "id": "th_rapoo", + "name": "Rapoo", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/rapoo", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/rapoo/", + "description": "Rapoo" + }, + { + "id": "th_neutral", + "name": "Neutral", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/neutral", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/neutral/", + "description": "Neutral" + }, + { + "id": "th_lander", + "name": "Lander", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/lander", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/lander/", + "description": "Lander" + }, + { + "id": "th_read_only", + "name": "Read Only", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/read-only", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/read-only/", + "description": "Read Only" + }, + { + "id": "th_trave", + "name": "Trave", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/trave", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/trave/", + "description": "Trave" + }, + { + "id": "th_magazee", + "name": "Magazee", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/magazee", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/magazee/", + "description": "Magazee" + }, + { + "id": "th_ubutia", + "name": "Ubutia", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/ubutia", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/ubutia/", + "description": "free bootstrap template" + }, + { + "id": "th_nissa", + "name": "Nissa", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/nissa", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/nissa/", + "description": "Nissa" + }, + { + "id": "th_parallo", + "name": "Parallo", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/parallo", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/parallo/", + "description": "Parallo" + }, + { + "id": "th_armando", + "name": "Armando", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/armando", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/armando/", + "description": "Armando" + }, + { + "id": "th_next", + "name": "Next", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/next", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/next/", + "description": "Next" + }, + { + "id": "th_erase", + "name": "Erase", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/erase", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/erase/", + "description": "Erase" + }, + { + "id": "th_pixel_lite", + "name": "Pixel Lite", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/pixel-lite", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/pixel-lite/", + "description": "Pixel Lite" + }, + { + "id": "th_ethan", + "name": "Ethan", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/ethan", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/ethan/", + "description": "Ethan" + }, + { + "id": "th_judicial", + "name": "Judicial", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/judicial", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/judicial/", + "description": "Judicial" + }, + { + "id": "th_slides", + "name": "Slides", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/slides", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/slides/", + "description": "Slides" + }, + { + "id": "th_equip", + "name": "Equip", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/equip", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/equip/", + "description": "Equip" + }, + { + "id": "th_noxen", + "name": "Noxen", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/noxen", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/noxen/", + "description": "Noxen" + }, + { + "id": "th_vira", + "name": "Vira", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/vira", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/vira/", + "description": "Vira" + }, + { + "id": "th_dot", + "name": "Dot", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/dot", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/dot/", + "description": "Dot" + }, + { + "id": "th_trekking", + "name": "Trekking", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/trekking", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/trekking/", + "description": "Trekking" + }, + { + "id": "th_bizzy", + "name": "Bizzy", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/bizzy", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/bizzy/", + "description": "Bizzy" + }, + { + "id": "th_ideal", + "name": "Ideal", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/ideal", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/ideal/", + "description": "Ideal" + }, + { + "id": "th_opium", + "name": "Opium", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/opium", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/opium/", + "description": "Opium" + }, + { + "id": "th_character", + "name": "Character", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/character", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/character/", + "description": "Character" + }, + { + "id": "th_wrapk", + "name": "Wrapk", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/wrapk", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/wrapk/", + "description": "Wrapk" + }, + { + "id": "th_landerz", + "name": "Landerz", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/landerz", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/landerz/", + "description": "Landerz" + }, + { + "id": "th_twenty", + "name": "Twenty", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/twenty", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/twenty/", + "description": "Twenty" + }, + { + "id": "th_browser", + "name": "Browser", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/browser", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/browser/", + "description": "Browser" + }, + { + "id": "th_paper_kit", + "name": "Paper Kit", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/paper-kit", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/paper-kit/", + "description": "Paper Kit is a Fully Coded Web UI Kit based on Bootstrap 3" + }, + { + "id": "th_dup", + "name": "Dup", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/dup", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/dup/", + "description": "Dup" + }, + { + "id": "th_outdoors", + "name": "Outdoors", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Outdoors", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Outdoors/", + "description": "An implementation of Gil Huybrecht “Outdoors” design project powered by layered CSS grids." + }, + { + "id": "th_material_kit", + "name": "Material Kit", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/material-kit", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/material-kit/", + "description": "Material Kit" + }, + { + "id": "th_ignite", + "name": "Ignite", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/ignite", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/ignite/", + "description": "Ignite" + }, + { + "id": "th_avana", + "name": "Avana", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/avana", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/avana/", + "description": "Avana" + }, + { + "id": "th_jeren", + "name": "Jeren", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/jeren", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/jeren/", + "description": "Jeren" + }, + { + "id": "th_element", + "name": "Element", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/element", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/element/", + "description": "Element" + }, + { + "id": "th_maze", + "name": "Maze", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/maze", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/maze/", + "description": "Maze" + }, + { + "id": "th_alstar", + "name": "Alstar", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/alstar", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/alstar/", + "description": "Alstar is a free parallax Bootstrap one page template with enormous features." + }, + { + "id": "th_elisa_template_demo", + "name": "Elisa Template Demo", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/elisa-template-demo", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/elisa-template-demo/", + "description": "Demo for Elisa Bootstrap Template" + }, + { + "id": "th_wired_ui_kit", + "name": "Wired Ui Kit", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/wired_ui_kit", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/wired_ui_kit/", + "description": "Wired Ui Kit" + }, + { + "id": "th_mind_craft", + "name": "Mind Craft", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Mind-Craft", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Mind-Craft/", + "description": "Mind Craft" + }, + { + "id": "th_clemo", + "name": "Clemo", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/clemo", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/clemo/", + "description": "Clemo" + }, + { + "id": "th_aavas", + "name": "Aavas", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/aavas", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/aavas/", + "description": "Aavas" + }, + { + "id": "th_fun_weather", + "name": "Fun Weather", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/fun-weather", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/fun-weather/", + "description": "Fun Weather" + }, + { + "id": "th_goind", + "name": "Goind", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/goind", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/goind/", + "description": "Goind" + }, + { + "id": "th_platina", + "name": "Platina", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/platina", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/platina/", + "description": "Platina" + }, + { + "id": "th_sided", + "name": "Sided", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/sided", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/sided/", + "description": "Sided" + }, + { + "id": "th_fplus", + "name": "Fplus", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/fplus", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/fplus/", + "description": "Fplus" + }, + { + "id": "th_copa", + "name": "Copa", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/copa", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/copa/", + "description": "Copa" + }, + { + "id": "th_thetown", + "name": "Thetown", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/thetown", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/thetown/", + "description": "Thetown" + }, + { + "id": "th_ubeasa", + "name": "Ubeasa", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/ubeasa", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/ubeasa/", + "description": "free html5 template" + }, + { + "id": "th_dinomuz", + "name": "Dinomuz", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/dinomuz", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/dinomuz/", + "description": "Dinomuz" + }, + { + "id": "th_charcoal", + "name": "Charcoal", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/charcoal", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/charcoal/", + "description": "Charcoal" + }, + { + "id": "th_marco_2", + "name": "Marco 2", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/marco-2", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/marco-2/", + "description": "Marco 2" + }, + { + "id": "th_regen_ui_kit", + "name": "Regen Ui Kit", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/regen-ui-kit", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/regen-ui-kit/", + "description": "Regen Ui Kit" + }, + { + "id": "th_katt", + "name": "Katt", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/katt", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/katt/", + "description": "Katt" + }, + { + "id": "th_hexa", + "name": "Hexa", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/hexa", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/hexa/", + "description": "Hexa" + }, + { + "id": "th_diner", + "name": "Diner", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/diner", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/diner/", + "description": "Diner" + }, + { + "id": "th_five_star", + "name": "Five Star", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/five-star", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/five-star/", + "description": "Five Star" + }, + { + "id": "th_arcwork", + "name": "Arcwork", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/arcwork", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/arcwork/", + "description": "Arcwork" + }, + { + "id": "th_innova", + "name": "Innova", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/innova", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/innova/", + "description": "Innova" + }, + { + "id": "th_next_level", + "name": "Next Level", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/next-level", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/next-level/", + "description": "Next Level" + }, + { + "id": "th_lambda", + "name": "Lambda", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/lambda", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/lambda/", + "description": "Lambda" + }, + { + "id": "th_loaft", + "name": "Loaft", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/loaft", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/loaft/", + "description": "Loaft" + }, + { + "id": "th_ronald", + "name": "Ronald", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/ronald", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/ronald/", + "description": "Ronald" + }, + { + "id": "th_tangre", + "name": "Tangre", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/tangre", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/tangre/", + "description": "Tangre" + }, + { + "id": "th_jaine", + "name": "Jaine", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/jaine", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/jaine/", + "description": "Jaine" + }, + { + "id": "th_elements", + "name": "Elements", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/elements", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/elements/", + "description": "Elements" + }, + { + "id": "th_jd", + "name": "Jd", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/jd", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/jd/", + "description": "Jd" + }, + { + "id": "th_promodise", + "name": "Promodise", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/promodise", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/promodise/", + "description": "Promodise" + }, + { + "id": "th_ramayana", + "name": "Ramayana", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/ramayana", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/ramayana/", + "description": "Ramayana" + }, + { + "id": "th_flamix", + "name": "Flamix", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/flamix", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/flamix/", + "description": "Flamix" + }, + { + "id": "th_hikers", + "name": "Hikers", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/hikers", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/hikers/", + "description": "Hikers" + }, + { + "id": "th_holmes", + "name": "Holmes", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/holmes", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/holmes/", + "description": "Holmes" + }, + { + "id": "th_fancy", + "name": "Fancy", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/fancy", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/fancy/", + "description": "Fancy" + }, + { + "id": "th_bravo", + "name": "Bravo", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/bravo", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/bravo/", + "description": "Bravo" + }, + { + "id": "th_synthetica", + "name": "Synthetica", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/synthetica", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/synthetica/", + "description": "Synthetica" + }, + { + "id": "th_trendy", + "name": "Trendy", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/trendy", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/trendy/", + "description": "Trendy" + }, + { + "id": "th_accent", + "name": "Accent", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/accent", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/accent/", + "description": "Accent" + }, + { + "id": "th_ultim8", + "name": "Ultim8", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/ultim8", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/ultim8/", + "description": "Ultim8" + }, + { + "id": "th_sun", + "name": "Sun", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/sun", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/sun/", + "description": "Sun" + }, + { + "id": "th_patros", + "name": "Patros", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/patros", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/patros/", + "description": "Patros" + }, + { + "id": "th_story", + "name": "Story", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/story", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/story/", + "description": "Story" + }, + { + "id": "th_vlava", + "name": "Vlava", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/vlava", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/vlava/", + "description": "Vlava" + }, + { + "id": "th_mortize", + "name": "Mortize", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/mortize", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/mortize/", + "description": "Mortize" + }, + { + "id": "th_iconic", + "name": "Iconic", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/iconic", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/iconic/", + "description": "Iconic" + }, + { + "id": "th_evie1", + "name": "Evie1", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/evie1", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/evie1/", + "description": "Evie1" + }, + { + "id": "th_negotiate", + "name": "Negotiate", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/negotiate", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/negotiate/", + "description": "Negotiate" + }, + { + "id": "th_casinal", + "name": "Casinal", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Casinal", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Casinal/", + "description": "Free website template" + }, + { + "id": "th_capiclean", + "name": "Capiclean", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Capiclean", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Capiclean/", + "description": "Free responsive website template" + }, + { + "id": "th_meyawo", + "name": "Meyawo", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/meyawo", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/meyawo/", + "description": "Free CSS Template" + }, + { + "id": "th_klar", + "name": "Klar", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/klar", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/klar/", + "description": "Free Responsive HTML Template" + }, + { + "id": "th_revolve", + "name": "Revolve", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/revolve", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/revolve/", + "description": "Free Bootstrap Template" + }, + { + "id": "th_clickr", + "name": "Clickr", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/clickr", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/clickr/", + "description": "Clickr" + }, + { + "id": "th_ioniq", + "name": "Ioniq", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/ioniq", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/ioniq/", + "description": "Ioniq" + }, + { + "id": "th_risotto", + "name": "Risotto", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/risotto", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/risotto/", + "description": "Risotto" + }, + { + "id": "th_ca", + "name": "Ca", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/CA", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/CA/", + "description": "Ca" + }, + { + "id": "th_roundy", + "name": "Roundy", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/roundy", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/roundy/", + "description": "Roundy" + }, + { + "id": "th_landwind", + "name": "Landwind", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/landwind", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/landwind/", + "description": "Landwind" + }, + { + "id": "th_farmfresh", + "name": "Farmfresh", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/farmfresh", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/farmfresh/", + "description": "Farmfresh" + }, + { + "id": "th_kindheart", + "name": "Kindheart", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/KindHeart", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/KindHeart/", + "description": "Kindheart" + }, + { + "id": "th_archiark", + "name": "Archiark", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/archiark", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/archiark/", + "description": "Archiark" + }, + { + "id": "th_podtalk", + "name": "Podtalk", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/PodTalk", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/PodTalk/", + "description": "Podtalk" + }, + { + "id": "th_festavalive", + "name": "Festavalive", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/FestavaLive", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/FestavaLive/", + "description": "Festavalive" + }, + { + "id": "th_multiverse", + "name": "Multiverse", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/multiverse", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/multiverse/", + "description": "Multiverse" + }, + { + "id": "th_feane", + "name": "Feane", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/feane", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/feane/", + "description": "Feane" + }, + { + "id": "th_growmark", + "name": "Growmark", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/GrowMark", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/GrowMark/", + "description": "Growmark" + }, + { + "id": "th_cycle", + "name": "Cycle", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Cycle", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Cycle/", + "description": "Cycle" + }, + { + "id": "th_teab", + "name": "Teab", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/teab", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/teab/", + "description": "Teab" + }, + { + "id": "th_swipol", + "name": "Swipol", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/SwiPol", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/SwiPol/", + "description": "Swipol" + }, + { + "id": "th_nico", + "name": "Nico", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/nico", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/nico/", + "description": "Nico" + }, + { + "id": "th_nimo", + "name": "Nimo", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Nimo", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Nimo/", + "description": "Nimo" + }, + { + "id": "th_tnio", + "name": "Tnio", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/tnio", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/tnio/", + "description": "Tnio" + }, + { + "id": "th_birdor", + "name": "Birdor", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Birdor", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Birdor/", + "description": "Birdor" + }, + { + "id": "th_transit", + "name": "Transit", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/transit", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/transit/", + "description": "Transit" + }, + { + "id": "th_painto", + "name": "Painto", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Painto", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Painto/", + "description": "Painto" + }, + { + "id": "th_moto", + "name": "Moto", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Moto", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Moto/", + "description": "Moto" + }, + { + "id": "th_rea", + "name": "Rea", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/rea", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/rea/", + "description": "Rea" + }, + { + "id": "th_crptiam", + "name": "Crptiam", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Crptiam", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Crptiam/", + "description": "Crptiam" + }, + { + "id": "th_growing", + "name": "Growing", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/growing", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/growing/", + "description": "Growing" + }, + { + "id": "th_gariox", + "name": "Gariox", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/gariox", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/gariox/", + "description": "Gariox" + }, + { + "id": "th_talenttalk", + "name": "Talenttalk", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/TalentTalk", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/TalentTalk/", + "description": "Talenttalk" + }, + { + "id": "th_dashui", + "name": "Dashui", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/DashUI", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/DashUI/", + "description": "Dashui" + }, + { + "id": "th_maxwell", + "name": "Maxwell", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/maxwell", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/maxwell/", + "description": "Maxwell" + }, + { + "id": "th_trator", + "name": "Trator", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/trator", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/trator/", + "description": "Trator" + }, + { + "id": "th_snapx", + "name": "Snapx", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/snapx", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/snapx/", + "description": "Snapx" + }, + { + "id": "th_mexant", + "name": "Mexant", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/mexant", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/mexant/", + "description": "Mexant" + }, + { + "id": "th_pinwheel", + "name": "Pinwheel", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/pinwheel", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/pinwheel/", + "description": "Pinwheel" + }, + { + "id": "th_topiclisting", + "name": "Topiclisting", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/TopicListing", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/TopicListing/", + "description": "Topiclisting" + }, + { + "id": "th_ultras", + "name": "Ultras", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/ultras", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/ultras/", + "description": "Ultras" + }, + { + "id": "th_rent4u", + "name": "Rent4U", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Rent4u", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Rent4u/", + "description": "Rent4U" + }, + { + "id": "th_furnics", + "name": "Furnics", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/furnics", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/furnics/", + "description": "Furnics" + }, + { + "id": "th_swanky", + "name": "Swanky", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/swanky", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/swanky/", + "description": "Swanky" + }, + { + "id": "th_financing", + "name": "Financing", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/financing", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/financing/", + "description": "Financing" + }, + { + "id": "th_strategy", + "name": "Strategy", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/strategy", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/strategy/", + "description": "Strategy" + }, + { + "id": "th_invent", + "name": "Invent", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/invent", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/invent/", + "description": "Invent" + }, + { + "id": "th_vintagefur", + "name": "Vintagefur", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/vintagefur", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/vintagefur/", + "description": "Vintagefur" + }, + { + "id": "th_milina", + "name": "Milina", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/milina", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/milina/", + "description": "Milina" + }, + { + "id": "th_snap", + "name": "Snap", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/snap", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/snap/", + "description": "Snap" + }, + { + "id": "th_furni", + "name": "Furni", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/furni", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/furni/", + "description": "Furni" + }, + { + "id": "th_learner", + "name": "Learner", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/learner", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/learner/", + "description": "Learner" + }, + { + "id": "th_roofer", + "name": "Roofer", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Roofer", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Roofer/", + "description": "Roofer" + }, + { + "id": "th_labsky", + "name": "Labsky", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Labsky", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Labsky/", + "description": "Labsky" + }, + { + "id": "th_ai_html", + "name": "Ai Html", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/AI-html", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/AI-html/", + "description": "Ai Html" + }, + { + "id": "th_pestkit", + "name": "Pestkit", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/PestKit", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/PestKit/", + "description": "Pestkit" + }, + { + "id": "th_caterserv", + "name": "Caterserv", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/CaterServ", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/CaterServ/", + "description": "Caterserv" + }, + { + "id": "th_guarder", + "name": "Guarder", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/guarder", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/guarder/", + "description": "Guarder" + }, + { + "id": "th_edgecut", + "name": "Edgecut", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/EdgeCut", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/EdgeCut/", + "description": "Edgecut" + }, + { + "id": "th_esigned", + "name": "Esigned", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/esigned", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/esigned/", + "description": "Esigned" + }, + { + "id": "th_mr_mrs", + "name": "Mr Mrs", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/mr-mrs", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/mr-mrs/", + "description": "Mr Mrs" + }, + { + "id": "th_fruitables", + "name": "Fruitables", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/fruitables", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/fruitables/", + "description": "Fruitables" + }, + { + "id": "th_lighten", + "name": "Lighten", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/lighten", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/lighten/", + "description": "Lighten" + }, + { + "id": "th_monica", + "name": "Monica", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/monica", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/monica/", + "description": "Monica" + }, + { + "id": "th_augustine", + "name": "Augustine", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/augustine", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/augustine/", + "description": "Augustine" + }, + { + "id": "th_spurgeon", + "name": "Spurgeon", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/spurgeon", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/spurgeon/", + "description": "Spurgeon" + }, + { + "id": "th_wise", + "name": "Wise", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/wise", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/wise/", + "description": "Wise" + }, + { + "id": "th_roxo", + "name": "Roxo", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/roxo", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/roxo/", + "description": "Roxo" + }, + { + "id": "th_apollo", + "name": "Apollo", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/apollo", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/apollo/", + "description": "Apollo" + }, + { + "id": "th_mueller", + "name": "Mueller", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/mueller", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/mueller/", + "description": "Mueller" + }, + { + "id": "th_terapia", + "name": "Terapia", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/terapia", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/terapia/", + "description": "Terapia" + }, + { + "id": "th_aranyak", + "name": "Aranyak", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/aranyak", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/aranyak/", + "description": "Aranyak" + }, + { + "id": "th_brainwave_io", + "name": "Brainwave Io", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/brainwave-io", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/brainwave-io/", + "description": "Brainwave Io" + }, + { + "id": "th_uoni", + "name": "Uoni", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Uoni", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Uoni/", + "description": "Uoni" + }, + { + "id": "th_mantis", + "name": "Mantis", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/mantis", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/mantis/", + "description": "Mantis" + }, + { + "id": "th_environs", + "name": "Environs", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/environs", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/environs/", + "description": "Free Nature Website Template" + }, + { + "id": "th_mui_boilerplate", + "name": "Mui Boilerplate", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/mui-boilerplate", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/mui-boilerplate/", + "description": "Mui Boilerplate" + }, + { + "id": "th_motiv", + "name": "Motiv", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/motiv", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/motiv/", + "description": "Motiv" + }, + { + "id": "th_nextjs_material_kit", + "name": "Nextjs Material Kit", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/NextJS-Material-Kit", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/NextJS-Material-Kit/", + "description": "Nextjs Material Kit" + }, + { + "id": "th_elegent", + "name": "Elegent", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/elegent", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/elegent/", + "description": "Elegent" + }, + { + "id": "th_slim_free_react_mui_template", + "name": "Slim Free React Mui Template", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/slim-free-react-mui-template", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/slim-free-react-mui-template/", + "description": "🚀⚡️Modern and clean react mui Template for easing and faster web development.💻" + }, + { + "id": "th_inception", + "name": "Inception", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/inception", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/inception/", + "description": "This project uses react to consume GitHub APIs and render some features. Its has authoral components, but uses MaterialUiReact components too" + }, + { + "id": "th_nickelfox", + "name": "Nickelfox", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/nickelfox", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/nickelfox/", + "description": "Nickelfox" + }, + { + "id": "th_bankdash", + "name": "Bankdash", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/bankdash", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/bankdash/", + "description": "Bankdash" + }, + { + "id": "th_dabang", + "name": "Dabang", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/dabang", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/dabang/", + "description": "Dabang" + }, + { + "id": "th_dnx", + "name": "Dnx", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/dnx", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/dnx/", + "description": "Dnx" + }, + { + "id": "th_accessories", + "name": " Accessories ", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/-Accessories-", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/-Accessories-/", + "description": "Free eCom Website Template" + }, + { + "id": "th_cental", + "name": "Cental", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Cental", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Cental/", + "description": "Cental" + }, + { + "id": "th_venus", + "name": "Venus", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/venus", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/venus/", + "description": "Venus" + }, + { + "id": "th_horizon", + "name": "Horizon", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/horizon", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/horizon/", + "description": "Horizon" + }, + { + "id": "th_minimal", + "name": "Minimal", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/minimal", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/minimal/", + "description": "Minimal" + }, + { + "id": "th_waggy", + "name": "Waggy", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/waggy", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/waggy/", + "description": "Free HTML eCom Website Template" + }, + { + "id": "th_amanda", + "name": "Amanda", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/amanda", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/amanda/", + "description": "Amanda" + }, + { + "id": "th_booth", + "name": "Booth", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Booth", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Booth/", + "description": "Booth" + }, + { + "id": "th_pensio", + "name": "Pensio", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/pensio", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/pensio/", + "description": "Free HTML Pricing Plan Template" + }, + { + "id": "th_luther", + "name": "Luther", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/luther", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/luther/", + "description": "Free Potfolio Website template" + }, + { + "id": "th_base", + "name": "Base", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/base", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/base/", + "description": "Base" + }, + { + "id": "th_freshen", + "name": "Freshen", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Freshen", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Freshen/", + "description": "Free Bootstrap 5 Laundry Website Template" + }, + { + "id": "th_medwin", + "name": "Medwin", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/MedWin", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/MedWin/", + "description": "Free Website Template" + }, + { + "id": "th_modol", + "name": "Modol", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/modol", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/modol/", + "description": "Modol" + }, + { + "id": "th_quickstart", + "name": "Quickstart", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/QuickStart", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/QuickStart/", + "description": "Free Bootstrap Website Template" + }, + { + "id": "th_agriculture", + "name": "Agriculture", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/AgriCulture", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/AgriCulture/", + "description": "Free Bootstrap 5 Website Template" + }, + { + "id": "th_spike_vue_free", + "name": "Spike Vue Free", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/spike-vue-free", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/spike-vue-free/", + "description": "Spike Vue Free" + }, + { + "id": "th_gp", + "name": "Gp", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/gp", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/gp/", + "description": "Free Bootstrap 5 Multipurpose Website Template" + }, + { + "id": "th_herobiz", + "name": "Herobiz", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/HeroBiz", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/HeroBiz/", + "description": "Free Bootstrap 5 Website Template" + }, + { + "id": "th_yummy_red", + "name": "Yummy Red", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/yummy-red", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/yummy-red/", + "description": "Free Bootstrap 5 Website Template" + }, + { + "id": "th_logis_new", + "name": "Logis New", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/logis-new", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/logis-new/", + "description": "Free Bootstrap 5 Tranportation Website template" + }, + { + "id": "th_flexstart", + "name": "Flexstart", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/FlexStart", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/FlexStart/", + "description": "Free bootstrap 5 Website Template" + }, + { + "id": "th_kelly", + "name": "Kelly", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Kelly", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Kelly/", + "description": "Free Bootstrap 5 Website template" + }, + { + "id": "th_sailor", + "name": "Sailor", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Sailor", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Sailor/", + "description": "Free Bootstrap 5 Website Template" + }, + { + "id": "th_knightone", + "name": "Knightone", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/KnightOne", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/KnightOne/", + "description": "Free Bootstrap 5 Website Template" + }, + { + "id": "th_enno", + "name": "Enno", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/eNno", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/eNno/", + "description": "Free Bootstrap 5 Website Template" + }, + { + "id": "th_dewi", + "name": "Dewi", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Dewi", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Dewi/", + "description": "Free Bootstrap 5 Website Template" + }, + { + "id": "th_spike_nuxtjs_free", + "name": "Spike Nuxtjs Free", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/spike-nuxtjs-free", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/spike-nuxtjs-free/", + "description": "Spike Nuxtjs Free" + }, + { + "id": "th_prefix", + "name": "Prefix", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Prefix", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Prefix/", + "description": "Free eCom Website Template" + }, + { + "id": "th_materialpro_nextjs_free", + "name": "Materialpro Nextjs Free", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/materialpro-nextjs-free", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/materialpro-nextjs-free/", + "description": "https://themewagon.github.io/materialpro-nextjs-free/" + }, + { + "id": "th_chefs_kitchen_nextjs_free", + "name": "Chefs Kitchen Nextjs Free", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/chefs-kitchen-nextjs-free", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/chefs-kitchen-nextjs-free/", + "description": "Chefs Kitchen Nextjs Free" + }, + { + "id": "th_hielo", + "name": "Hielo", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Hielo", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Hielo/", + "description": "Hielo" + }, + { + "id": "th_berry_mui", + "name": "Berry Mui", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Berry-MUI", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Berry-MUI/", + "description": "Berry Mui" + }, + { + "id": "th_intensify", + "name": "Intensify", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Intensify", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Intensify/", + "description": "Intensify" + }, + { + "id": "th_spike_bootstrap", + "name": "Spike Bootstrap", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Spike-Bootstrap", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Spike-Bootstrap/", + "description": "Spike Bootstrap" + }, + { + "id": "th_binary", + "name": "Binary", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Binary", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Binary/", + "description": "Free HTML5 & CSS3 Template" + }, + { + "id": "th_regal", + "name": "Regal", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/regal", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/regal/", + "description": "Regal" + }, + { + "id": "th_matdash", + "name": "Matdash", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/MatDash", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/MatDash/", + "description": "Matdash" + }, + { + "id": "th_modernize_mui", + "name": "Modernize Mui", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Modernize-MUI", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Modernize-MUI/", + "description": "Modernize Mui" + }, + { + "id": "th_amoeba", + "name": "Amoeba", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/amoeba", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/amoeba/", + "description": "Amoeba" + }, + { + "id": "th_folio", + "name": "Folio", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/folio", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/folio/", + "description": "Folio" + }, + { + "id": "th_picto", + "name": "Picto", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/picto", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/picto/", + "description": "Developed by ThemeWagon" + }, + { + "id": "th_iridium", + "name": "Iridium", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Iridium", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Iridium/", + "description": "Iridium" + }, + { + "id": "th_materio", + "name": "Materio", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/materio", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/materio/", + "description": "Materio" + }, + { + "id": "th_phaseshift", + "name": "Phaseshift", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/PhaseShift", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/PhaseShift/", + "description": "Phaseshift" + }, + { + "id": "th_retrospect", + "name": "Retrospect", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Retrospect", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Retrospect/", + "description": "Retrospect" + }, + { + "id": "th_chefer", + "name": "Chefer", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Chefer", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Chefer/", + "description": "Chefer" + }, + { + "id": "th_koppee", + "name": "Koppee", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Koppee", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Koppee/", + "description": "Koppee" + }, + { + "id": "th_edukate", + "name": "Edukate", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Edukate", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Edukate/", + "description": "Edukate" + }, + { + "id": "th_broadcast", + "name": "Broadcast", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Broadcast", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Broadcast/", + "description": "Broadcast" + }, + { + "id": "th_velora", + "name": "Velora", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Velora", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Velora/", + "description": "Velora" + }, + { + "id": "th_weldork", + "name": "Weldork", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Weldork", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Weldork/", + "description": "Weldork" + }, + { + "id": "th_velora_vue", + "name": "Velora Vue", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Velora-vue", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Velora-vue/", + "description": "Velora Vue" + }, + { + "id": "th_spike_tailwind", + "name": "Spike Tailwind", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/spike-tailwind", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/spike-tailwind/", + "description": "Spike Tailwind" + }, + { + "id": "th_dattaable", + "name": "Dattaable", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/DattaAble", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/DattaAble/", + "description": "Dattaable" + }, + { + "id": "th_tailwind_starter_kit", + "name": "Tailwind Starter Kit", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/tailwind-starter-kit", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/tailwind-starter-kit/", + "description": "Tailwind Starter Kit" + }, + { + "id": "th_crypgo", + "name": "Crypgo", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Crypgo", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Crypgo/", + "description": "Crypgo" + }, + { + "id": "th_nova_bootstrap", + "name": "Nova Bootstrap", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Nova-Bootstrap", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Nova-Bootstrap/", + "description": "Nova Bootstrap" + }, + { + "id": "th_netic", + "name": "Netic", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Netic", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Netic/", + "description": "Netic" + }, + { + "id": "th_oberlo", + "name": "Oberlo", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Oberlo", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Oberlo/", + "description": "Oberlo" + }, + { + "id": "th_lounge", + "name": "Lounge", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Lounge", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Lounge/", + "description": "Lounge" + }, + { + "id": "th_notus_react", + "name": "Notus React", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Notus-React", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Notus-React/", + "description": "Notus React" + }, + { + "id": "th_notus_next_js", + "name": "Notus Next.Js", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Notus-Next.js", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Notus-Next.js/", + "description": "Notus Next.Js" + }, + { + "id": "th_berry_vue", + "name": "Berry Vue", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/berry-vue", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/berry-vue/", + "description": "Berry Vue" + }, + { + "id": "th_mantis_vue", + "name": "Mantis Vue", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Mantis-Vue", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Mantis-Vue/", + "description": "Mantis Vue" + }, + { + "id": "th_electro_bootstrap", + "name": "Electro Bootstrap", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Electro-Bootstrap", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Electro-Bootstrap/", + "description": "Electro Bootstrap" + }, + { + "id": "th_windster", + "name": "Windster", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/windster", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/windster/", + "description": "Windster" + }, + { + "id": "th_nextly", + "name": "Nextly", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/nextly", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/nextly/", + "description": "Nextly" + }, + { + "id": "th_dasher_ui", + "name": "Dasher Ui", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/dasher-ui", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/dasher-ui/", + "description": "Dasher Ui" + }, + { + "id": "th_bundle_v2", + "name": "Bundle V2", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/bundle-v2", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/bundle-v2/", + "description": "Bundle V2" + }, + { + "id": "th_pulse_crm", + "name": "Pulse Crm", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/pulse-crm", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/pulse-crm/", + "description": "Pulse Crm" + }, + { + "id": "th_polk", + "name": "Polk", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Polk", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Polk/", + "description": "Polk" + }, + { + "id": "th_volt_bootstrap", + "name": "Volt Bootstrap", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/volt-Bootstrap", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/volt-Bootstrap/", + "description": "Volt Bootstrap" + }, + { + "id": "th_cryptoflow", + "name": "Cryptoflow", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/cryptoflow", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/cryptoflow/", + "description": "Cryptoflow" + }, + { + "id": "th_charitize", + "name": "Charitize", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Charitize", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Charitize/", + "description": "Charitize" + }, + { + "id": "th_hostpro", + "name": "Hostpro", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/hostpro", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/hostpro/", + "description": "Hostpro" + }, + { + "id": "th_avision", + "name": "Avision", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/avision", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/avision/", + "description": "Avision" + }, + { + "id": "th_base_tailwind", + "name": "Base Tailwind", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Base-Tailwind", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Base-Tailwind/", + "description": "Base Tailwind" + }, + { + "id": "th_play_tailwind", + "name": "Play Tailwind", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/play-tailwind", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/play-tailwind/", + "description": "Play Tailwind" + }, + { + "id": "th_faunaflora", + "name": "Faunaflora", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/FaunaFlora", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/FaunaFlora/", + "description": "Faunaflora" + }, + { + "id": "th_crypto_nextjs", + "name": "Crypto Nextjs", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/crypto-nextjs", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/crypto-nextjs/", + "description": "Crypto Nextjs" + }, + { + "id": "th_dsign", + "name": "Dsign", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/dSign", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/dSign/", + "description": "Dsign" + }, + { + "id": "th_bliss", + "name": "Bliss", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Bliss", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Bliss/", + "description": "Bliss" + }, + { + "id": "th_nova_bootstrap5_beta1", + "name": "Nova Bootstrap5 Beta1", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Nova-Bootstrap5_beta1", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Nova-Bootstrap5_beta1/", + "description": "Nova Bootstrap5 Beta1" + }, + { + "id": "th_flat", + "name": "Flat", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Flat", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Flat/", + "description": "Flat" + }, + { + "id": "th_venus_nextjs", + "name": "Venus Nextjs", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/venus-nextjs", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/venus-nextjs/", + "description": "Venus Free Next.js Website Template" + }, + { + "id": "th_plasery", + "name": "Plasery", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Plasery", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Plasery/", + "description": "Plasery" + }, + { + "id": "th_poseify", + "name": "Poseify", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Poseify", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Poseify/", + "description": "Poseify" + }, + { + "id": "th_sustainable_nextjs", + "name": "Sustainable Nextjs", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/sustainable-nextjs", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/sustainable-nextjs/", + "description": "Sustainable Nextjs" + }, + { + "id": "th_muvid", + "name": "Muvid", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Muvid", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Muvid/", + "description": "Muvid" + }, + { + "id": "th_advanced", + "name": "Advanced", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/advanced", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/advanced/", + "description": "Advanced" + }, + { + "id": "th_neutral_new", + "name": "Neutral New", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/neutral-new", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/neutral-new/", + "description": "Neutral New" + }, + { + "id": "th_symposium_nextjs", + "name": "Symposium Nextjs", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/symposium-nextjs", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/symposium-nextjs/", + "description": "symposium-nextjs" + }, + { + "id": "th_medinova", + "name": "Medinova", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Medinova", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Medinova/", + "description": "Medinova" + }, + { + "id": "th_pixelize", + "name": "Pixelize", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Pixelize", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Pixelize/", + "description": "Pixelize" + }, + { + "id": "th_geeky_nextjs", + "name": "Geeky Nextjs", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/geeky-nextjs", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/geeky-nextjs/", + "description": "Geeky Nextjs" + }, + { + "id": "th_quantam", + "name": "Quantam", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Quantam", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Quantam/", + "description": "Quantam" + }, + { + "id": "th_globalbank", + "name": "Globalbank", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/GlobalBank", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/GlobalBank/", + "description": "Globalbank" + }, + { + "id": "th_docsta", + "name": "Docsta", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/docsta", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/docsta/", + "description": "Docsta" + }, + { + "id": "th_typefolio", + "name": "Typefolio", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Typefolio", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Typefolio/", + "description": "Typefolio - NextJs Template" + }, + { + "id": "th_tiyagolfclub", + "name": "Tiyagolfclub", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/TiyaGolfClub", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/TiyaGolfClub/", + "description": "Tiyagolfclub" + }, + { + "id": "th_acidus", + "name": "Acidus", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/acidus", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/acidus/", + "description": "Initial commit" + }, + { + "id": "th_impulse", + "name": "Impulse", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/impulse", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/impulse/", + "description": "Impulse" + }, + { + "id": "th_dentista", + "name": "Dentista", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/dentista", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/dentista/", + "description": "Dentista" + }, + { + "id": "th_cleopatra_tailwind", + "name": "Cleopatra Tailwind", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/cleopatra-tailwind", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/cleopatra-tailwind/", + "description": "Cleopatra Tailwind" + }, + { + "id": "th_nextplate_nextjs", + "name": "Nextplate Nextjs", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/nextplate-nextjs", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/nextplate-nextjs/", + "description": "Nextplate - Nextjs Boilerplate" + }, + { + "id": "th_material_shadcn", + "name": "Material Shadcn", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/material-shadcn", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/material-shadcn/", + "description": "Material Shadcn" + }, + { + "id": "th_volcan", + "name": "Volcan", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Volcan", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Volcan/", + "description": "Volcan" + }, + { + "id": "th_atlas_v2_0_0", + "name": "Atlas V2.0.0", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/atlas-v2.0.0", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/atlas-v2.0.0/", + "description": "Atlas V2.0.0" + }, + { + "id": "th_atom", + "name": "Atom", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/atom", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/atom/", + "description": "Atom" + }, + { + "id": "th_agentix_html", + "name": "Agentix Html", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/Agentix-html", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/Agentix-html/", + "description": "Agentix Html" + }, + { + "id": "th_tailwind_bundle", + "name": "Tailwind Bundle", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/tailwind-bundle", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/tailwind-bundle/", + "description": "50 Tailwind CSS website templates" + }, + { + "id": "th_argon_design_system_angular", + "name": "Argon Design System Angular", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/argon-design-system-angular", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/argon-design-system-angular/", + "description": "Argon Design System Angular" + }, + { + "id": "th_block", + "name": "Block", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/block", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/block/", + "description": "Block – Tailwind CSS HTML Template Free" + }, + { + "id": "th_daiva", + "name": "Daiva", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/daiva", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/daiva/", + "description": "Daiva - 6 pages tailwind template" + }, + { + "id": "th_furnish", + "name": "Furnish", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/furnish", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/furnish/", + "description": "Furnish Free Bootstrap 5 Furniture Website Template" + }, + { + "id": "th_coach", + "name": "Coach", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/coach", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/coach/", + "description": "Coach" + }, + { + "id": "th_vaultedge", + "name": "Vaultedge", + "source": "themewagon", + "repo_url": "https://github.com/themewagon/vaultedge", + "sparse_path": ".", + "preview_url": "https://themewagon.github.io/vaultedge/", + "description": "VaultEdge – Financial & Loan Services Website Template" + }, + { + "id": "da_includes", + "name": " Includes", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "_includes", + "preview_url": "https://dawidolko.github.io/Website-Templates/_includes/", + "description": " Includes" + }, + { + "id": "da_layouts", + "name": " Layouts", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "_layouts", + "preview_url": "https://dawidolko.github.io/Website-Templates/_layouts/", + "description": " Layouts" + }, + { + "id": "da_ace_responsive_coming_soon_template", + "name": "Ace Responsive Coming Soon Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "ace-responsive-coming-soon-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/ace-responsive-coming-soon-template/", + "description": "Ace Responsive Coming Soon Template" + }, + { + "id": "da_aerosky_real_estate_html_responsive_website_template", + "name": "Aerosky Real Estate Html Responsive Website Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "aerosky-real-estate-html-responsive-website-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/aerosky-real-estate-html-responsive-website-template/", + "description": "Aerosky Real Estate Html Responsive Website Template" + }, + { + "id": "da_alive_responsive_coming_soon_template", + "name": "Alive Responsive Coming Soon Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "alive-responsive-coming-soon-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/alive-responsive-coming-soon-template/", + "description": "Alive Responsive Coming Soon Template" + }, + { + "id": "da_avenger_multi_purpose_responsive_html5_bootstrap_template", + "name": "Avenger Multi Purpose Responsive Html5 Bootstrap Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "avenger-multi-purpose-responsive-html5-bootstrap-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/avenger-multi-purpose-responsive-html5-bootstrap-template/", + "description": "Avenger Multi Purpose Responsive Html5 Bootstrap Template" + }, + { + "id": "da_basic_free_html5_template_for_multi_purpose", + "name": "Basic Free Html5 Template For Multi Purpose", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "basic-free-html5-template-for-multi-purpose", + "preview_url": "https://dawidolko.github.io/Website-Templates/basic-free-html5-template-for-multi-purpose/", + "description": "Basic Free Html5 Template For Multi Purpose" + }, + { + "id": "da_blazer_responsive_html5_coming_soon_template", + "name": "Blazer Responsive Html5 Coming Soon Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "blazer-responsive-html5-coming-soon-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/blazer-responsive-html5-coming-soon-template/", + "description": "Blazer Responsive Html5 Coming Soon Template" + }, + { + "id": "da_city_square_bootstrap_responsive_web_template", + "name": "City Square Bootstrap Responsive Web Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "city-square-bootstrap-responsive-web-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/city-square-bootstrap-responsive-web-template/", + "description": "City Square Bootstrap Responsive Web Template" + }, + { + "id": "da_coming_soon_responsive_theme_jack", + "name": "Coming Soon Responsive Theme Jack", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "coming-soon-responsive-theme-jack", + "preview_url": "https://dawidolko.github.io/Website-Templates/coming-soon-responsive-theme-jack/", + "description": "Coming Soon Responsive Theme Jack" + }, + { + "id": "da_css3_bw", + "name": "Css3 Bw", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "css3-bw", + "preview_url": "https://dawidolko.github.io/Website-Templates/css3-bw/", + "description": "Css3 Bw" + }, + { + "id": "da_css3_drop_shadows", + "name": "Css3 Drop Shadows", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "css3-drop-shadows", + "preview_url": "https://dawidolko.github.io/Website-Templates/css3-drop-shadows/", + "description": "Css3 Drop Shadows" + }, + { + "id": "da_css3_seascape_two", + "name": "Css3 Seascape Two", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "css3-seascape-two", + "preview_url": "https://dawidolko.github.io/Website-Templates/css3-seascape-two/", + "description": "Css3 Seascape Two" + }, + { + "id": "da_css3_seascape", + "name": "Css3 Seascape", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "css3-seascape", + "preview_url": "https://dawidolko.github.io/Website-Templates/css3-seascape/", + "description": "Css3 Seascape" + }, + { + "id": "da_delight_multi_purpose_free_html5_website_template", + "name": "Delight Multi Purpose Free Html5 Website Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "delight-multi-purpose-free-html5-website-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/delight-multi-purpose-free-html5-website-template/", + "description": "Delight Multi Purpose Free Html5 Website Template" + }, + { + "id": "da_dreamy", + "name": "Dreamy", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "dreamy", + "preview_url": "https://dawidolko.github.io/Website-Templates/dreamy/", + "description": "Dreamy" + }, + { + "id": "da_drifting", + "name": "Drifting", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "drifting", + "preview_url": "https://dawidolko.github.io/Website-Templates/drifting/", + "description": "Drifting" + }, + { + "id": "da_droll", + "name": "Droll", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "droll", + "preview_url": "https://dawidolko.github.io/Website-Templates/droll/", + "description": "Droll" + }, + { + "id": "da_elegant_free_multi_purpose_bootstrap_responsive_template", + "name": "Elegant Free Multi Purpose Bootstrap Responsive Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "elegant-free-multi-purpose-bootstrap-responsive-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/elegant-free-multi-purpose-bootstrap-responsive-template/", + "description": "Elegant Free Multi Purpose Bootstrap Responsive Template" + }, + { + "id": "da_endure_html5_responsive_coming_soon_template", + "name": "Endure Html5 Responsive Coming Soon Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "endure-html5-responsive-coming-soon-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/endure-html5-responsive-coming-soon-template/", + "description": "Endure Html5 Responsive Coming Soon Template" + }, + { + "id": "da_extent", + "name": "Extent", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "extent", + "preview_url": "https://dawidolko.github.io/Website-Templates/extent/", + "description": "Extent" + }, + { + "id": "da_free_bootstrap_template_for_multi_purpose_ladder", + "name": "Free Bootstrap Template For Multi Purpose Ladder", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "free-bootstrap-template-for-multi-purpose-ladder", + "preview_url": "https://dawidolko.github.io/Website-Templates/free-bootstrap-template-for-multi-purpose-ladder/", + "description": "Free Bootstrap Template For Multi Purpose Ladder" + }, + { + "id": "da_free_bootstrap_template_real_estate_my_home", + "name": "Free Bootstrap Template Real Estate My Home", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "free-bootstrap-template-real-estate-my-home", + "preview_url": "https://dawidolko.github.io/Website-Templates/free-bootstrap-template-real-estate-my-home/", + "description": "Free Bootstrap Template Real Estate My Home" + }, + { + "id": "da_full_slider", + "name": "Full Slider", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "full-slider", + "preview_url": "https://dawidolko.github.io/Website-Templates/full-slider/", + "description": "Full Slider" + }, + { + "id": "da_funky_cool_blue", + "name": "Funky Cool Blue", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "funky-cool-blue", + "preview_url": "https://dawidolko.github.io/Website-Templates/funky-cool-blue/", + "description": "Funky Cool Blue" + }, + { + "id": "da_gila", + "name": "Gila", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "gila", + "preview_url": "https://dawidolko.github.io/Website-Templates/gila/", + "description": "Gila" + }, + { + "id": "da_glips_responsive_free_coming_soon_bootstrap_template", + "name": "Glips Responsive Free Coming Soon Bootstrap Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "glips-responsive-free-coming-soon-bootstrap-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/glips-responsive-free-coming-soon-bootstrap-template/", + "description": "Glips Responsive Free Coming Soon Bootstrap Template" + }, + { + "id": "da_grand_free_bootstrap_responsive_website_template", + "name": "Grand Free Bootstrap Responsive Website Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "grand-free-bootstrap-responsive-website-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/grand-free-bootstrap-responsive-website-template/", + "description": "Grand Free Bootstrap Responsive Website Template" + }, + { + "id": "da_grandure_bootstrap_free_coming_soon_template", + "name": "Grandure Bootstrap Free Coming Soon Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "grandure-bootstrap-free-coming-soon-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/grandure-bootstrap-free-coming-soon-template/", + "description": "Grandure Bootstrap Free Coming Soon Template" + }, + { + "id": "da_grass_stains", + "name": "Grass Stains", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "grass-stains", + "preview_url": "https://dawidolko.github.io/Website-Templates/grass-stains/", + "description": "Grass Stains" + }, + { + "id": "da_green_corp_flat_free_responsive_mobile_website", + "name": "Green Corp Flat Free Responsive Mobile Website", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "green-corp-flat-free-responsive-mobile-website", + "preview_url": "https://dawidolko.github.io/Website-Templates/green-corp-flat-free-responsive-mobile-website/", + "description": "Green Corp Flat Free Responsive Mobile Website" + }, + { + "id": "da_greenery", + "name": "Greenery", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "greenery", + "preview_url": "https://dawidolko.github.io/Website-Templates/greenery/", + "description": "Greenery" + }, + { + "id": "da_gunmetal_portal", + "name": "Gunmetal Portal", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "gunmetal-portal", + "preview_url": "https://dawidolko.github.io/Website-Templates/gunmetal-portal/", + "description": "Gunmetal Portal" + }, + { + "id": "da_half_slider", + "name": "Half Slider", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "half-slider", + "preview_url": "https://dawidolko.github.io/Website-Templates/half-slider/", + "description": "Half Slider" + }, + { + "id": "da_html5_responsive_coming_soon_page", + "name": "Html5 Responsive Coming Soon Page", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "html5-responsive-coming-soon-page", + "preview_url": "https://dawidolko.github.io/Website-Templates/html5-responsive-coming-soon-page/", + "description": "Html5 Responsive Coming Soon Page" + }, + { + "id": "da_icon_real_estate_developers_free_responsive_html_template", + "name": "Icon Real Estate Developers Free Responsive Html Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "icon-real-estate-developers-free-responsive-html-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/icon-real-estate-developers-free-responsive-html-template/", + "description": "Icon Real Estate Developers Free Responsive Html Template" + }, + { + "id": "da_indus_free_coming_soon_bootstrap_responsive_template", + "name": "Indus Free Coming Soon Bootstrap Responsive Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "indus-free-coming-soon-bootstrap-responsive-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/indus-free-coming-soon-bootstrap-responsive-template/", + "description": "Indus Free Coming Soon Bootstrap Responsive Template" + }, + { + "id": "da_interio", + "name": "Interio", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "interio", + "preview_url": "https://dawidolko.github.io/Website-Templates/interio/", + "description": "Interio" + }, + { + "id": "da_internet_portal", + "name": "Internet Portal", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "internet-portal", + "preview_url": "https://dawidolko.github.io/Website-Templates/internet-portal/", + "description": "Internet Portal" + }, + { + "id": "da_lazydays", + "name": "Lazydays", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "lazydays", + "preview_url": "https://dawidolko.github.io/Website-Templates/lazydays/", + "description": "Lazydays" + }, + { + "id": "da_light_coming_soon_html_responsive_template", + "name": "Light Coming Soon Html Responsive Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "light-coming-soon-html-responsive-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/light-coming-soon-html-responsive-template/", + "description": "Light Coming Soon Html Responsive Template" + }, + { + "id": "da_metropolis", + "name": "Metropolis", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "metropolis", + "preview_url": "https://dawidolko.github.io/Website-Templates/metropolis/", + "description": "Metropolis" + }, + { + "id": "da_midway_free_html5_website_template_for_multi_purpose", + "name": "Midway Free Html5 Website Template For Multi Purpose", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "midway-free-html5-website-template-for-multi-purpose", + "preview_url": "https://dawidolko.github.io/Website-Templates/midway-free-html5-website-template-for-multi-purpose/", + "description": "Midway Free Html5 Website Template For Multi Purpose" + }, + { + "id": "da_missunderstood", + "name": "Missunderstood", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "missunderstood", + "preview_url": "https://dawidolko.github.io/Website-Templates/missunderstood/", + "description": "Missunderstood" + }, + { + "id": "da_moon_free_bootstrap_coming_soon_template", + "name": "Moon Free Bootstrap Coming Soon Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "moon-free-bootstrap-coming-soon-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/moon-free-bootstrap-coming-soon-template/", + "description": "Moon Free Bootstrap Coming Soon Template" + }, + { + "id": "da_next_responsive_coming_soon_bootstrap_template", + "name": "Next Responsive Coming Soon Bootstrap Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "next-responsive-coming-soon-bootstrap-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/next-responsive-coming-soon-bootstrap-template/", + "description": "Next Responsive Coming Soon Bootstrap Template" + }, + { + "id": "da_orange_coming_soon_html_responsive_template", + "name": "Orange Coming Soon Html Responsive Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "orange-coming-soon-html-responsive-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/orange-coming-soon-html-responsive-template/", + "description": "Orange Coming Soon Html Responsive Template" + }, + { + "id": "da_park_city_bootstrap_html_real_estate_responsive_template", + "name": "Park City Bootstrap Html Real Estate Responsive Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "park-city-bootstrap-html-real-estate-responsive-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/park-city-bootstrap-html-real-estate-responsive-template/", + "description": "Park City Bootstrap Html Real Estate Responsive Template" + }, + { + "id": "da_plain", + "name": "Plain", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "plain", + "preview_url": "https://dawidolko.github.io/Website-Templates/plain/", + "description": "Plain" + }, + { + "id": "da_prosimii", + "name": "Prosimii", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "prosimii", + "preview_url": "https://dawidolko.github.io/Website-Templates/prosimii/", + "description": "Prosimii" + }, + { + "id": "da_relic_portal", + "name": "Relic Portal", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "relic-portal", + "preview_url": "https://dawidolko.github.io/Website-Templates/relic-portal/", + "description": "Relic Portal" + }, + { + "id": "da_reveal", + "name": "Reveal", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "reveal", + "preview_url": "https://dawidolko.github.io/Website-Templates/reveal/", + "description": "Reveal" + }, + { + "id": "da_rider_free_multi_purpose_bootstrap_template", + "name": "Rider Free Multi Purpose Bootstrap Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "rider-free-multi-purpose-bootstrap-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/rider-free-multi-purpose-bootstrap-template/", + "description": "Rider Free Multi Purpose Bootstrap Template" + }, + { + "id": "da_sample_site", + "name": "Sample Site", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "sample_site", + "preview_url": "https://dawidolko.github.io/Website-Templates/sample_site/", + "description": "Sample Site" + }, + { + "id": "da_simply_bootstrap_coming_soon_template", + "name": "Simply Bootstrap Coming Soon Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "simply-bootstrap-coming-soon-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/simply-bootstrap-coming-soon-template/", + "description": "Simply Bootstrap Coming Soon Template" + }, + { + "id": "da_sinorca", + "name": "Sinorca", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "sinorca", + "preview_url": "https://dawidolko.github.io/Website-Templates/sinorca/", + "description": "Sinorca" + }, + { + "id": "da_startbootstrap_grayscale_1_0_3", + "name": "Grayscale 1.0.3", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "startbootstrap-grayscale-1.0.3", + "preview_url": "https://dawidolko.github.io/Website-Templates/startbootstrap-grayscale-1.0.3/", + "description": "Grayscale 1.0.3" + }, + { + "id": "da_street_life", + "name": "Street Life", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "street-life", + "preview_url": "https://dawidolko.github.io/Website-Templates/street-life/", + "description": "Street Life" + }, + { + "id": "da_stylish_bootstrap_coming_soon_template", + "name": "Stylish Bootstrap Coming Soon Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "stylish-bootstrap-coming-soon-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/stylish-bootstrap-coming-soon-template/", + "description": "Stylish Bootstrap Coming Soon Template" + }, + { + "id": "da_target_multipurpose_free_bootstrap_responsive_template", + "name": "Target Multipurpose Free Bootstrap Responsive Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "target-multipurpose-free-bootstrap-responsive-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/target-multipurpose-free-bootstrap-responsive-template/", + "description": "Target Multipurpose Free Bootstrap Responsive Template" + }, + { + "id": "da_theme_changer_template", + "name": "Theme Changer Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "theme-changer-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/theme-changer-template/", + "description": "Theme Changer Template" + }, + { + "id": "da_themer_bootstrap_responsive_web_template", + "name": "Themer Bootstrap Responsive Web Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "themer-bootstrap-responsive-web-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/themer-bootstrap-responsive-web-template/", + "description": "Themer Bootstrap Responsive Web Template" + }, + { + "id": "da_thin_green_line", + "name": "Thin Green Line", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "thin-green-line", + "preview_url": "https://dawidolko.github.io/Website-Templates/thin-green-line/", + "description": "Thin Green Line" + }, + { + "id": "da_trendset_coming_soon_responsive_theme", + "name": "Trendset Coming Soon Responsive Theme", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "trendset-coming-soon-responsive-theme", + "preview_url": "https://dawidolko.github.io/Website-Templates/trendset-coming-soon-responsive-theme/", + "description": "Trendset Coming Soon Responsive Theme" + }, + { + "id": "da_trendy_free_bootstrap_responsive_website_template", + "name": "Trendy Free Bootstrap Responsive Website Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "trendy-free-bootstrap-responsive-website-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/trendy-free-bootstrap-responsive-website-template/", + "description": "Trendy Free Bootstrap Responsive Website Template" + }, + { + "id": "da_unique_free_responsive_html5_template", + "name": "Unique Free Responsive Html5 Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "unique-free-responsive-html5-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/unique-free-responsive-html5-template/", + "description": "Unique Free Responsive Html5 Template" + }, + { + "id": "da_vento_coming_soon_responsive_theme", + "name": "Vento Coming Soon Responsive Theme", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "vento-coming-soon-responsive-theme", + "preview_url": "https://dawidolko.github.io/Website-Templates/vento-coming-soon-responsive-theme/", + "description": "Vento Coming Soon Responsive Theme" + }, + { + "id": "da_viver_free_html5_bootstrap_coming_soon_template", + "name": "Viver Free Html5 Bootstrap Coming Soon Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "viver-free-html5-bootstrap-coming-soon-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/viver-free-html5-bootstrap-coming-soon-template/", + "description": "Viver Free Html5 Bootstrap Coming Soon Template" + }, + { + "id": "da_webtrends_free_bootstrap_responsive_web_template", + "name": "Webtrends Free Bootstrap Responsive Web Template", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "webtrends-free-bootstrap-responsive-web-template", + "preview_url": "https://dawidolko.github.io/Website-Templates/webtrends-free-bootstrap-responsive-web-template/", + "description": "Webtrends Free Bootstrap Responsive Web Template" + }, + { + "id": "da_zenlike", + "name": "Zenlike", + "source": "dawidolko", + "repo_url": "https://github.com/dawidolko/Website-Templates", + "sparse_path": "zenlike", + "preview_url": "https://dawidolko.github.io/Website-Templates/zenlike/", + "description": "Zenlike" + } + ] + } + ] +} \ No newline at end of file