diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 80b8654..0000000 --- a/.gitignore +++ /dev/null @@ -1,29 +0,0 @@ -# Environment -.env -*.env.local - -# Python -__pycache__/ -*.pyc -venv/ -.venv/ - -# Backups (contain secrets) -backups/ -*.tar.gz -*.tar.gz.enc -*.sha256 - -# Temp -/tmp/ -*.tmp -*.swp - -# IDE -.vscode/ -.idea/ -*.code-workspace - -# OS -.DS_Store -Thumbs.db diff --git a/gotelegram-bot/bot.py b/gotelegram-bot/bot.py index 4e73940..97fadbb 100644 --- a/gotelegram-bot/bot.py +++ b/gotelegram-bot/bot.py @@ -1,18 +1,22 @@ #!/usr/bin/env python3 """ -GoTelegram v2.2 Bot - MTProxy Management for Linux +GoTelegram v2.4 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 hashlib import html import json import logging import os import re +import shutil import subprocess +import sys import time import toml from datetime import datetime @@ -32,10 +36,56 @@ from telegram.ext import ( 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() @@ -50,7 +100,7 @@ logger = logging.getLogger(__name__) # CONFIGURATION # ============================================================================ -GOTELEGRAM_VERSION = "2.3.1" +GOTELEGRAM_VERSION = "2.4.0" GOTELEGRAM_CONFIG = "/opt/gotelegram/config.json" TELEMT_CONFIG = "/etc/telemt/config.toml" TELEMT_SERVICE = "telemt" @@ -326,43 +376,44 @@ async def require_auth(update: Update, context: ContextTypes.DEFAULT_TYPE) -> bo # ============================================================================ -def get_main_menu() -> InlineKeyboardMarkup: - """Generate main menu keyboard.""" +def get_main_menu(user_id: Optional[int] = None) -> InlineKeyboardMarkup: + """Generate main menu keyboard localized for the given user.""" buttons = [ [ - InlineKeyboardButton("⚙️ Install", callback_data="menu_install"), - InlineKeyboardButton("📊 Status", callback_data="menu_status"), + InlineKeyboardButton(_t(user_id, "menu_install"), callback_data="menu_install"), + InlineKeyboardButton(_t(user_id, "menu_status"), callback_data="menu_status"), ], [ - InlineKeyboardButton("🔗 Link", callback_data="menu_link"), - InlineKeyboardButton("📤 Share", callback_data="menu_share"), + InlineKeyboardButton(_t(user_id, "menu_link"), callback_data="menu_link"), + InlineKeyboardButton(_t(user_id, "menu_share"), callback_data="menu_share"), ], [ - InlineKeyboardButton("🔄 Restart", callback_data="menu_restart"), - InlineKeyboardButton("📋 Logs", callback_data="menu_logs"), + InlineKeyboardButton(_t(user_id, "menu_restart"), callback_data="menu_restart"), + InlineKeyboardButton(_t(user_id, "menu_logs"), callback_data="menu_logs"), ], [ - InlineKeyboardButton("⚡ Change Mode/Template", callback_data="menu_change"), - InlineKeyboardButton("💾 Backup", callback_data="menu_backup"), + InlineKeyboardButton(_t(user_id, "menu_change"), callback_data="menu_change"), + InlineKeyboardButton(_t(user_id, "menu_backup"), callback_data="menu_backup"), ], [ - InlineKeyboardButton("↩️ Restore", callback_data="menu_restore"), - InlineKeyboardButton("📡 Update telemt", callback_data="menu_update"), + InlineKeyboardButton(_t(user_id, "menu_restore"), callback_data="menu_restore"), + InlineKeyboardButton(_t(user_id, "menu_update"), callback_data="menu_update"), ], [ - InlineKeyboardButton("🌐 Website/SSL", callback_data="menu_website"), - InlineKeyboardButton("🎁 Промо", callback_data="menu_promo"), + InlineKeyboardButton(_t(user_id, "menu_website"), callback_data="menu_website"), + InlineKeyboardButton(_t(user_id, "menu_promo"), callback_data="menu_promo"), ], [ - InlineKeyboardButton("📊 Traffic Stats", callback_data="menu_stats"), - InlineKeyboardButton("🗑️ Remove", callback_data="menu_remove"), + InlineKeyboardButton(_t(user_id, "menu_stats"), callback_data="menu_stats"), + InlineKeyboardButton(_t(user_id, "menu_remove"), callback_data="menu_remove"), ], [ - InlineKeyboardButton("👤 Админы", callback_data="menu_admins"), - InlineKeyboardButton("ℹ️ Credits", callback_data="menu_credits"), + InlineKeyboardButton(_t(user_id, "menu_admins"), callback_data="menu_admins"), + InlineKeyboardButton(_t(user_id, "menu_credits"), callback_data="menu_credits"), ], [ - InlineKeyboardButton("❌ Close", callback_data="close_menu"), + InlineKeyboardButton(_t(user_id, "menu_language"), callback_data="menu_lang"), + InlineKeyboardButton(_t(user_id, "menu_close"), callback_data="close_menu"), ], ] return InlineKeyboardMarkup(buttons) @@ -384,16 +435,13 @@ async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: # ── Режим ожидания первого админа ── if _WAITING_FOR_ADMIN: name = user.full_name or user.username or str(user_id) - text = ( - f"👋 Привет, {html.escape(name)}!\n\n" - f"Бот ещё не настроен.\n" - f"Ваш Telegram ID: {user_id}\n\n" - f"Назначить вас администратором?" - ) + 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("✅ Да", callback_data=f"admin_confirm_{user_id}"), - InlineKeyboardButton("❌ Нет", callback_data="admin_cancel"), + 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") @@ -402,19 +450,19 @@ async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: # ── Проверка доступа ── if not is_user_allowed(user_id): await update.message.reply_text( - f"⛔ Доступ запрещён.\nВаш ID: {user_id}", + _tf(user_id, "access_denied", user_id), parse_mode="HTML", ) return welcome = ( - f"GoTelegram v{GOTELEGRAM_VERSION}\n\n" - "🤖 MTProxy Management Bot\n" - "Powered by telemt engine\n\n" - "Select an action from the menu below:" + 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(), parse_mode="HTML" + welcome, reply_markup=get_main_menu(user_id), parse_mode="HTML" ) # Промо раз в сутки @@ -429,27 +477,41 @@ 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 = ( - "GoTelegram Bot — Команды\n\n" - "/start — Главное меню\n" - "/help — Эта справка\n" - "/status — Быстрый статус\n" - "/logs — Последние логи\n" - "/addadmin ID — Добавить админа\n" - "/deladmin ID — Удалить админа\n\n" - "Используйте кнопки меню для остальных операций." + 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 - - await update.message.reply_text("⏳ Checking status...", parse_mode="HTML") - status_text = await get_status_text() + 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") @@ -457,6 +519,7 @@ 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" @@ -468,7 +531,7 @@ async def cmd_logs(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: parse_mode="HTML", ) else: - await update.message.reply_text("Failed to retrieve logs") + await update.message.reply_text(_t(user_id, "logs_failed")) # ============================================================================ @@ -476,38 +539,39 @@ async def cmd_logs(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: # ============================================================================ -async def get_status_text() -> str: - """Generate status report.""" - lines = ["📊 Current Status\n"] +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) - lines.append(f"Service: {'✅ Running' if is_running else '❌ Stopped'}") + 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"Telemt: v{version}") + lines.append(f"{_t(user_id, 'status_telemt')}: v{version}") # Config status config = load_json(GOTELEGRAM_CONFIG) if config: - lines.append(f"Mode: {html.escape(str(config.get('mode', 'unknown')))}") + lines.append(f"{_t(user_id, 'status_mode')}: {html.escape(str(config.get('mode', 'unknown')))}") if "template" in config: - lines.append(f"Template: {html.escape(str(config['template']))}") + lines.append(f"{_t(user_id, 'status_template')}: {html.escape(str(config['template']))}") if "domain" in config: - lines.append(f"Domain: {html.escape(str(config['domain']))}") + lines.append(f"{_t(user_id, 'status_domain')}: {html.escape(str(config['domain']))}") if "port" in config: - lines.append(f"Port: {html.escape(str(config['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"Listen Port: {server_cfg['port']}") + 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"TLS Domain: {html.escape(str(censor_cfg['tls_domain']))}") + lines.append(f"{_t(user_id, 'status_tls_domain')}: {html.escape(str(censor_cfg['tls_domain']))}") # Backups backup_count = 0 @@ -627,7 +691,7 @@ async def cb_menu_stats(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N keyboard = [ [InlineKeyboardButton("🔄 Обновить", callback_data="menu_stats")], - [InlineKeyboardButton("« Меню", callback_data="menu_main")], + [InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")], ] await safe_edit_message( @@ -646,10 +710,10 @@ async def cb_menu_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> if not await require_auth(update, context): return - text = await get_status_text() + text = await get_status_text(_uid(update)) keyboard = [ [InlineKeyboardButton("🔄 Обновить", callback_data="menu_status")], - [InlineKeyboardButton("« Меню", callback_data="menu_main")], + [InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")], ] await safe_edit_message( query, @@ -669,7 +733,7 @@ def get_install_mode_menu() -> InlineKeyboardMarkup: buttons = [ [InlineKeyboardButton("⚡ Lite", callback_data="install_mode_lite")], [InlineKeyboardButton("🛡 Pro", callback_data="install_mode_pro")], - [InlineKeyboardButton("« Back", callback_data="menu_main")], + [InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")], ] return InlineKeyboardMarkup(buttons) @@ -693,7 +757,7 @@ async def cb_menu_install(update: Update, context: ContextTypes.DEFAULT_TYPE) -> buttons = [ [InlineKeyboardButton("🔄 Migrate from v1", callback_data="install_migrate")], [InlineKeyboardButton("✨ Fresh Install", callback_data="install_mode_lite")], - [InlineKeyboardButton("« Back", callback_data="menu_main")], + [InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")], ] keyboard = InlineKeyboardMarkup(buttons) else: @@ -761,7 +825,7 @@ async def cb_lite_domain(update: Update, context: ContextTypes.DEFAULT_TYPE) -> f"Service starting... Check status in 10 seconds." ) keyboard = InlineKeyboardMarkup( - [[InlineKeyboardButton("« Back", callback_data="menu_main")]] + [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]] ) await safe_edit_message(query, text, reply_markup=keyboard, parse_mode="HTML" @@ -790,7 +854,12 @@ async def cb_install_mode_pro(update: Update, context: ContextTypes.DEFAULT_TYPE ) 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( [ @@ -799,11 +868,134 @@ async def cb_install_mode_pro(update: Update, context: ContextTypes.DEFAULT_TYPE ) ] ) - buttons.append([InlineKeyboardButton("« Back", callback_data="menu_install")]) + buttons.append([InlineKeyboardButton(_t(user_id, "btn_back"), callback_data="menu_install")]) - text = "Pro Mode - Select Template Category:" + text = "Pro Mode — Select Template Category:" keyboard = InlineKeyboardMarkup(buttons) - await safe_edit_message(query,text, reply_markup=keyboard) + 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 + 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 + + 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: @@ -924,7 +1116,7 @@ async def cb_pro_confirm(update: Update, context: ContextTypes.DEFAULT_TYPE) -> f"Service starting... Check status in 10 seconds." ) keyboard = InlineKeyboardMarkup( - [[InlineKeyboardButton("« Back", callback_data="menu_main")]] + [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]] ) await safe_edit_message(query, text, reply_markup=keyboard, parse_mode="HTML" @@ -1000,7 +1192,7 @@ async def cb_menu_link(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No ) keyboard = InlineKeyboardMarkup( - [[InlineKeyboardButton("« Back", callback_data="menu_main")]] + [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]] ) await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") @@ -1014,7 +1206,7 @@ async def cb_menu_share(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N if not link: text = "❌ Proxy not installed yet." keyboard = InlineKeyboardMarkup( - [[InlineKeyboardButton("« Back", callback_data="menu_main")]] + [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]] ) await safe_edit_message(query,text, reply_markup=keyboard) return @@ -1051,7 +1243,7 @@ async def cb_menu_share(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N await safe_edit_message(query, f"🔗 Proxy Link\n\n{html.escape(link)}", reply_markup=InlineKeyboardMarkup( - [[InlineKeyboardButton("« Back", callback_data="menu_main")]] + [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]] ), parse_mode="HTML", ) @@ -1077,7 +1269,7 @@ async def cb_menu_restart(update: Update, context: ContextTypes.DEFAULT_TYPE) -> text = f"❌ Failed to restart:\n{html.escape(stderr[:500])}" keyboard = InlineKeyboardMarkup( - [[InlineKeyboardButton("« Back", callback_data="menu_main")]] + [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]] ) await safe_edit_message(query, text, reply_markup=keyboard, parse_mode="HTML" @@ -1100,7 +1292,7 @@ async def cb_menu_logs(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No text = "❌ Failed to retrieve logs" keyboard = InlineKeyboardMarkup( - [[InlineKeyboardButton("« Back", callback_data="menu_main")]] + [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]] ) await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") @@ -1134,7 +1326,7 @@ async def cb_menu_backup(update: Update, context: ContextTypes.DEFAULT_TYPE) -> [InlineKeyboardButton("📋 List Backups", callback_data="backup_list")] ) - buttons.append([InlineKeyboardButton("« Back", callback_data="menu_main")]) + buttons.append([InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]) text = f"💾 Backup Management\n\nExisting backups: {len(backups)}" keyboard = InlineKeyboardMarkup(buttons) @@ -1215,7 +1407,7 @@ async def cb_menu_restore(update: Update, context: ContextTypes.DEFAULT_TYPE) -> if not backups: text = "❌ No backups available" keyboard = InlineKeyboardMarkup( - [[InlineKeyboardButton("« Back", callback_data="menu_main")]] + [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]] ) else: text = "Select backup to restore:" @@ -1228,7 +1420,7 @@ async def cb_menu_restore(update: Update, context: ContextTypes.DEFAULT_TYPE) -> ) ] ) - buttons.append([InlineKeyboardButton("« Back", callback_data="menu_main")]) + buttons.append([InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]) keyboard = InlineKeyboardMarkup(buttons) # Store backup list in user_data for retrieval context.user_data["backup_list"] = backups[:10] @@ -1273,7 +1465,7 @@ async def cb_restore_backup(update: Update, context: ContextTypes.DEFAULT_TYPE) text = f"❌ Restore failed:\n{html.escape(stderr[:500])}" keyboard = InlineKeyboardMarkup( - [[InlineKeyboardButton("« Back", callback_data="menu_main")]] + [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]] ) await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") @@ -1318,7 +1510,7 @@ async def cb_menu_update(update: Update, context: ContextTypes.DEFAULT_TYPE) -> text = "❌ Failed to parse release info" keyboard = InlineKeyboardMarkup( - [[InlineKeyboardButton("« Back", callback_data="menu_main")]] + [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]] ) await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") @@ -1331,7 +1523,7 @@ async def cb_menu_change(update: Update, context: ContextTypes.DEFAULT_TYPE) -> buttons = [ [InlineKeyboardButton("⚡ Switch to Lite Mode", callback_data="change_lite")], [InlineKeyboardButton("🛡 Switch to Pro Mode", callback_data="change_pro")], - [InlineKeyboardButton("« Back", callback_data="menu_main")], + [InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")], ] keyboard = InlineKeyboardMarkup(buttons) await safe_edit_message(query, @@ -1392,7 +1584,7 @@ async def cb_menu_website(update: Update, context: ContextTypes.DEFAULT_TYPE) -> buttons = [ [InlineKeyboardButton("🔄 Renew SSL Certificate", callback_data="ssl_renew")], [InlineKeyboardButton("📊 SSL Status", callback_data="ssl_status")], - [InlineKeyboardButton("« Back", callback_data="menu_main")], + [InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")], ] keyboard = InlineKeyboardMarkup(buttons) await safe_edit_message(query, @@ -1464,7 +1656,7 @@ async def cb_menu_admins(update: Update, context: ContextTypes.DEFAULT_TYPE) -> ) keyboard = InlineKeyboardMarkup([ - [InlineKeyboardButton("« Назад", callback_data="menu_main")], + [InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")], ]) await safe_edit_message(query, text, reply_markup=keyboard, parse_mode="HTML") @@ -1599,7 +1791,7 @@ async def cb_menu_promo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N await query.answer() keyboard = InlineKeyboardMarkup( - [[InlineKeyboardButton("« Назад", callback_data="menu_main")]] + [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]] ) await safe_edit_message(query, get_promo_text(), reply_markup=keyboard, parse_mode="HTML") @@ -1627,7 +1819,7 @@ async def cb_menu_credits(update: Update, context: ContextTypes.DEFAULT_TYPE) -> ) keyboard = InlineKeyboardMarkup( - [[InlineKeyboardButton("« Back", callback_data="menu_main")]] + [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]] ) await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") @@ -1650,7 +1842,7 @@ async def cb_menu_remove(update: Update, context: ContextTypes.DEFAULT_TYPE) -> buttons = [ [InlineKeyboardButton("❌ Yes, Remove", callback_data="remove_confirm")], - [InlineKeyboardButton("« Back", callback_data="menu_main")], + [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") @@ -1672,7 +1864,7 @@ async def cb_remove_confirm(update: Update, context: ContextTypes.DEFAULT_TYPE) text = "✅ GoTelegram removed successfully" keyboard = InlineKeyboardMarkup( - [[InlineKeyboardButton("« Back", callback_data="menu_main")]] + [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]] ) await safe_edit_message(query,text, reply_markup=keyboard, parse_mode="HTML") @@ -1726,16 +1918,18 @@ async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> await query.answer("Доступ запрещён") return + user_id = update.effective_user.id + # Main menu if data == "menu_main": await query.answer() - buttons = get_main_menu() + buttons = get_main_menu(user_id) text = ( - f"GoTelegram v{GOTELEGRAM_VERSION}\n\n" - "🤖 MTProxy Management\n" - "Select an action:" + 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") + await safe_edit_message(query, text, reply_markup=buttons, parse_mode="HTML") return if data == "close_menu": @@ -1743,6 +1937,41 @@ async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> 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, @@ -1773,6 +2002,22 @@ async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> "menu_stats": cb_menu_stats, } + # 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) @@ -1795,6 +2040,37 @@ async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> # ============================================================================ +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 + # 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 config + config = load_json(GOTELEGRAM_CONFIG) or {} + config["template"] = tpl_id + config["template_source"] = url + save_json(GOTELEGRAM_CONFIG, config) + 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) @@ -1819,12 +2095,18 @@ def main() -> None: 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) diff --git a/gotelegram-bot/i18n.py b/gotelegram-bot/i18n.py new file mode 100644 index 0000000..83151fe --- /dev/null +++ b/gotelegram-bot/i18n.py @@ -0,0 +1,142 @@ +""" +GoTelegram v2.4 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") + +# Supported codes; keep in sync with lang/*.json +SUPPORTED_LANGS = ("en", "ru") +DEFAULT_LANG = os.getenv("BOT_LANG", "en").strip().lower() or "en" +if DEFAULT_LANG not in SUPPORTED_LANGS: + DEFAULT_LANG = "en" + +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 DEFAULT_LANG + return _USER_LANGS.get(int(user_id), 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..5ae9002 --- /dev/null +++ b/gotelegram-bot/lang/en.json @@ -0,0 +1,116 @@ +{ + "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 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 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_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..a8a195f --- /dev/null +++ b/gotelegram-bot/lang/ru.json @@ -0,0 +1,116 @@ +{ + "lang_english": "English", + "lang_russian": "Русский", + "lang_title": "🌐 Язык", + "lang_current": "Текущий язык: %s", + "lang_saved": "Язык сохранён: %s", + "lang_choose": "Выберите язык:", + + "welcome_title": "GoTelegram 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 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_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 (stealth + сайт)", + + "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/install.sh b/install.sh index 33a54c0..0bab9c0 100755 --- a/install.sh +++ b/install.sh @@ -1,20 +1,21 @@ #!/bin/bash # ══════════════════════════════════════════════════════════════════════════════ -# GoTelegram v2.3.0 — MTProxy на ядре telemt (Rust + Tokio) -# Anti-DPI • Fake TLS • TCP Splice • JA3/JA4 Resistance +# GoTelegram v2.4.0 — MTProxy powered by telemt (Rust + Tokio) +# Anti-DPI • Fake TLS • TCP Splice • JA3/JA4 Resistance • i18n (EN/RU) # -# Установка: +# Install: # curl -sL URL/install.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" @@ -22,6 +23,9 @@ source "$LIB_DIR/templates_catalog.sh" source "$LIB_DIR/backup.sh" [ -f "$LIB_DIR/stats.sh" ] && source "$LIB_DIR/stats.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 @@ -38,13 +42,13 @@ show_main_menu() { local line; line=$(printf '━%.0s' $(seq 1 $W)) local line2; line2=$(printf '─%.0s' $(seq 1 $W)) - # ── Заголовок (без правого бордера — ANSI ломает выравнивание) ── + # ── Header (no right border — ANSI breaks alignment) ── echo "" echo -e " ${BOLD}${CYAN}━${line}━${NC}" - echo -e " ${BOLD}${WHITE} GoTelegram v${GOTELEGRAM_VERSION}${NC} ${DIM}— Панель управления${NC}" + echo -e " ${BOLD}${WHITE} GoTelegram v${GOTELEGRAM_VERSION}${NC} ${DIM}— $(t dashboard_title)${NC}" echo -e " ${BOLD}${CYAN}━${line}━${NC}" - # ── Здоровье сервисов ── + # ── Service health ── echo "" echo -e " ${DIM}${line2}${NC}" @@ -55,7 +59,7 @@ show_main_menu() { stopped) proxy_icon="○"; proxy_color="${YELLOW}" ;; *) proxy_icon="✗"; proxy_color="${RED}" ;; esac - echo -e " ${proxy_color}${proxy_icon}${NC} Прокси ${proxy_color}${proxy_status}${NC} ${DIM}(telemt ${mode})${NC}" + 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 @@ -63,7 +67,7 @@ show_main_menu() { running) nginx_icon="●"; nginx_color="${GREEN}" ;; *) nginx_icon="✗"; nginx_color="${RED}" ;; esac - echo -e " ${nginx_icon}${nginx_color}${NC} nginx ${nginx_color}${nginx_st}${NC} ${DIM}(127.0.0.1:8443)${NC}" + 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 @@ -73,29 +77,29 @@ show_main_menu() { else site_icon="✗"; site_color="${RED}" fi - echo -e " ${site_color}${site_icon}${NC} Сайт ${site_color}https://${domain}${NC}" + 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} SSL ${DIM}до ${ssl_expiry}${NC}" + 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} Бот ${GREEN}running${NC}" ;; - stopped) echo -e " ${YELLOW}○${NC} Бот ${YELLOW}stopped${NC}" ;; + 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}" - # ── Сетевые параметры ── - echo -e " ${WHITE}IP:${NC} ${CYAN}${ip}${NC} ${WHITE}Порт:${NC} ${CYAN}${port}${NC} ${WHITE}Режим:${NC} ${CYAN}${mode}${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}Домен:${NC} ${CYAN}${domain}${NC}" + echo -e " ${WHITE}$(t net_domain)${NC} ${CYAN}${domain}${NC}" fi echo -e " ${DIM}${line2}${NC}" - # ── Прокси-ссылка + QR ── + # ── Proxy link + QR ── local mask_host mask_host=$(config_get mask_host 2>/dev/null || echo "") if [ -n "$secret" ] && [ "$proxy_status" = "running" ]; then @@ -105,7 +109,7 @@ show_main_menu() { link=$(generate_proxy_link "$ip" "$port" "$secret" "$mask_host") fi - echo -e " ${BOLD}${WHITE}Ссылка для Telegram:${NC}" + echo -e " ${BOLD}${WHITE}$(t connection_link)${NC}" echo -e " ${GREEN}${link}${NC}" if command -v qrencode &>/dev/null; then @@ -116,39 +120,39 @@ show_main_menu() { echo "" fi else - echo -e " ${DIM}Прокси не настроен. Выберите пункт 1.${NC}" + echo -e " ${DIM}$(t proxy_not_configured)${NC}" echo "" fi - # ── Меню ── + # ── Menu ── echo -e " ${DIM}${line2}${NC}" - echo -e " ${CYAN}1${NC}) Прокси ▸" - echo -e " ${CYAN}2${NC}) Статистика ▸" - echo -e " ${CYAN}3${NC}) Управление ▸" - echo -e " ${CYAN}4${NC}) Telegram-бот ▸" - echo -e " ${CYAN}5${NC}) О программе ▸" - echo -e " ${CYAN}0${NC}) ${DIM}Выход${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}Обновление через 30 сек${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}🚀 ПРОКСИ${NC}" + echo -e " ${BOLD}${WHITE}$(t submenu_proxy_title)${NC}" echo -e " ${DIM}$(printf '─%.0s' {1..54})${NC}" - echo -e " ${CYAN}1${NC}) Установить / Обновить" - echo -e " ${CYAN}2${NC}) Статус подробно" - echo -e " ${CYAN}3${NC}) Скопировать ссылку" - echo -e " ${CYAN}4${NC}) Поделиться ключом" - echo -e " ${CYAN}5${NC}) Перезапуск" - echo -e " ${CYAN}6${NC}) Логи" - echo -e " ${CYAN}7${NC}) Сменить режим / шаблон" - echo -e " ${CYAN}0${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}Выбор:${NC} " + echo -ne " ${WHITE}$(t choose):${NC} " read -r ch case "$ch" in @@ -160,29 +164,30 @@ submenu_proxy() { 6) menu_logs ;; 7) menu_change_mode ;; 0) break ;; - *) log_error "Неверный выбор" ;; + *) log_error "$(t invalid_choice)" ;; esac echo "" - echo -ne " ${DIM}Нажмите Enter...${NC}" + echo -ne " ${DIM}$(t press_enter)${NC}" read -r done } -# ── Подменю: Управление ────────────────────────────────────────────────────── +# ── Submenu: Management ───────────────────────────────────────────────────── submenu_manage() { while true; do echo "" - echo -e " ${BOLD}${WHITE}⚙️ УПРАВЛЕНИЕ${NC}" + echo -e " ${BOLD}${WHITE}$(t submenu_manage_title)${NC}" echo -e " ${DIM}$(printf '─%.0s' {1..54})${NC}" - echo -e " ${CYAN}1${NC}) Бекап" - echo -e " ${CYAN}2${NC}) Восстановить" - echo -e " ${CYAN}3${NC}) Обновить telemt" - echo -e " ${CYAN}4${NC}) Сайт / SSL" - echo -e " ${CYAN}5${NC}) Удалить" - echo -e " ${CYAN}0${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}Выбор:${NC} " + echo -ne " ${WHITE}$(t choose):${NC} " read -r ch case "$ch" in @@ -191,61 +196,62 @@ submenu_manage() { 3) update_telemt ;; 4) menu_website ;; 5) menu_remove ;; + 6) menu_language ;; 0) break ;; - *) log_error "Неверный выбор" ;; + *) log_error "$(t invalid_choice)" ;; esac echo "" - echo -ne " ${DIM}Нажмите Enter...${NC}" + echo -ne " ${DIM}$(t press_enter)${NC}" read -r done } -# ── Подменю: О программе ───────────────────────────────────────────────────── +# ── Submenu: About ────────────────────────────────────────────────────────── submenu_about() { while true; do echo "" - echo -e " ${BOLD}${WHITE}ℹ️ О ПРОГРАММЕ${NC}" + echo -e " ${BOLD}${WHITE}$(t submenu_about_title)${NC}" echo -e " ${DIM}$(printf '─%.0s' {1..54})${NC}" - echo -e " ${CYAN}1${NC}) Информация о версии" - echo -e " ${CYAN}2${NC}) Промо / Донат" - echo -e " ${CYAN}0${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}Выбор:${NC} " + echo -ne " ${WHITE}$(t choose):${NC} " read -r ch case "$ch" in 1) menu_version ;; 2) menu_promo ;; 0) break ;; - *) log_error "Неверный выбор" ;; + *) log_error "$(t invalid_choice)" ;; esac echo "" - echo -ne " ${DIM}Нажмите Enter...${NC}" + echo -ne " ${DIM}$(t press_enter)${NC}" read -r done } -# ── Информация о версии ────────────────────────────────────────────────────── +# ── Version info ──────────────────────────────────────────────────────────── menu_version() { echo "" - echo -e " ${BOLD}${WHITE}🔍 Информация${NC}" + echo -e " ${BOLD}${WHITE}$(t version_title)${NC}" echo -e " ${DIM}$(printf '─%.0s' {1..54})${NC}" - echo -e " ${WHITE}GoTelegram:${NC} v${GOTELEGRAM_VERSION}" - echo -e " ${WHITE}Ядро:${NC} telemt (Rust + Tokio)" - echo -e " ${WHITE}Технология:${NC} Anti-DPI, Fake TLS, TCP Splice" - echo -e " ${WHITE}Лицензия:${NC} MIT" + 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}" } -# ── Установка: выбор режима ────────────────────────────────────────────────── +# ── Install: mode selection ───────────────────────────────────────────────── menu_install() { - # Проверяем v1 + # Check for v1 if detect_v1_installation; then echo "" - echo -e " ${YELLOW}⚠️ Обнаружена установка GoTelegram v1 (mtg)${NC}" - echo -e " ${DIM}Контейнер: ${V1_CONTAINER_NAME}${NC}" + 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 @@ -253,185 +259,185 @@ menu_install() { fi echo "" - echo -e " ${BOLD}${WHITE}🎭 Выберите режим маскировки:${NC}" + echo -e " ${BOLD}${WHITE}$(t install_select_mode)${NC}" echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" - echo -e " ${CYAN}1)${NC} ${GREEN}⚡ Lite${NC} — маскировка под популярный сайт" - echo -e " ${DIM}Быстро, без домена. telemt маскирует трафик${NC}" - echo -e " ${DIM}под выбранный сайт (google.com и т.д.)${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}🛡 Pro${NC} — свой сайт + полная маскировка" - echo -e " ${DIM}nginx + SSL + HTML-шаблон + telemt.${NC}" - echo -e " ${DIM}DPI видит реальный сайт с реальным сертификатом.${NC}" - echo -e " ${DIM}Требует: домен, направленный на этот сервер.${NC}" + 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}Выбор (1/2):${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 "Неверный выбор: ${mode_choice:-<пусто>}" ;; + *) log_error "$(tf install_bad_choice "${mode_choice:-}")" ;; esac } -# ── Lite-режим ─────────────────────────────────────────────────────────────── +# ── Lite mode ─────────────────────────────────────────────────────────────── install_lite_mode() { - log_step "Установка Lite-режима" + 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 - # Генерация секрета + # Generate secret local secret secret=$(generate_hex 32) - # Подтверждение + # Confirm local ip ip=$(get_server_ip) echo "" - echo -e " ${BOLD}${WHITE}📋 Конфигурация:${NC}" - echo -e " IP: ${CYAN}${ip}${NC}" - echo -e " Порт: ${CYAN}${port}${NC}" - echo -e " Маскировка: ${CYAN}${domain}${NC}" - echo -e " Режим: ${GREEN}Lite${NC}" + 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 "Установить прокси?"; then + if ! confirm "$(t install_confirm_proxy)"; then return fi - # Установка + # Install ensure_deps install_telemt_full || return - # Генерируем конфиг telemt + # Generate telemt config generate_telemt_toml "$secret" "$port" "lite" "$domain" "443" - # Валидация + # Validate validate_telemt_config || return - # Запуск + # Start start_telemt || return - # Сохраняем GoTelegram конфиг + # Save GoTelegram config save_gotelegram_config "telemt" "lite" "$port" "$secret" "$domain" "" "" - # Благодарности + # Credits show_credits - # Результат + # Result show_proxy_info - log_success "GoTelegram v${GOTELEGRAM_VERSION} установлен! (Lite-режим)" + log_success "$(tf install_done "$GOTELEGRAM_VERSION" "Lite")" } -# ── Pro-режим ──────────────────────────────────────────────────────────────── +# ── Pro mode ──────────────────────────────────────────────────────────────── install_pro_mode() { - log_step "Установка Pro-режима" + log_step "$(t install_pro_step)" - # Ввод домена + # Enter domain echo "" - echo -ne " ${WHITE}Введите ваш домен (например, example.com):${NC} " + echo -ne " ${WHITE}$(t install_enter_domain)${NC} " read -r user_domain if [ -z "$user_domain" ] || ! validate_domain "$user_domain"; then - log_error "Некорректный домен: ${user_domain:-<пусто>}" + log_error "$(tf install_bad_domain "${user_domain:-}")" return fi - # Проверяем DNS + # 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 "Домен $user_domain указывает на $resolved_ip, а не на $server_ip" - if ! confirm "Продолжить всё равно?"; then + log_warning "$(tf install_dns_mismatch "$user_domain" "$resolved_ip" "$server_ip")" + if ! confirm "$(t install_continue_anyway)"; then return fi fi - # Email для Let's Encrypt - echo -ne " ${WHITE}Email для SSL (Enter = без email):${NC} " + # 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: - # telemt слушает на 0.0.0.0:443 (принимает ВСЕ подключения) - # nginx слушает на 127.0.0.1:8443 с SSL (обслуживает сайт) - # MTProxy клиент → :443 → telemt (проксирует) - # Обычный браузер → :443 → telemt → 127.0.0.1:8443 → nginx (сайт) - # Провайдер видит только HTTPS на 443 к домену + # 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}telemt принимает весь трафик на 443 (маскировка под HTTPS)${NC}" - echo -e " ${DIM}nginx обслуживает сайт на внутреннем порту $nginx_internal_port${NC}" - echo -e " ${DIM}Провайдер видит только HTTPS-трафик к ${user_domain}:443${NC}" + 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}" - # Генерация fake-TLS секрета (ee + secret + hex domain) - # Префикс ee говорит Telegram-клиенту маскировать трафик под TLS к домену + # 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}📋 Конфигурация:${NC}" - echo -e " Домен: ${CYAN}${user_domain}${NC}" - echo -e " Порт: ${CYAN}443 (telemt + nginx внутри)${NC}" - echo -e " Режим: ${MAGENTA}Pro (fake-TLS)${NC}" + 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 "Установить прокси + сайт?"; then + if ! confirm "$(t install_confirm_proxy_site)"; then return fi - # Установка + # Install ensure_deps install_telemt_full || return - # Конфиг telemt: слушает 443, маскировка на локальный nginx через dns_override + # telemt config: listen 443, masquerade to local nginx via dns_override generate_telemt_toml "$raw_secret" "443" "pro" "$user_domain" "$nginx_internal_port" - # Настройка сайта (nginx на внутреннем порту + certbot + шаблон) + # Website setup (nginx on internal port + certbot + template) setup_pro_mode "$user_domain" "$template_dir" "$nginx_internal_port" "$ssl_email" || return - # Останавливаем nginx на 443 перед запуском telemt (telemt займёт 443) - # nginx уже перенастроен на внутренний порт + # Stop nginx on 443 before starting telemt (telemt will take 443) + # nginx already reconfigured to internal port systemctl restart nginx 2>/dev/null - # Запуск telemt + # 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" - # Результат — используем домен и fake-TLS ссылку + # Result — use domain and fake-TLS link show_proxy_info_pro "$user_domain" "$faketls_secret" - echo -e " ${WHITE}Сайт:${NC} ${GREEN}https://${user_domain}${NC}" - log_success "GoTelegram v${GOTELEGRAM_VERSION} установлен! (Pro-режим)" + 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 - # Дополнительно для pro + # Extras for pro local mode mode=$(config_get mode 2>/dev/null) if [ "$mode" = "pro" ]; then @@ -442,9 +448,9 @@ menu_status() { ssl_expiry=$(get_ssl_expiry "$domain") local nginx_st nginx_st=$(nginx_status) - echo -e " ${WHITE}nginx:${NC} ${nginx_st}" - echo -e " ${WHITE}SSL до:${NC} ${ssl_expiry}" - echo -e " ${WHITE}Сайт:${NC} https://${domain}" + 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 @@ -467,7 +473,7 @@ menu_link() { fi echo "" - echo -e " ${BOLD}${WHITE}🔗 Ссылка для подключения:${NC}" + echo -e " ${BOLD}${WHITE}$(t link_title)${NC}" echo "" echo -e " ${GREEN}${link}${NC}" echo "" @@ -496,17 +502,17 @@ menu_share() { fi echo "" - echo -e " ${BOLD}📤 Перешлите это сообщение:${NC}" + echo -e " ${BOLD}$(t share_title)${NC}" echo "" - echo "🔐 MTProxy для Telegram (GoTelegram v${GOTELEGRAM_VERSION})" + printf "$(t share_line1)\n" "$GOTELEGRAM_VERSION" echo "" - echo "🌍 Сервер: $server_display" - echo "🔌 Порт: $port" + printf "$(t share_server)\n" "$server_display" + printf "$(t share_port)\n" "$port" echo "" - echo "👉 Подключиться одним нажатием:" + echo "$(t share_connect_cta)" echo "$link" echo "" - echo "Просто нажмите на ссылку или настройте вручную." + echo "$(t share_footer)" echo "" } @@ -520,32 +526,32 @@ menu_restart() { fi } -# ── Логи ───────────────────────────────────────────────────────────────────── +# ── Logs ──────────────────────────────────────────────────────────────────── menu_logs() { echo "" - echo -e " ${BOLD}${WHITE}📋 Логи telemt (последние 40 строк):${NC}" + 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 ────────────────────────────────────────────────── menu_change_mode() { local current_mode current_mode=$(config_get mode 2>/dev/null) echo "" - echo -e " ${WHITE}Текущий режим:${NC} ${CYAN}${current_mode}${NC}" + echo -e " ${WHITE}$(t change_current_mode)${NC} ${CYAN}${current_mode}${NC}" echo "" - echo -e " ${CYAN}1${NC}) Сменить шаблон сайта (только pro)" - echo -e " ${CYAN}2${NC}) Переключить режим (lite ↔ pro)" - echo -e " ${CYAN}0${NC}) Назад" - echo -ne " ${WHITE}Выбор:${NC} " + 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 "Смена шаблона доступна только в pro-режиме" + log_error "$(t change_only_pro)" return fi local template_dir @@ -554,21 +560,21 @@ menu_change_mode() { switch_template "$template_dir" ;; 2) - log_warning "Переключение режима требует переустановки." - if confirm "Переустановить прокси?"; then + 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 "Управление сайтом доступно только в pro-режиме" + log_info "$(t website_only_pro)" return fi @@ -576,15 +582,15 @@ menu_website() { domain=$(config_get domain 2>/dev/null) echo "" - echo -e " ${BOLD}${WHITE}🌐 Управление сайтом${NC}" - echo -e " Домен: ${CYAN}${domain}${NC}" - echo -e " SSL до: $(get_ssl_expiry "$domain")" + 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}) Обновить SSL сертификат" - echo -e " ${CYAN}2${NC}) Перезапустить nginx" - echo -e " ${CYAN}3${NC}) Сменить шаблон" - echo -e " ${CYAN}0${NC}) Назад" - echo -ne " ${WHITE}Выбор:${NC} " + 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 @@ -599,23 +605,23 @@ menu_website() { esac } -# ── Удаление ───────────────────────────────────────────────────────────────── +# ── Remove ───────────────────────────────────────────────────────────────── menu_remove() { echo "" - echo -e " ${BOLD}${RED}🗑 Удаление GoTelegram${NC}" + echo -e " ${BOLD}${RED}$(t remove_title)${NC}" echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" - echo -e " ${CYAN}1${NC}) Удалить только прокси (telemt)" - echo -e " ${CYAN}2${NC}) Удалить только Telegram-бота" - echo -e " ${CYAN}3${NC}) Удалить всё (прокси + бот + настройки)" - echo -e " ${CYAN}0${NC}) Назад" - echo -ne " ${WHITE}Выбор:${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 "Это удалит прокси и все его настройки." - if ! confirm "Удалить прокси?"; then return; fi - if confirm "Сделать бекап перед удалением?"; then + 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 @@ -625,18 +631,18 @@ menu_remove() { remove_pro_mode fi rm -f "$GOTELEGRAM_CONFIG" - log_success "Прокси удалён" + log_success "$(t remove_proxy_done)" ;; 2) bot_remove ;; 3) - log_warning "Это удалит ВСЁ: прокси, бот, сайт, настройки." - if ! confirm "Вы точно уверены?"; then return; fi - if confirm "Сделать бекап перед удалением?"; then + 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) @@ -644,7 +650,7 @@ menu_remove() { 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 @@ -652,7 +658,7 @@ menu_remove() { systemctl daemon-reload rm -rf "$BOT_DIR" fi - log_success "GoTelegram полностью удалён (прокси + бот)" + log_success "$(t remove_all_done)" ;; esac } @@ -676,42 +682,42 @@ menu_bot() { st=$(bot_service_status) echo "" - echo -e " ${BOLD}${WHITE}🤖 Telegram-бот${NC}" + echo -e " ${BOLD}${WHITE}$(t bot_title)${NC}" echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" case "$st" in running) - echo -e " Статус: ${GREEN}● Работает${NC}" + echo -e " $(t bot_status_colon) ${GREEN}$(t bot_status_running)${NC}" echo "" - echo -e " ${CYAN}1${NC}) 📊 Статус бота" - echo -e " ${CYAN}2${NC}) 📋 Логи бота" - echo -e " ${CYAN}3${NC}) 🔄 Перезапустить бота" - echo -e " ${CYAN}4${NC}) ⏹ Остановить бота" - echo -e " ${CYAN}5${NC}) ⚙️ Настройки (.env)" - echo -e " ${CYAN}6${NC}) 🗑 Удалить бота" + 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 " Статус: ${YELLOW}○ Остановлен${NC}" + echo -e " $(t bot_status_colon) ${YELLOW}$(t bot_status_stopped)${NC}" echo "" - echo -e " ${CYAN}1${NC}) 📊 Статус бота" - echo -e " ${CYAN}2${NC}) 📋 Логи бота" - echo -e " ${CYAN}3${NC}) ▶️ Запустить бота" - echo -e " ${CYAN}5${NC}) ⚙️ Настройки (.env)" - echo -e " ${CYAN}6${NC}) 🗑 Удалить бота" + 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 " Статус: ${RED}✗ Не установлен${NC}" + echo -e " $(t bot_status_colon) ${RED}$(t bot_status_not_installed)${NC}" echo "" - echo -e " ${DIM}Бот позволяет управлять прокси прямо из Telegram:${NC}" - echo -e " ${DIM}статус, перезапуск, смена режима, бекап, QR-код.${NC}" + echo -e " ${DIM}$(t bot_intro1)${NC}" + echo -e " ${DIM}$(t bot_intro2)${NC}" echo "" - echo -e " ${CYAN}1${NC}) 🔧 Установить бота" + echo -e " ${CYAN}1${NC}) $(t bot_menu_install)" ;; esac - echo -e " ${CYAN}0${NC}) « Назад" + echo -e " ${CYAN}0${NC}) $(t back)" echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" - echo -ne " ${WHITE}Выбор:${NC} " + echo -ne " ${WHITE}$(t choose):${NC} " read -r ch case "$st" in @@ -719,8 +725,8 @@ menu_bot() { case "$ch" in 1) bot_show_status ;; 2) bot_show_logs ;; - 3) systemctl restart "$BOT_SERVICE" && log_success "Бот перезапущен" ;; - 4) systemctl stop "$BOT_SERVICE" && log_info "Бот остановлен" ;; + 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 @@ -729,7 +735,7 @@ menu_bot() { case "$ch" in 1) bot_show_status ;; 2) bot_show_logs ;; - 3) systemctl start "$BOT_SERVICE" && log_success "Бот запущен" ;; + 3) systemctl start "$BOT_SERVICE" && log_success "$(t bot_started)" ;; 5) bot_edit_config ;; 6) bot_remove ;; esac @@ -743,11 +749,11 @@ menu_bot() { } bot_install() { - log_step "Установка Telegram-бота" + log_step "$(t bot_install_step)" # Python if ! command -v python3 &>/dev/null; then - log_info "Установка Python3..." + log_info "$(t bot_install_python)" 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 @@ -757,65 +763,76 @@ bot_install() { 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 "Файлы бота не найдены в $SCRIPT_DIR/gotelegram-bot/" + log_error "$(tf bot_files_not_found "$SCRIPT_DIR/gotelegram-bot/")" return 1 fi - # Каталог шаблонов + # Templates catalog [ -f "$SCRIPT_DIR/templates_catalog.json" ] && \ cp "$SCRIPT_DIR/templates_catalog.json" "$GOTELEGRAM_DIR/" # Venv if [ ! -d "$BOT_DIR/venv" ]; then - log_info "Создание виртуального окружения..." + log_info "$(t bot_create_venv)" python3 -m venv "$BOT_DIR/venv" fi - log_info "Установка зависимостей..." + log_info "$(t bot_install_deps)" "$BOT_DIR/venv/bin/pip" install -r "$BOT_DIR/requirements.txt" -q - # Конфигурация + # Configuration if [ ! -f "$BOT_DIR/.env" ]; then echo "" - echo -e " ${YELLOW}Введите BOT_TOKEN от @BotFather:${NC}" + echo -e " ${YELLOW}$(t bot_enter_token)${NC}" local token="" while [ -z "$token" ]; do - echo -ne " ${WHITE}Token:${NC} " + echo -ne " ${WHITE}$(t bot_token)${NC} " read -r token token=$(echo "$token" | tr -d '[:space:]') - [ -z "$token" ] && log_error "Токен не может быть пустым" + [ -z "$token" ] && log_error "$(t bot_token_empty)" done echo "" - echo -e " ${WHITE}Как добавить администратора?${NC}" - echo -e " ${CYAN}1${NC}) Автоматически — бот определит ID при первом /start" - echo -e " ${CYAN}2${NC}) Вручную — ввести ID сейчас" - echo -ne " ${WHITE}Выбор [1]:${NC} " + 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}ID администраторов (через пробел/запятую):${NC} " + 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 ".env создан" + log_success "$(t bot_env_created)" else - log_info ".env уже существует, настройки сохранены" + log_info "$(t bot_env_exists)" fi # Systemd @@ -840,37 +857,37 @@ SVCEOF systemctl enable "$BOT_SERVICE" &>/dev/null systemctl restart "$BOT_SERVICE" 2>/dev/null || systemctl start "$BOT_SERVICE" - # Если авто-режим — ждём пока бот словит первого админа + # 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 "" echo -e " ${YELLOW}╔══════════════════════════════════════════════════════╗${NC}" - echo -e " ${YELLOW}║${NC} ${BOLD}Ожидание администратора${NC} ${YELLOW}║${NC}" + printf " ${YELLOW}║${NC} ${BOLD}%-52s${NC} ${YELLOW}║${NC}\n" "$(t bot_wait_admin_title)" echo -e " ${YELLOW}║${NC} ${YELLOW}║${NC}" - echo -e " ${YELLOW}║${NC} Откройте бота в Telegram и отправьте ${CYAN}/start${NC} ${YELLOW}║${NC}" - echo -e " ${YELLOW}║${NC} Бот автоматически назначит вас администратором ${YELLOW}║${NC}" + printf " ${YELLOW}║${NC} %s ${CYAN}/start${NC}%*s${YELLOW}║${NC}\n" "$(t bot_wait_admin_msg1)" 0 "" + printf " ${YELLOW}║${NC} %-52s ${YELLOW}║${NC}\n" "$(t bot_wait_admin_msg2)" echo -e " ${YELLOW}║${NC} ${YELLOW}║${NC}" - echo -e " ${YELLOW}║${NC} ${DIM}Нажмите Ctrl+C чтобы пропустить${NC} ${YELLOW}║${NC}" + printf " ${YELLOW}║${NC} ${DIM}%-52s${NC} ${YELLOW}║${NC}\n" "$(t bot_wait_admin_skip)" echo -e " ${YELLOW}╚══════════════════════════════════════════════════════╝${NC}" echo "" local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏') local i=0 local waited=0 - local max_wait=300 # 5 минут максимум + local max_wait=300 # 5 min max - # Ловим Ctrl+C чтобы выйти из ожидания без убийства скрипта + # 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} Ожидание... напишите /start боту (%d сек) " "$waited" >&2 + printf "\r ${CYAN}${frames[$i]}${NC} $(tf bot_wait_spinner "$waited") " >&2 i=$(( (i+1) % ${#frames[@]} )) sleep 1 waited=$((waited + 1)) - # Проверяем появился ли ALLOWED_IDS + # 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 @@ -878,30 +895,30 @@ SVCEOF done trap - INT - printf "\r\033[K" >&2 # очистить строку со спиннером + printf "\r\033[K" >&2 # clear spinner line if [ -n "$has_ids" ]; then echo "" - log_success "Администратор назначен!" + log_success "$(t bot_admin_assigned)" echo -e " ${WHITE}ID:${NC} ${GREEN}${has_ids}${NC}" elif [ $interrupted -eq 1 ]; then echo "" - log_warning "Пропущено. Добавить админа позже: меню → Telegram-бот → Настройки" + log_warning "$(t bot_wait_skipped)" else echo "" - log_warning "Таймаут (5 мин). Добавить админа: меню → Telegram-бот → Настройки" + log_warning "$(t bot_wait_timeout)" fi fi echo "" - log_success "Бот установлен и запущен!" - echo -e " ${DIM}Проверка: systemctl status $BOT_SERVICE${NC}" - echo -e " ${DIM}Логи: journalctl -u $BOT_SERVICE -f${NC}" + 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}📊 Статус Telegram-бота${NC}" + 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" @@ -912,14 +929,20 @@ bot_show_status() { 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) - echo -e " Token: ${has_token:+${GREEN}✓ настроен${NC}}" - echo -e " Доступ: ${has_ids:+ID: $has_ids}${has_ids:-${YELLOW}все пользователи${NC}}" + 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}📋 Логи бота (последние 30 строк):${NC}" + 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" @@ -929,13 +952,13 @@ bot_show_logs() { bot_edit_config() { echo "" - echo -e " ${BOLD}${WHITE}⚙️ Настройки бота${NC}" + 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}Текущий .env:${NC}" + 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}" @@ -946,29 +969,29 @@ bot_edit_config() { fi echo "" - echo -e " ${CYAN}1${NC}) Сменить BOT_TOKEN" - echo -e " ${CYAN}2${NC}) Изменить ALLOWED_IDS" - echo -e " ${CYAN}0${NC}) Назад" - echo -ne " ${WHITE}Выбор:${NC} " + 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}Новый BOT_TOKEN:${NC} " + 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 "Токен обновлён, бот перезапущен" + log_success "$(t bot_token_updated)" else - log_error "Пустой токен" + log_error "$(t bot_token_empty_err)" fi ;; 2) - echo -ne " ${WHITE}ALLOWED_IDS (через пробел/запятую, пусто = авто):${NC} " + 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 @@ -980,15 +1003,15 @@ bot_edit_config() { [ -n "$new_ids" ] && echo "ALLOWED_IDS=$new_ids" >> "$BOT_DIR/.env" fi systemctl restart "$BOT_SERVICE" - log_success "Доступ обновлён, бот перезапущен" + log_success "$(t bot_access_updated)" ;; esac } bot_remove() { echo "" - log_warning "Это удалит Telegram-бота и все его настройки." - if ! confirm "Удалить бота?"; then + log_warning "$(t bot_remove_warn)" + if ! confirm "$(t bot_remove_confirm)"; then return fi @@ -997,29 +1020,33 @@ bot_remove() { rm -f "/etc/systemd/system/${BOT_SERVICE}.service" systemctl daemon-reload rm -rf "$BOT_DIR" - log_success "Бот полностью удалён" + 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}) + 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}antenka20${NC} — $(tf promo_ant20)" + echo -e " ${WHITE}antenka6${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}" + echo -e " ${DIM}${line2}${NC}" + echo "" } -# ── Промо ──────────────────────────────────────────────────────────────────── menu_promo() { - echo "" - echo -e " ${YELLOW}╔══════════════════════════════════════════════════════╗${NC}" - echo -e " ${YELLOW}║${NC} ${BOLD}💰 ХОСТИНГ #1 — СКИДКА ДО 60%${NC} ${YELLOW}║${NC}" - echo -e " ${YELLOW}║${NC} Ссылка: ${CYAN}https://vk.cc/ct29NQ${NC} ${YELLOW}║${NC}" - echo -e " ${YELLOW}║${NC} ${YELLOW}║${NC}" - echo -e " ${YELLOW}║${NC} ${WHITE}OFF60${NC} — 60% скидки на первый месяц ${YELLOW}║${NC}" - echo -e " ${YELLOW}║${NC} ${WHITE}antenka20${NC} — 20% + 3% при оплате за 3 месяца ${YELLOW}║${NC}" - echo -e " ${YELLOW}║${NC} ${WHITE}antenka6${NC} — 15% + 5% при оплате за 6 месяцев ${YELLOW}║${NC}" - echo -e " ${YELLOW}╟──────────────────────────────────────────────────────╢${NC}" - echo -e " ${YELLOW}║${NC} ${BOLD}💰 ХОСТИНГ #2 — СКИДКА ДО 60%${NC} ${YELLOW}║${NC}" - echo -e " ${YELLOW}║${NC} Ссылка: ${CYAN}https://vk.cc/cUxAhj${NC} ${YELLOW}║${NC}" - echo -e " ${YELLOW}║${NC} ${YELLOW}║${NC}" - echo -e " ${YELLOW}║${NC} ${WHITE}OFF60${NC} — 60% скидки на первый месяц ${YELLOW}║${NC}" - echo -e " ${YELLOW}╟──────────────────────────────────────────────────────╢${NC}" - echo -e " ${YELLOW}║${NC} ${BOLD}☕ Донат / Чаевые${NC} ${YELLOW}║${NC}" - echo -e " ${YELLOW}║${NC} ${CYAN}https://pay.cloudtips.ru/p/7410814f${NC} ${YELLOW}║${NC}" - echo -e " ${YELLOW}╚══════════════════════════════════════════════════════╝${NC}" - echo "" + _promo_block } # ── Проверка: показывать ли промо (раз в сутки) ──────────────────────────── @@ -1043,37 +1070,23 @@ mark_promo_shown() { date +%s > "$GOTELEGRAM_DIR/.promo_last_shown" } -# ── Промо с QR и задержкой (при установке + раз в сутки) ─────────────────── +# ── Promo with QR + delay (on install + once per day) ─────────────────── show_promo_with_qr() { - echo "" - echo -e " ${YELLOW}╔══════════════════════════════════════════════════════╗${NC}" - echo -e " ${YELLOW}║${NC} ${BOLD}💰 ХОСТИНГ #1 — СКИДКА ДО 60%${NC} ${YELLOW}║${NC}" - echo -e " ${YELLOW}║${NC} Ссылка: ${CYAN}https://vk.cc/ct29NQ${NC} ${YELLOW}║${NC}" - echo -e " ${YELLOW}║${NC} ${YELLOW}║${NC}" - echo -e " ${YELLOW}║${NC} ${WHITE}OFF60${NC} — 60% скидки на первый месяц ${YELLOW}║${NC}" - echo -e " ${YELLOW}║${NC} ${WHITE}antenka20${NC} — 20% + 3% при оплате за 3 месяца ${YELLOW}║${NC}" - echo -e " ${YELLOW}║${NC} ${WHITE}antenka6${NC} — 15% + 5% при оплате за 6 месяцев ${YELLOW}║${NC}" - echo -e " ${YELLOW}╟──────────────────────────────────────────────────────╢${NC}" - echo -e " ${YELLOW}║${NC} ${BOLD}💰 ХОСТИНГ #2 — СКИДКА ДО 60%${NC} ${YELLOW}║${NC}" - echo -e " ${YELLOW}║${NC} Ссылка: ${CYAN}https://vk.cc/cUxAhj${NC} ${YELLOW}║${NC}" - echo -e " ${YELLOW}║${NC} ${YELLOW}║${NC}" - echo -e " ${YELLOW}║${NC} ${WHITE}OFF60${NC} — 60% скидки на первый месяц ${YELLOW}║${NC}" - echo -e " ${YELLOW}╚══════════════════════════════════════════════════════╝${NC}" - echo "" + _promo_block - # QR-коды + # QR codes if command -v qrencode &>/dev/null; then - echo -e " ${DIM}── QR: Хостинг #1 ──${NC}" + echo -e " ${DIM}$(t promo_qr_host1)${NC}" qrencode -t UTF8 -m 1 "https://vk.cc/ct29NQ" 2>/dev/null | while IFS= read -r qr_line; do echo " $qr_line" done echo "" - echo -e " ${DIM}── QR: Хостинг #2 ──${NC}" + echo -e " ${DIM}$(t promo_qr_host2)${NC}" qrencode -t UTF8 -m 1 "https://vk.cc/cUxAhj" 2>/dev/null | while IFS= read -r qr_line; do echo " $qr_line" done echo "" - echo -e " ${DIM}── QR: Чаевые / Донат ──${NC}" + echo -e " ${DIM}$(t promo_qr_tips)${NC}" qrencode -t UTF8 -m 1 "https://pay.cloudtips.ru/p/7410814f" 2>/dev/null | while IFS= read -r qr_line; do echo " $qr_line" done @@ -1081,25 +1094,63 @@ show_promo_with_qr() { mark_promo_shown - # 5-секундная задержка с обратным отсчётом + # 5-second countdown for i in 5 4 3 2 1; do - echo -ne "\r ${DIM}Меню через ${i} сек...${NC} " + echo -ne "\r ${DIM}$(tf promo_menu_in "$i")${NC} " sleep 1 done - echo -ne "\r \r" + 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 +} + +# ── Точка входа / Entry point ─────────────────────────────────────────────── main() { check_root init_dirs + + # 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 @@ -1115,14 +1166,14 @@ main() { 3) submenu_manage ;; 4) menu_bot ;; 5) submenu_about ;; - 0|q|exit) echo ""; log_info "До встречи! 👋"; exit 0 ;; - *) log_error "Неверный выбор" ;; + 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}Нажмите Enter для возврата в меню...${NC}" + echo -ne " ${DIM}$(t press_enter_to_return)${NC}" read -r fi fi @@ -1155,33 +1206,34 @@ submenu_stats() { tput cup 0 0 2>/dev/null || printf '\033[H' fi - # Рисуем весь экран поверх старого содержимого - echo -e "\033[J" # очищаем от курсора до конца (убирает хвосты) - echo -e " ${BOLD}${WHITE}📊 Статистика трафика${NC}" + # 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}Модуль статистики не загружен.${NC}" - echo -e " ${DIM}Файл lib/stats.sh не найден.${NC}" + 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="вкл" + 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="выкл" + [ "$cfg_val" = "false" ] && stats_on=$(t stats_off) fi - echo -e " ${CYAN}1${NC}) Вкл/Выкл подсчёт (сейчас: ${stats_on})" - echo -e " ${CYAN}2${NC}) Установить/обновить сборщик статистики" - echo -e " ${CYAN}0${NC}) ${DIM}← Назад${NC}" + 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}Обновление каждые 3 сек${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}" @@ -1191,14 +1243,14 @@ submenu_stats() { 1) if type toggle_stats &>/dev/null; then toggle_stats - echo -ne " ${DIM}Нажмите Enter...${NC}"; read -r - first_draw=1 # полная перерисовка после действия + 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}Нажмите Enter...${NC}"; read -r + echo -ne " ${DIM}$(t press_enter)${NC}"; read -r first_draw=1 fi ;; diff --git a/lib/backup.sh b/lib/backup.sh index 07854ef..f5d3cd6 100755 --- a/lib/backup.sh +++ b/lib/backup.sh @@ -1,5 +1,5 @@ #!/bin/bash -# GoTelegram v2.2 — Бекап и восстановление конфигурации +# GoTelegram v2.4 — backup and restore (i18n-aware) # ── Создание бекапа ────────────────────────────────────────────────────────── create_backup() { @@ -13,7 +13,7 @@ create_backup() { mkdir -p "$tmp_dir" "$output_dir" # Собираем файлы - log_info "Собираю конфигурацию..." + log_info "$(_t_or backup_collecting 'Собираю конфигурацию...')" # telemt конфиг if [ -f "$TELEMT_CONFIG" ]; then @@ -25,6 +25,11 @@ create_backup() { cp "$GOTELEGRAM_CONFIG" "$tmp_dir/gotelegram.json" 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" @@ -44,40 +49,46 @@ create_backup() { if [ -d "$WEBSITE_ROOT" ] && [ -f "$WEBSITE_ROOT/index.html" ]; then mkdir -p "$tmp_dir/site" cp -r "$WEBSITE_ROOT"/* "$tmp_dir/site/" - log_dim "Шаблон сайта включён" + log_dim "$(_t_or backup_site_included 'Шаблон сайта включён')" fi # Метаданные - local ip mode engine + 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.0", + "backup_version": "1.1", "gotelegram_version": "$GOTELEGRAM_VERSION", "created_at": "$(date -Iseconds)", "hostname": "$(hostname)", "ip": "$ip", "engine": "$engine", "mode": "$mode", - "port": $(config_get port 2>/dev/null || echo "443"), - "domain": "$(config_get domain 2>/dev/null)" + "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 "Ошибка создания архива" + 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 "Архив не создан" + log_error "$(_t_or backup_archive_missing 'Архив не создан')" rm -rf "$tmp_dir" return 1 fi @@ -88,13 +99,13 @@ EOMETA 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 "Ошибка шифрования" + log_error "$(_t_or backup_encrypt_err 'Ошибка шифрования')" rm -f "$tar_file" rm -rf "$tmp_dir" return 1 fi rm -f "$tar_file" - log_success "Бекап зашифрован (AES-256-CBC)" + log_success "$(_t_or backup_encrypted 'Бекап зашифрован (AES-256-CBC)')" else final_file="${output_dir}/${backup_name}.tar.gz" mv "$tar_file" "$final_file" @@ -108,7 +119,11 @@ EOMETA local size size=$(du -h "$final_file" | cut -f1) - log_success "Бекап создан: $final_file ($size)" + 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 } @@ -119,7 +134,11 @@ restore_backup() { local password="$2" if [ ! -f "$backup_file" ]; then - log_error "Файл не найден: $backup_file" + 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 @@ -130,14 +149,14 @@ restore_backup() { local tar_file="" if echo "$backup_file" | grep -q '\.enc$'; then if [ -z "$password" ]; then - echo -ne " Введите пароль от бекапа: " + 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 "Неверный пароль или повреждённый файл" + log_error "$(_t_or backup_bad_pass 'Неверный пароль или повреждённый файл')" rm -rf "$tmp_dir" "$tar_file" return 1 fi @@ -148,7 +167,7 @@ restore_backup() { # Распаковываем tar xzf "$tar_file" -C "$tmp_dir" 2>/dev/null if [ $? -ne 0 ]; then - log_error "Ошибка распаковки архива" + log_error "$(_t_or backup_extract_err 'Ошибка распаковки архива')" rm -rf "$tmp_dir" return 1 fi @@ -160,18 +179,20 @@ restore_backup() { # Проверяем метаданные if [ -f "$backup_dir/metadata.json" ]; then - local bk_version bk_mode bk_ip + 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}📦 Бекап:${NC}" - echo -e " Версия: $bk_version | Режим: $bk_mode | IP: $bk_ip" - echo -e " Дата: $(jq -r '.created_at' "$backup_dir/metadata.json")" + 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 ! confirm "Восстановить конфигурацию? Текущие настройки будут перезаписаны."; then + if ! confirm "$(_t_or backup_confirm_restore 'Восстановить конфигурацию? Текущие настройки будут перезаписаны.')"; then rm -rf "$tmp_dir" return 0 fi @@ -185,14 +206,21 @@ restore_backup() { mkdir -p /etc/telemt cp "$backup_dir/config.toml" "$TELEMT_CONFIG" chmod 600 "$TELEMT_CONFIG" - log_success "telemt конфиг восстановлен" + log_success "$(_t_or backup_restored_telemt 'telemt конфиг восстановлен')" fi # Восстанавливаем GoTelegram конфиг if [ -f "$backup_dir/gotelegram.json" ]; then mkdir -p "$GOTELEGRAM_DIR" cp "$backup_dir/gotelegram.json" "$GOTELEGRAM_CONFIG" - log_success "GoTelegram конфиг восстановлен" + log_success "$(_t_or backup_restored_gotelegram 'GoTelegram конфиг восстановлен')" + 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 конфиг @@ -200,7 +228,7 @@ restore_backup() { 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 "nginx конфиг восстановлен" + log_success "$(_t_or backup_restored_nginx 'nginx конфиг восстановлен')" fi # Восстанавливаем SSL @@ -211,7 +239,7 @@ restore_backup() { local cert_dir="/etc/letsencrypt/live/$domain" mkdir -p "$cert_dir" cp "$backup_dir/certs/"* "$cert_dir/" 2>/dev/null - log_success "SSL сертификаты восстановлены" + log_success "$(_t_or backup_restored_ssl 'SSL сертификаты восстановлены')" fi fi @@ -220,7 +248,7 @@ restore_backup() { mkdir -p "$WEBSITE_ROOT" cp -r "$backup_dir/site"/* "$WEBSITE_ROOT/" chown -R www-data:www-data "$WEBSITE_ROOT" 2>/dev/null - log_success "Шаблон сайта восстановлен" + log_success "$(_t_or backup_restored_site 'Шаблон сайта восстановлен')" fi # Запускаем сервисы @@ -233,7 +261,7 @@ restore_backup() { rm -rf "$tmp_dir" [ "$tar_file" != "$backup_file" ] && rm -f "$tar_file" - log_success "Восстановление завершено!" + log_success "$(_t_or backup_restore_done 'Восстановление завершено!')" show_proxy_info return 0 } @@ -241,12 +269,12 @@ restore_backup() { # ── Список бекапов ─────────────────────────────────────────────────────────── list_backups() { if [ ! -d "$BACKUP_DIR" ] || [ -z "$(ls -A "$BACKUP_DIR" 2>/dev/null)" ]; then - log_info "Бекапов нет" + log_info "$(_t_or backup_none 'Бекапов нет')" return 1 fi echo "" - echo -e " ${BOLD}${WHITE}📦 Доступные бекапы:${NC}" + echo -e " ${BOLD}${WHITE}📦 $(_t_or backup_list_title 'Доступные бекапы'):${NC}" echo -e " ${DIM}$(printf '─%.0s' {1..60})${NC}" local i=1 @@ -276,31 +304,35 @@ cleanup_old_backups() { find "$BACKUP_DIR" -name "gotelegram_backup_*.tar.gz*" ! -name "*.sha256" 2>/dev/null | sort | head -n "$to_delete" | while read -r f; do rm -f "$f" "${f}.sha256" done - log_dim "Удалено $to_delete старых бекапов (оставлено $keep)" + if type tf &>/dev/null; then + log_dim "$(tf backup_cleanup_fmt "$to_delete" "$keep")" + else + log_dim "Удалено $to_delete старых бекапов (оставлено $keep)" + fi fi } # ── Интерактивный бекап ────────────────────────────────────────────────────── interactive_backup() { echo "" - echo -e " ${BOLD}${WHITE}💾 Создание бекапа${NC}" - echo -ne " Зашифровать бекап паролем? [Y/n]: " + 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 " Введите пароль: " + echo -ne " $(_t_or backup_enter_pass 'Введите пароль'): " read -rs password echo "" - echo -ne " Повторите пароль: " + echo -ne " $(_t_or backup_repeat_pass 'Повторите пароль'): " read -rs password2 echo "" if [ "$password" != "$password2" ]; then - log_error "Пароли не совпадают" + log_error "$(_t_or backup_pass_mismatch 'Пароли не совпадают')" return 1 fi if [ ${#password} -lt 6 ]; then - log_error "Пароль слишком короткий (минимум 6 символов)" + log_error "$(_t_or backup_pass_short 'Пароль слишком короткий (минимум 6 символов)')" return 1 fi fi @@ -313,7 +345,7 @@ interactive_backup() { interactive_restore() { list_backups || return 1 - echo -ne " Номер бекапа (или путь к файлу): " + echo -ne " $(_t_or backup_pick_prompt 'Номер бекапа (или путь к файлу)'): " read -r choice local backup_file="" @@ -333,7 +365,7 @@ interactive_restore() { fi if [ -z "$backup_file" ]; then - log_error "Бекап не найден" + log_error "$(_t_or backup_not_found 'Бекап не найден')" return 1 fi diff --git a/lib/common.sh b/lib/common.sh index ce5c649..a4d35ea 100755 --- a/lib/common.sh +++ b/lib/common.sh @@ -1,9 +1,9 @@ #!/bin/bash -# GoTelegram v2.3 — Общие утилиты -# Цвета, логирование, спиннер, системные функции, совместимость с v1 +# GoTelegram v2.4 — common utilities +# Colors, logging, spinner, system helpers, v1 compat, i18n-aware -# ── Версия ──────────────────────────────────────────────────────────────────── -GOTELEGRAM_VERSION="2.3.1" +# ── Version ─────────────────────────────────────────────────────────────────── +GOTELEGRAM_VERSION="2.4.0" GOTELEGRAM_NAME="GoTelegram" # ── Пути ────────────────────────────────────────────────────────────────────── @@ -49,10 +49,12 @@ log_to_file() { echo "[$ts] $*" >> "$LOG_FILE" 2>/dev/null } -# ── Спиннер ────────────────────────────────────────────────────────────────── +# ── Spinner ────────────────────────────────────────────────────────────────── _spin_pid="" spinner_start() { - local msg="${1:-Подождите...}" + local default_msg + default_msg=$(type t &>/dev/null && t wait || echo "Please wait...") + local msg="${1:-$default_msg}" ( local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏') local i=0 @@ -95,7 +97,9 @@ run_with_spinner() { if [ $rc -eq 0 ]; then log_success "$label" else - log_error "$label ${RED}(ошибка, код: $rc)${NC}" + 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 @@ -104,35 +108,45 @@ run_with_spinner() { return $rc } -# ── Баннер ─────────────────────────────────────────────────────────────────── +# ── Banner ─────────────────────────────────────────────────────────────────── show_banner() { + local line + line=$(printf '━%.0s' $(seq 1 60)) echo "" - echo -e "${CYAN}╔══════════════════════════════════════════════════════════╗${NC}" - echo -e "${CYAN}║${NC} ${BOLD}${WHITE}🚀 GoTelegram v${GOTELEGRAM_VERSION}${NC} ${CYAN}║${NC}" - echo -e "${CYAN}║${NC} ${DIM}MTProxy на ядре telemt (Rust + Tokio)${NC} ${CYAN}║${NC}" - echo -e "${CYAN}║${NC} ${DIM}Anti-DPI • Fake TLS • TCP Splice • JA3/JA4${NC} ${CYAN}║${NC}" - echo -e "${CYAN}╚══════════════════════════════════════════════════════════╝${NC}" + 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 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}╔══════════════════════════════════════════════════════════╗${NC}" - echo -e "${MAGENTA}║${NC} ${BOLD}Благодарности / Credits${NC} ${MAGENTA}║${NC}" - echo -e "${MAGENTA}╟──────────────────────────────────────────────────────────╢${NC}" - echo -e "${MAGENTA}║${NC} ${WHITE}telemt${NC} — MTProxy engine (Rust) ${MAGENTA}║${NC}" - echo -e "${MAGENTA}║${NC} ${DIM}github.com/telemt/telemt${NC} ${MAGENTA}║${NC}" - echo -e "${MAGENTA}║${NC} ${MAGENTA}║${NC}" - echo -e "${MAGENTA}║${NC} ${WHITE}HTML5 UP${NC} — адаптивные HTML/CSS шаблоны ${MAGENTA}║${NC}" - echo -e "${MAGENTA}║${NC} ${DIM}html5up.net • CC BY 3.0 • @ajlkn${NC} ${MAGENTA}║${NC}" - echo -e "${MAGENTA}║${NC} ${MAGENTA}║${NC}" - echo -e "${MAGENTA}║${NC} ${WHITE}learning-zone${NC} — 150+ HTML5 шаблонов ${MAGENTA}║${NC}" - echo -e "${MAGENTA}║${NC} ${DIM}github.com/learning-zone/website-templates${NC} ${MAGENTA}║${NC}" - echo -e "${MAGENTA}║${NC} ${MAGENTA}║${NC}" - echo -e "${MAGENTA}║${NC} ${WHITE}Start Bootstrap${NC} — MIT лицензия ${MAGENTA}║${NC}" - echo -e "${MAGENTA}║${NC} ${DIM}startbootstrap.com${NC} ${MAGENTA}║${NC}" - echo -e "${MAGENTA}╚══════════════════════════════════════════════════════════╝${NC}" + 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 "" } @@ -164,31 +178,41 @@ get_server_ip() { 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 "Запустите скрипт с sudo / от root" + 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 "Не удалось определить ОС. Требуется Linux." + 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 содержит подозрительные строки, пропускаем" + 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 "ОС: $PRETTY_NAME" + log_dim "OS: $PRETTY_NAME" return 0 ;; *) - log_warning "ОС $ID может быть несовместима. Поддерживаются: Ubuntu, Debian, CentOS, Rocky." + log_warning "OS $ID may be incompatible. Supported: Ubuntu, Debian, CentOS, Rocky." return 0 ;; esac @@ -219,7 +243,7 @@ install_pkg() { apt) apt-get install -y -qq "$pkg" ;; dnf) dnf install -y -q "$pkg" ;; yum) yum install -y -q "$pkg" ;; - *) log_error "Неизвестный пакетный менеджер"; return 1 ;; + *) log_error "$(_t_or err_bad_pkg_mgr 'Unknown package manager')"; return 1 ;; esac } @@ -231,7 +255,11 @@ ensure_deps() { fi done if [ ${#missing[@]} -gt 0 ]; then - log_step "Установка зависимостей: ${missing[*]}" + if type tf &>/dev/null; then + log_step "$(tf deps_installing "${missing[*]}")" + else + log_step "Installing dependencies: ${missing[*]}" + fi case "$(get_pkg_manager)" in apt) apt-get update -qq && apt-get install -y -qq "${missing[@]}" ;; dnf) dnf install -y -q "${missing[@]}" ;; @@ -257,7 +285,11 @@ check_disk_space() { local avail_mb avail_mb=$(df -m / | awk 'NR==2 {print $4}') if [ "$avail_mb" -lt "$min_mb" ]; then - log_error "Мало места на диске: ${avail_mb}MB (нужно ${min_mb}MB+)" + 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 @@ -266,6 +298,8 @@ check_disk_space() { # ── Конфигурация 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", @@ -276,6 +310,7 @@ save_gotelegram_config() { "mask_host": "${5:-google.com}", "domain": "${6:-}", "template_id": "${7:-}", + "language": "${cur_lang}", "installed_at": "$(date -Iseconds)", "updated_at": "$(date -Iseconds)" } @@ -295,13 +330,11 @@ load_gotelegram_config() { config_get() { local key="$1" if [ ! -f "$GOTELEGRAM_CONFIG" ]; then - log_dim "Конфиг не найден: $GOTELEGRAM_CONFIG" >&2 return 2 # file missing fi local val val=$(jq -r ".$key // empty" "$GOTELEGRAM_CONFIG" 2>/dev/null) if [ $? -ne 0 ]; then - log_dim "Ошибка чтения JSON: $GOTELEGRAM_CONFIG" >&2 return 3 # invalid JSON fi if [ -z "$val" ]; then @@ -361,7 +394,7 @@ get_v1_config() { } migrate_v1_to_v2() { - log_step "Миграция с v1 (mtg) на v2 (telemt)" + log_step "$(_t_or v1_migration_step 'Migrating from v1 (mtg) to v2 (telemt)')" local v1_config v1_config=$(get_v1_config) @@ -371,44 +404,59 @@ migrate_v1_to_v2() { old_secret=$(echo "$v1_config" | jq -r '.secret // empty') if [ -z "$old_secret" ]; then - log_warning "Не удалось извлечь secret из v1. Будет создан новый." + log_warning "Failed to extract secret from v1. A new one will be generated." return 1 fi echo "" - echo -e " ${WHITE}Найдена установка v1 (mtg):${NC}" - echo -e " Порт: ${CYAN}${old_port}${NC}" - echo -e " Secret: ${CYAN}${old_secret:0:16}...${NC}" + 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}Внимание:${NC} секрет mtg НЕ совместим с telemt напрямую." - echo -e " Клиентам потребуется новая ссылка." + 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 " Остановить v1 контейнер и перейти на v2? [Y/n]: " + 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 "Миграция отменена. v1 оставлен без изменений." + log_info "$(_t_or v1_migration_cancelled 'Migration cancelled. v1 left intact.')" return 1 fi - # Останавливаем v1 - log_info "Остановка v1 контейнера..." + # 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 - # Бекапим v1 конфиг + # 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 - log_success "Конфиг v1 сохранён в $GOTELEGRAM_DIR/v1_backup_proxy.json" + 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 - log_success "v1 остановлен. Порт $old_port освобождён." + 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 msg="${1:-Продолжить?}" + 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] ]] @@ -429,7 +477,7 @@ select_option() { ((i++)) done echo -e " ${DIM}$(printf '─%.0s' {1..50})${NC}" >&2 - echo -ne " ${WHITE}Выбор:${NC} " >&2 + echo -ne " ${WHITE}$(_t_or choose 'Choose'):${NC} " >&2 read -r choice echo "$choice" } diff --git a/lib/i18n.sh b/lib/i18n.sh new file mode 100755 index 0000000..ca8107a --- /dev/null +++ b/lib/i18n.sh @@ -0,0 +1,140 @@ +#!/bin/bash +# GoTelegram v2.4 — 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 100755 index 0000000..fd3046e --- /dev/null +++ b/lib/lang/en.sh @@ -0,0 +1,375 @@ +#!/bin/bash +# GoTelegram v2.4 — 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 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:" +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 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 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" +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 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_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_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 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 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 100755 index 0000000..6101c23 --- /dev/null +++ b/lib/lang/ru.sh @@ -0,0 +1,375 @@ +#!/bin/bash +# GoTelegram v2.4 — 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 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:" +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 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 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" +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 полностью удалён (прокси + бот)" + +# ── 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_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_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 конфиг восстановлен" +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 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 deleted file mode 100755 index adcee19..0000000 --- a/lib/stats.sh +++ /dev/null @@ -1,406 +0,0 @@ -#!/bin/bash -# stats.sh — Traffic statistics module for GoTelegram -# 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" -SNAPSHOTS_DIR="$STATS_DIR/snapshots" -CURRENT_SNAPSHOT="$STATS_DIR/stats_current.json" -CONFIG_FILE="/opt/gotelegram/config.json" - -# Initialize stats infrastructure -stats_init() { - # 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 - - # 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) - - # 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) - 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 old entries (keep only 365 days) - stats_cleanup_history - fi - fi - - rm -f "$temp_file" 2>/dev/null -} - -# 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 -} - -# Clean up history older than 365 days -stats_cleanup_history() { - if [[ ! -f "$HISTORY_FILE" ]]; then - return - fi - - local now=$(date +%s) - local ts_365d=$((now - 31536000)) - local temp_file=$(mktemp) - - # Keep header + entries from last 365 days - { - head -1 "$HISTORY_FILE" - awk -F, -v ts="$ts_365d" '$1 >= ts' "$HISTORY_FILE" | tail -n +2 - } > "$temp_file" 2>/dev/null - - mv "$temp_file" "$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 - - # 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 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 start gotelegram-stats.service - - 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" 2>/dev/null - - echo "Сервис статистики удалён" >&2 -} - -# Export functions for external use -export -f stats_init stats_collect stats_read_current stats_calculate_rates -export -f show_traffic_stats format_bytes format_rate toggle_stats -export -f stats_cleanup_history install_stats_collector remove_stats_collector -export -f json_get diff --git a/lib/templates_catalog.sh b/lib/templates_catalog.sh index 0060493..01503a1 100755 --- a/lib/templates_catalog.sh +++ b/lib/templates_catalog.sh @@ -1,20 +1,29 @@ #!/bin/bash -# GoTelegram v2.2 — Каталог шаблонов сайтов -# Выбор из ~200 шаблонов, превью-ссылки, скачивание через git sparse-checkout +# GoTelegram v2.4 — 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 - log_error "Каталог шаблонов не найден: $CATALOG_FILE" + 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 } @@ -24,13 +33,13 @@ get_category_name() { 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 @@ -42,16 +51,19 @@ get_template_field() { 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}📂 Категории шаблонов сайтов:${NC}" >&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=1 + local i=2 while IFS='|' read -r id name icon count; do [ "$count" -eq 0 ] && continue local emoji @@ -67,40 +79,52 @@ select_category() { chart-bar) emoji="🔧" ;; *) emoji="📄" ;; esac - printf " ${CYAN}%2d)${NC} ${emoji} %-30s ${DIM}(%d шаблонов)${NC}\n" "$i" "$name" "$count" >&2 + 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} 🎲 Случайный шаблон\n" "$i" >&2 + printf " ${CYAN}%2d)${NC} %s\n" "$i" "$(t templates_random)" >&2 echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" >&2 - echo -ne " ${WHITE}Выбор:${NC} " >&2 + echo -ne " ${WHITE}$(t choose):${NC} " >&2 read -r choice - # Случайный - if [ "$choice" -eq "$i" ] 2>/dev/null; then + 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 - if [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -lt "$i" ]; then - echo "${cats[$((choice-1))]}" + # 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 "Неверный выбор" + 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}📋 $cat_name — доступные шаблоны:${NC}" >&2 + echo -e " ${BOLD}${WHITE}$(tf templates_list "$cat_name")${NC}" >&2 echo -e " ${DIM}$(printf '─%.0s' {1..60})${NC}" >&2 local tpls=() @@ -112,29 +136,29 @@ select_template() { done < <(get_templates_by_category "$cat_id") if [ ${#tpls[@]} -eq 0 ]; then - log_info "В этой категории нет шаблонов" + log_info "$(t templates_cat_empty)" return 1 fi echo -e " ${DIM}$(printf '─%.0s' {1..60})${NC}" >&2 - echo -ne " ${WHITE}Выбор (1-$((i-1))):${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_template_preview "$selected_id" + # Show preview + show_template_preview "$selected_id" || return 1 echo "$selected_id" return 0 fi - log_error "Неверный выбор" + log_error "$(t invalid_choice)" return 1 } -# ── Показ превью шаблона ──────────────────────────────────────────────────── +# ── Template preview ─────────────────────────────────────────────────── show_template_preview() { local tpl_id="$1" local info @@ -148,36 +172,36 @@ show_template_preview() { description=$(echo "$info" | jq -r '.description // "—"') echo "" >&2 - echo -e " ${BOLD}${WHITE}🔍 Превью шаблона:${NC}" >&2 + echo -e " ${BOLD}${WHITE}$(t templates_preview_title)${NC}" >&2 echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" >&2 - echo -e " ${WHITE}Название:${NC} $name" >&2 - echo -e " ${WHITE}Источник:${NC} $source" >&2 - echo -e " ${WHITE}Описание:${NC} $description" >&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}👁 Превью:${NC} ${CYAN}${preview_url}${NC}" >&2 - echo -e " ${DIM}Откройте ссылку в браузере для просмотра шаблона${NC}" >&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}📦 Репо: ${repo_url}${NC}" >&2 + echo -e " ${DIM}$(t templates_repo) ${repo_url}${NC}" >&2 fi - # Благодарность автору + # Thanks echo "" >&2 - echo -e " ${MAGENTA}💜 Спасибо авторам ${source} за открытый код!${NC}" >&2 + echo -e " ${MAGENTA}$(tf templates_thanks "$source")${NC}" >&2 echo -e " ${DIM}$(printf '─%.0s' {1..55})${NC}" >&2 echo "" >&2 - if ! confirm "Установить этот шаблон?"; then + 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}" @@ -194,9 +218,9 @@ download_template() { rm -rf "$clone_dir" mkdir -p "$clone_dir" - log_info "Скачивание шаблона \"$name\"..." + log_info "$(tf templates_downloading "$name")" - # Для HTML5 UP — отдельный репо с папками + # HTML5 UP — one repo with folders if [ "$source" = "html5up" ]; then local tmp_clone="/tmp/html5up_clone_$$" rm -rf "$tmp_clone" @@ -204,7 +228,7 @@ download_template() { # Sparse checkout git clone --depth 1 --filter=blob:none --sparse "$repo_url" "$tmp_clone" 2>/dev/null if [ $? -ne 0 ]; then - # Fallback: полный clone + # Fallback: full clone git clone --depth 1 "$repo_url" "$tmp_clone" 2>/dev/null fi @@ -217,7 +241,7 @@ download_template() { fi rm -rf "$tmp_clone" - # Для learning-zone — один большой репо + # learning-zone — one big repo elif [ "$source" = "learning-zone" ]; then local tmp_clone="/tmp/lz_clone_$$" rm -rf "$tmp_clone" @@ -236,14 +260,14 @@ download_template() { fi rm -rf "$tmp_clone" - # Для StartBootstrap — каждый шаблон в своём репо + # 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 хранит production-файлы в dist/ + # 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 @@ -260,7 +284,7 @@ download_template() { fi rm -rf "$sb_tmp" - # Для ThemeWagon / ColorlibHQ — каждый шаблон в отдельном репо + # ThemeWagon / ColorlibHQ — each template in its own repo elif [ "$source" = "themewagon" ] || [ "$source" = "colorlib" ]; then local tw_tmp="/tmp/tw_clone_$$" rm -rf "$tw_tmp" @@ -283,7 +307,7 @@ download_template() { fi rm -rf "$tw_tmp" - # Для dawidolko — один большой репо с папками (как learning-zone) + # dawidolko — one big repo with folders (similar to learning-zone) elif [ "$source" = "dawidolko" ]; then local tmp_clone="/tmp/dw_clone_$$" rm -rf "$tmp_clone" @@ -301,13 +325,13 @@ download_template() { rm -rf "$tmp_clone" fi - # Проверяем результат + # Check result if [ -f "$clone_dir/index.html" ]; then - log_success "Шаблон \"$name\" скачан" + log_success "$(tf templates_downloaded "$name")" echo "$clone_dir" return 0 else - # fallback: ищем index.html в подпапках (нестандартная структура) + # 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 @@ -315,33 +339,253 @@ download_template() { fallback_dir=$(dirname "$fallback_index") if [ "$fallback_dir" != "$clone_dir" ]; then cp -r "$fallback_dir/"* "$clone_dir/" - log_success "Шаблон \"$name\" скачан (из подпапки)" + log_success "$(tf templates_downloaded_subfolder "$name")" echo "$clone_dir" return 0 fi fi - log_error "Шаблон не содержит index.html" - log_dim "Путь: $clone_dir" + 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