From af3ad880c6f229d3e48529ca9ff258bfefc1b834 Mon Sep 17 00:00:00 2001 From: Whispering Wind <151555003+ReturnFI@users.noreply.github.com> Date: Mon, 25 Aug 2025 22:59:04 +0330 Subject: [PATCH 01/12] Set user creation_date to None by default --- core/scripts/hysteria2/add_user.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/core/scripts/hysteria2/add_user.py b/core/scripts/hysteria2/add_user.py index ec23b0a..d9853e7 100644 --- a/core/scripts/hysteria2/add_user.py +++ b/core/scripts/hysteria2/add_user.py @@ -18,7 +18,7 @@ def add_user(username, traffic_gb, expiration_days, password=None, creation_date traffic_gb (str): The traffic limit in GB. expiration_days (str): The number of days until the account expires. password (str, optional): The user's password. If None, a random one is generated. - creation_date (str, optional): The account creation date in YYYY-MM-DD format. If None, the current date is used. + creation_date (str, optional): The account creation date in YYYY-MM-DD format. Defaults to None. unlimited_user (bool, optional): If True, user is exempt from IP limits. Defaults to False. Returns: @@ -48,9 +48,7 @@ def add_user(username, traffic_gb, expiration_days, password=None, creation_date print("Error: Failed to generate password. Please install 'pwgen' or ensure /proc access.") return 1 - if not creation_date: - creation_date = datetime.now().strftime("%Y-%m-%d") - else: + if creation_date: if not re.match(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}$", creation_date): print("Invalid date format. Expected YYYY-MM-DD.") return 1 @@ -59,6 +57,8 @@ def add_user(username, traffic_gb, expiration_days, password=None, creation_date except ValueError: print("Invalid date. Please provide a valid date in YYYY-MM-DD format.") return 1 + else: + creation_date = None if not re.match(r"^[a-zA-Z0-9_]+$", username): print("Error: Username can only contain letters, numbers, and underscores.") From 9a438959536f9c9a4fb75f6cecff13705acc88a5 Mon Sep 17 00:00:00 2001 From: Whispering Wind <151555003+ReturnFI@users.noreply.github.com> Date: Mon, 25 Aug 2025 23:08:23 +0330 Subject: [PATCH 02/12] feat(core): implement on-hold user activation --- core/scripts/scheduler.py | 47 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/core/scripts/scheduler.py b/core/scripts/scheduler.py index a733a1c..5cc0e06 100644 --- a/core/scripts/scheduler.py +++ b/core/scripts/scheduler.py @@ -7,6 +7,7 @@ import logging import subprocess import fcntl import datetime +import json from pathlib import Path from paths import * @@ -23,7 +24,6 @@ logger = logging.getLogger("HysteriaScheduler") # Constants BASE_DIR = Path("/etc/hysteria") VENV_ACTIVATE = BASE_DIR / "hysteria2_venv/bin/activate" -# CLI_PATH = BASE_DIR / "core/cli.py" LOCK_FILE = "/tmp/hysteria_scheduler.lock" def acquire_lock(): @@ -41,7 +41,6 @@ def release_lock(lock_fd): lock_fd.close() def run_command(command, log_success=False): - activate_cmd = f"source {VENV_ACTIVATE}" full_cmd = f"{activate_cmd} && {command}" @@ -73,7 +72,46 @@ def check_traffic_status(): try: success = run_command(f"python3 {CLI_PATH} traffic-status --no-gui", log_success=False) if not success: - pass + logger.error("Failed to run traffic-status command. Aborting check.") + return + + if not os.path.exists(USERS_FILE): + logger.warning(f"{USERS_FILE} not found. Skipping on-hold user check.") + return + + try: + with open(USERS_FILE, 'r') as f: + users_data = json.load(f) + except (json.JSONDecodeError, IOError) as e: + logger.error(f"Error reading or parsing {USERS_FILE}: {e}") + return + + users_updated = False + today_date = datetime.datetime.now().strftime("%Y-%m-%d") + + for username, user_data in users_data.items(): + is_on_hold = not user_data.get("account_creation_date") + + if is_on_hold: + is_online = user_data.get("status") == "Online" + + if is_online: + logger.info(f"On-hold user '{username}' connected. Activating account with creation date {today_date}.") + user_data["account_creation_date"] = today_date + users_updated = True + else: + if user_data.get("status") != "On-hold": + user_data["status"] = "On-hold" + users_updated = True + + if users_updated: + try: + with open(USERS_FILE, 'w') as f: + json.dump(users_data, f, indent=4) + logger.info("Successfully updated users.json for on-hold users.") + except IOError as e: + logger.error(f"Error writing updates to {USERS_FILE}: {e}") + finally: release_lock(lock_fd) @@ -94,7 +132,10 @@ def main(): schedule.every(1).minutes.do(check_traffic_status) schedule.every(6).hours.do(backup_hysteria) + # logger.info("Performing initial runs on startup...") + check_traffic_status() backup_hysteria() + # logger.info("Initial runs complete. Entering main loop.") while True: try: From 9b12a28bbbf4f1c4671fa1dc93d2362dbe5861bc Mon Sep 17 00:00:00 2001 From: Whispering Wind <151555003+ReturnFI@users.noreply.github.com> Date: Mon, 25 Aug 2025 23:12:17 +0330 Subject: [PATCH 03/12] fix(webpanel): Improve user data and status handling - Correctly handle malformed user data. - Accurately display status for on-hold users. --- .../webpanel/routers/user/viewmodel.py | 53 +++++++++++++------ 1 file changed, 36 insertions(+), 17 deletions(-) diff --git a/core/scripts/webpanel/routers/user/viewmodel.py b/core/scripts/webpanel/routers/user/viewmodel.py index 121410b..eed9cee 100644 --- a/core/scripts/webpanel/routers/user/viewmodel.py +++ b/core/scripts/webpanel/routers/user/viewmodel.py @@ -20,30 +20,49 @@ class User(BaseModel): @staticmethod def __parse_user_data(user_data: dict) -> dict: - expiration_days = user_data.get('expiration_days', 0) + essential_keys = [ + 'password', + 'max_download_bytes', + 'expiration_days', + 'blocked', + 'unlimited_user' + ] - if expiration_days > 0: - creation_date_str = user_data.get("account_creation_date") - display_expiry_days = str(expiration_days) - - if isinstance(creation_date_str, str): - try: - creation_date = datetime.strptime(creation_date_str, "%Y-%m-%d") - expiry_dt_obj = creation_date + timedelta(days=expiration_days) - display_expiry_date = expiry_dt_obj.strftime("%Y-%m-%d") - except ValueError: - display_expiry_date = "Error" - else: - display_expiry_date = "Error" - else: + if not all(key in user_data for key in essential_keys): + return { + 'username': user_data.get('username', 'Unknown'), + 'status': 'Conflict', + 'quota': 'N/A', + 'traffic_used': 'N/A', + 'expiry_date': 'N/A', + 'expiry_days': 'N/A', + 'enable': False, + 'unlimited_ip': False + } + + expiration_days = user_data.get('expiration_days', 0) + creation_date_str = user_data.get("account_creation_date") + + if not creation_date_str: + display_expiry_days = "On-hold" + display_expiry_date = "On-hold" + elif expiration_days <= 0: display_expiry_days = "Unlimited" display_expiry_date = "Unlimited" + else: + display_expiry_days = str(expiration_days) + try: + creation_date = datetime.strptime(creation_date_str, "%Y-%m-%d") + expiry_dt_obj = creation_date + timedelta(days=expiration_days) + display_expiry_date = expiry_dt_obj.strftime("%Y-%m-%d") + except (ValueError, TypeError): + display_expiry_date = "Error" used_bytes = user_data.get("download_bytes", 0) + user_data.get("upload_bytes", 0) quota_bytes = user_data.get('max_download_bytes', 0) used_formatted = User.__format_traffic(used_bytes) - quota_formatted = "Unlimited" if quota_bytes == 0 else User.__format_traffic(quota_bytes) + quota_formatted = "Unlimited" if quota_bytes <= 0 else User.__format_traffic(quota_bytes) percentage = 0 if quota_bytes > 0: @@ -64,7 +83,7 @@ class User(BaseModel): @staticmethod def __format_traffic(traffic_bytes) -> str: - if traffic_bytes == 0: + if traffic_bytes <= 0: return "0 B" if traffic_bytes < 1024: return f'{traffic_bytes} B' From 28062074bf662ecf21cf41c620c6e4f44c8d643a Mon Sep 17 00:00:00 2001 From: Whispering Wind <151555003+ReturnFI@users.noreply.github.com> Date: Tue, 26 Aug 2025 00:14:25 +0330 Subject: [PATCH 04/12] refactor(core): centralize on-hold user logic in traffic.py --- core/scripts/hysteria2/bulk_users.py | 2 +- core/scripts/scheduler.py | 47 +----- core/scripts/webpanel/routers/api/v1/user.py | 1 + core/traffic.py | 159 +++++++------------ 4 files changed, 61 insertions(+), 148 deletions(-) diff --git a/core/scripts/hysteria2/bulk_users.py b/core/scripts/hysteria2/bulk_users.py index b1f4618..1fdd23f 100644 --- a/core/scripts/hysteria2/bulk_users.py +++ b/core/scripts/hysteria2/bulk_users.py @@ -35,7 +35,7 @@ def add_bulk_users(traffic_gb, expiration_days, count, prefix, start_number, unl existing_users_lower = {u.lower() for u in users_data} new_users_to_add = {} - creation_date = datetime.now().strftime("%Y-%m-%d") + creation_date = None try: password_process = subprocess.run(['pwgen', '-s', '32', str(count)], capture_output=True, text=True, check=True) diff --git a/core/scripts/scheduler.py b/core/scripts/scheduler.py index 5cc0e06..ff3b2e1 100644 --- a/core/scripts/scheduler.py +++ b/core/scripts/scheduler.py @@ -1,13 +1,9 @@ #!/usr/bin/env python3 -import os -import sys import time import schedule import logging import subprocess import fcntl -import datetime -import json from pathlib import Path from paths import * @@ -72,46 +68,7 @@ def check_traffic_status(): try: success = run_command(f"python3 {CLI_PATH} traffic-status --no-gui", log_success=False) if not success: - logger.error("Failed to run traffic-status command. Aborting check.") - return - - if not os.path.exists(USERS_FILE): - logger.warning(f"{USERS_FILE} not found. Skipping on-hold user check.") - return - - try: - with open(USERS_FILE, 'r') as f: - users_data = json.load(f) - except (json.JSONDecodeError, IOError) as e: - logger.error(f"Error reading or parsing {USERS_FILE}: {e}") - return - - users_updated = False - today_date = datetime.datetime.now().strftime("%Y-%m-%d") - - for username, user_data in users_data.items(): - is_on_hold = not user_data.get("account_creation_date") - - if is_on_hold: - is_online = user_data.get("status") == "Online" - - if is_online: - logger.info(f"On-hold user '{username}' connected. Activating account with creation date {today_date}.") - user_data["account_creation_date"] = today_date - users_updated = True - else: - if user_data.get("status") != "On-hold": - user_data["status"] = "On-hold" - users_updated = True - - if users_updated: - try: - with open(USERS_FILE, 'w') as f: - json.dump(users_data, f, indent=4) - logger.info("Successfully updated users.json for on-hold users.") - except IOError as e: - logger.error(f"Error writing updates to {USERS_FILE}: {e}") - + pass finally: release_lock(lock_fd) @@ -132,10 +89,8 @@ def main(): schedule.every(1).minutes.do(check_traffic_status) schedule.every(6).hours.do(backup_hysteria) - # logger.info("Performing initial runs on startup...") check_traffic_status() backup_hysteria() - # logger.info("Initial runs complete. Entering main loop.") while True: try: diff --git a/core/scripts/webpanel/routers/api/v1/user.py b/core/scripts/webpanel/routers/api/v1/user.py index 2164a48..6bdd238 100644 --- a/core/scripts/webpanel/routers/api/v1/user.py +++ b/core/scripts/webpanel/routers/api/v1/user.py @@ -149,6 +149,7 @@ async def remove_user_api(username: str): cli_api.kick_user_by_name(username) cli_api.traffic_status(display_output=False) cli_api.remove_user(username) + cli_api.traffic_status(display_output=False) return DetailResponse(detail=f'User {username} has been removed.') except HTTPException: diff --git a/core/traffic.py b/core/traffic.py index 3ec1363..f27244a 100644 --- a/core/traffic.py +++ b/core/traffic.py @@ -16,16 +16,6 @@ LOCKFILE = "/tmp/kick.lock" BACKUP_FILE = f"{USERS_FILE}.bak" MAX_WORKERS = 8 -# import logging -# logging.basicConfig( -# level=logging.INFO, -# format='%(asctime)s: [%(levelname)s] %(message)s', -# datefmt='%Y-%m-%d %H:%M:%S' -# ) -# logger = logging.getLogger() -# null_handler = logging.NullHandler() -# logger.handlers = [null_handler] - def acquire_lock(): """Acquires a lock file to prevent concurrent execution""" try: @@ -36,14 +26,7 @@ def acquire_lock(): sys.exit(1) def traffic_status(no_gui=False): - """Updates and retrieves traffic statistics for all users. - - Args: - no_gui (bool): If True, suppresses output to console - - Returns: - dict: User data including upload/download bytes and status - """ + """Updates and retrieves traffic statistics for all users.""" green = '\033[0;32m' cyan = '\033[0;36m' NC = '\033[0m' @@ -90,8 +73,7 @@ def traffic_status(no_gui=False): users_data[user_id]["status"] = "Online" if status.is_online else "Offline" else: users_data[user_id] = { - "upload_bytes": 0, - "download_bytes": 0, + "upload_bytes": 0, "download_bytes": 0, "status": "Online" if status.is_online else "Offline" } @@ -102,11 +84,23 @@ def traffic_status(no_gui=False): else: online = user_id in online_status and online_status[user_id].is_online users_data[user_id] = { - "upload_bytes": stats.upload_bytes, - "download_bytes": stats.download_bytes, + "upload_bytes": stats.upload_bytes, "download_bytes": stats.download_bytes, "status": "Online" if online else "Offline" } + today_date = datetime.datetime.now().strftime("%Y-%m-%d") + for username, user_data in users_data.items(): + is_on_hold = not user_data.get("account_creation_date") + + if is_on_hold: + is_online = user_data.get("status") == "Online" + has_traffic = user_data.get("download_bytes", 0) > 0 or user_data.get("upload_bytes", 0) > 0 + + if is_online or has_traffic: + user_data["account_creation_date"] = today_date + else: + user_data["status"] = "On-hold" + with open(USERS_FILE, 'w') as users_file: json.dump(users_data, users_file, indent=4) @@ -137,66 +131,49 @@ def display_traffic_data(data, green, cyan, NC): print(f"{user:<15} {green}{formatted_tx:<15}{NC} {cyan}{formatted_rx:<15}{NC} {status:<10}") print("-------------------------------------------------") -def format_bytes(bytes): +def format_bytes(bytes_val): """Format bytes as human-readable string""" - if bytes < 1024: - return f"{bytes}B" - elif bytes < 1048576: - return f"{bytes / 1024:.2f}KB" - elif bytes < 1073741824: - return f"{bytes / 1048576:.2f}MB" - elif bytes < 1099511627776: - return f"{bytes / 1073741824:.2f}GB" - else: - return f"{bytes / 1099511627776:.2f}TB" + if bytes_val < 1024: return f"{bytes_val}B" + elif bytes_val < 1048576: return f"{bytes_val / 1024:.2f}KB" + elif bytes_val < 1073741824: return f"{bytes_val / 1048576:.2f}MB" + elif bytes_val < 1099511627776: return f"{bytes_val / 1073741824:.2f}GB" + else: return f"{bytes_val / 1099511627776:.2f}TB" def kick_users(usernames, secret): """Kicks specified users from the server""" try: - client = Hysteria2Client( - base_url=API_BASE_URL, - secret=secret - ) - + client = Hysteria2Client(base_url=API_BASE_URL, secret=secret) client.kick_clients(usernames) return True except Exception: return False -def process_user(username, user_data, config_secret, users_data): +def process_user(username, user_data, users_data): """Process a single user to check if they should be kicked""" - blocked = user_data.get('blocked', False) - - if blocked: - return None + if user_data.get('blocked', False): return None + account_creation_date = user_data.get('account_creation_date') + if not account_creation_date: return None + max_download_bytes = user_data.get('max_download_bytes', 0) expiration_days = user_data.get('expiration_days', 0) - account_creation_date = user_data.get('account_creation_date') - current_download_bytes = user_data.get('download_bytes', 0) - current_upload_bytes = user_data.get('upload_bytes', 0) - - total_bytes = current_download_bytes + current_upload_bytes - - if not account_creation_date: - return None + total_bytes = user_data.get('download_bytes', 0) + user_data.get('upload_bytes', 0) + should_block = False try: - current_date = datetime.datetime.now().timestamp() - creation_date = datetime.datetime.fromisoformat(account_creation_date.replace('Z', '+00:00')) - expiration_date = (creation_date + datetime.timedelta(days=expiration_days)).timestamp() - - should_block = False - - if max_download_bytes > 0 and total_bytes >= 0 and expiration_days > 0: - if total_bytes >= max_download_bytes or current_date >= expiration_date: + if expiration_days > 0: + creation_date = datetime.datetime.strptime(account_creation_date, "%Y-%m-%d") + expiration_date = creation_date + datetime.timedelta(days=expiration_days) + if datetime.datetime.now() >= expiration_date: should_block = True - - if should_block: - users_data[username]['blocked'] = True - return username - except Exception: + if not should_block and max_download_bytes > 0 and total_bytes >= max_download_bytes: + should_block = True + + if should_block: + users_data[username]['blocked'] = True + return username + except (ValueError, TypeError): return None return None @@ -206,59 +183,39 @@ def kick_expired_users(): lock_file = acquire_lock() try: + if not os.path.exists(USERS_FILE): return shutil.copy2(USERS_FILE, BACKUP_FILE) try: with open(CONFIG_FILE, 'r') as f: config = json.load(f) secret = config.get('trafficStats', {}).get('secret', '') - if not secret: - sys.exit(1) + if not secret: sys.exit(1) except Exception: - shutil.copy2(BACKUP_FILE, USERS_FILE) sys.exit(1) - try: - with open(USERS_FILE, 'r') as f: - users_data = json.load(f) - except json.JSONDecodeError: - shutil.copy2(BACKUP_FILE, USERS_FILE) - sys.exit(1) - except Exception: - shutil.copy2(BACKUP_FILE, USERS_FILE) - sys.exit(1) + with open(USERS_FILE, 'r') as f: + users_data = json.load(f) users_to_kick = [] with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor: - future_to_user = { - executor.submit(process_user, username, user_data, secret, users_data): username - for username, user_data in users_data.items() - } + futures = [executor.submit(process_user, u, d, users_data) for u, d in users_data.items()] + for future in futures: + result = future.result() + if result: + users_to_kick.append(result) + + if users_to_kick: + with open(USERS_FILE, 'w') as f: + json.dump(users_data, f, indent=4) - for future in future_to_user: - username = future.result() - if username: - users_to_kick.append(username) - - if users_to_kick: - for retry in range(3): - try: - with open(USERS_FILE, 'w') as f: - json.dump(users_data, f, indent=2) - break - except Exception: - time.sleep(1) - if retry == 2: - raise - - if users_to_kick: - batch_size = 50 - for i in range(0, len(users_to_kick), batch_size): - batch = users_to_kick[i:i+batch_size] + for i in range(0, len(users_to_kick), 50): + batch = users_to_kick[i:i+50] kick_users(batch, secret) except Exception: - shutil.copy2(BACKUP_FILE, USERS_FILE) + if os.path.exists(BACKUP_FILE): + shutil.copy2(BACKUP_FILE, USERS_FILE) sys.exit(1) finally: fcntl.flock(lock_file, fcntl.LOCK_UN) From 368205222536a9939fca229cb40072ceccec98c5 Mon Sep 17 00:00:00 2001 From: Whispering Wind <151555003+ReturnFI@users.noreply.github.com> Date: Tue, 26 Aug 2025 00:23:39 +0330 Subject: [PATCH 05/12] Clone hold branch --- install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install.sh b/install.sh index d463b2f..7f6db92 100644 --- a/install.sh +++ b/install.sh @@ -123,7 +123,7 @@ clone_repository() { fi fi - if git clone https://github.com/ReturnFI/Blitz /etc/hysteria &> /dev/null; then + if git clone -b hold https://github.com/ReturnFI/Blitz /etc/hysteria &> /dev/null; then log_success "Repository cloned successfully" else log_error "Failed to clone repository" From eef7342bc318e5f84e8a4242833e2ead56fb0874 Mon Sep 17 00:00:00 2001 From: Whispering Wind <151555003+ReturnFI@users.noreply.github.com> Date: Tue, 26 Aug 2025 00:24:27 +0330 Subject: [PATCH 06/12] feat(webpanel): add distinct colors for user statuses --- core/scripts/webpanel/templates/users.html | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/core/scripts/webpanel/templates/users.html b/core/scripts/webpanel/templates/users.html index a22154f..67c89fc 100644 --- a/core/scripts/webpanel/templates/users.html +++ b/core/scripts/webpanel/templates/users.html @@ -98,6 +98,10 @@ Online {% elif user['status'] == "Offline" %} Offline + {% elif user['status'] == "On-hold" %} + On-hold + {% elif user['status'] == "Conflict" %} + Conflict {% else %} {{ user['status'] }} {% endif %} @@ -575,7 +579,7 @@ const blocked = row.find("td:eq(7) i").hasClass("text-danger"); const unlimited_ip = row.find(".unlimited-ip-cell i").hasClass("text-primary"); - const expiryDaysValue = (expiryDaysText.toLowerCase() === 'unlimited') ? 0 : parseInt(expiryDaysText, 10); + const expiryDaysValue = (expiryDaysText.toLowerCase() === 'unlimited' || expiryDaysText.toLowerCase() === 'on-hold') ? 0 : parseInt(expiryDaysText, 10); let trafficLimitValue = 0; if (!trafficUsageText.toLowerCase().includes('/unlimited')) { From bd0124446d62e575c37a05c157e327c926a75a33 Mon Sep 17 00:00:00 2001 From: Whispering Wind <151555003+ReturnFI@users.noreply.github.com> Date: Wed, 27 Aug 2025 16:36:46 +0330 Subject: [PATCH 07/12] fix(bot): handle escaped underscores in usernames --- core/scripts/telegrambot/utils/adduser.py | 26 ++++++++++++------ core/scripts/telegrambot/utils/edituser.py | 32 +++++++++++----------- core/scripts/telegrambot/utils/search.py | 2 +- 3 files changed, 35 insertions(+), 25 deletions(-) diff --git a/core/scripts/telegrambot/utils/adduser.py b/core/scripts/telegrambot/utils/adduser.py index 7298f2e..b81d3c4 100644 --- a/core/scripts/telegrambot/utils/adduser.py +++ b/core/scripts/telegrambot/utils/adduser.py @@ -1,10 +1,13 @@ import qrcode import io import json +import re from telebot import types from utils.command import * from utils.common import create_main_markup +def escape_markdown(text): + return str(text).replace('_', '\\_').replace('*', '\\*').replace('`', '\\`') def create_cancel_markup(back_step=None): markup = types.ReplyKeyboardMarkup(resize_keyboard=True, one_time_keyboard=True) @@ -15,7 +18,7 @@ def create_cancel_markup(back_step=None): @bot.message_handler(func=lambda message: is_admin(message.from_user.id) and message.text == 'Add User') def add_user(message): - msg = bot.reply_to(message, "Enter username:", reply_markup=create_cancel_markup()) + msg = bot.reply_to(message, "Enter username (only letters, numbers, and underscores are allowed):", reply_markup=create_cancel_markup()) bot.register_next_step_handler(msg, process_add_user_step1) def process_add_user_step1(message): @@ -24,6 +27,12 @@ def process_add_user_step1(message): return username = message.text.strip() + + if not re.match("^[a-zA-Z0-9_]*$", username): + bot.reply_to(message, "Invalid username. Only letters, numbers, and underscores are allowed. Please try again:", reply_markup=create_cancel_markup()) + bot.register_next_step_handler(message, process_add_user_step1) + return + if not username: bot.reply_to(message, "Username cannot be empty. Please enter a valid username:", reply_markup=create_cancel_markup()) bot.register_next_step_handler(message, process_add_user_step1) @@ -41,7 +50,7 @@ def process_add_user_step1(message): users_data = json.loads(result) existing_users = {user_key.lower() for user_key in users_data.keys()} if username.lower() in existing_users: - bot.reply_to(message, f"Username '{username}' already exists. Please choose a different username:", reply_markup=create_cancel_markup()) + bot.reply_to(message, f"Username '{escape_markdown(username)}' already exists. Please choose a different username:", reply_markup=create_cancel_markup()) bot.register_next_step_handler(message, process_add_user_step1) return except json.JSONDecodeError: @@ -59,7 +68,7 @@ def process_add_user_step2(message, username): bot.reply_to(message, "Process canceled.", reply_markup=create_main_markup()) return if message.text == "⬅️ Back": - msg = bot.reply_to(message, "Enter username:", reply_markup=create_cancel_markup()) + msg = bot.reply_to(message, "Enter username (only letters, numbers, and underscores are allowed):", reply_markup=create_cancel_markup()) bot.register_next_step_handler(msg, process_add_user_step1) return @@ -96,8 +105,7 @@ def process_add_user_step3(message, username, traffic_limit): bot.send_chat_action(message.chat.id, 'typing') - lower_username = username.lower() - uri_info_command = f"python3 {CLI_PATH} show-user-uri -u \"{lower_username}\" -ip 4 -n" + uri_info_command = f"python3 {CLI_PATH} show-user-uri -u \"{username}\" -ip 4 -n" uri_info_output = run_cli_command(uri_info_command) direct_uri = None @@ -121,18 +129,20 @@ def process_add_user_step3(message, username, traffic_limit): except (IndexError, AttributeError): pass - caption_text = f"{add_user_feedback}\n" + display_username = escape_markdown(username) + escaped_feedback = escape_markdown(add_user_feedback) + caption_text = f"{escaped_feedback}\n" link_to_generate_qr_for = None link_type_for_caption = "" if normal_sub_link: link_to_generate_qr_for = normal_sub_link link_type_for_caption = "Normal Subscription Link" - caption_text += f"\n{link_type_for_caption} for `{username}`:\n`{normal_sub_link}`" + caption_text += f"\n{link_type_for_caption} for `{display_username}`:\n`{normal_sub_link}`" elif direct_uri: link_to_generate_qr_for = direct_uri link_type_for_caption = "Hysteria2 IPv4 URI" - caption_text += f"\n{link_type_for_caption} for `{username}`:\n`{direct_uri}`" + caption_text += f"\n{link_type_for_caption} for `{display_username}`:\n`{direct_uri}`" if link_to_generate_qr_for: qr_img = qrcode.make(link_to_generate_qr_for) diff --git a/core/scripts/telegrambot/utils/edituser.py b/core/scripts/telegrambot/utils/edituser.py index 537084c..bc9eed4 100644 --- a/core/scripts/telegrambot/utils/edituser.py +++ b/core/scripts/telegrambot/utils/edituser.py @@ -1,5 +1,3 @@ -#show and edituser file - import qrcode import io import json @@ -8,6 +6,9 @@ from utils.command import * from utils.common import * +def escape_markdown(text): + return str(text).replace('_', '\\_').replace('*', '\\*').replace('`', '\\`') + @bot.callback_query_handler(func=lambda call: call.data == "cancel_show_user") def handle_cancel_show_user(call): bot.edit_message_text("Operation canceled.", chat_id=call.message.chat.id, message_id=call.message.message_id) @@ -33,7 +34,7 @@ def process_show_user(message): existing_users = {user.lower(): user for user in users.keys()} if username not in existing_users: - bot.reply_to(message, f"Username '{message.text.strip()}' does not exist. Please enter a valid username.") + bot.reply_to(message, f"Username '{escape_markdown(message.text.strip())}' does not exist. Please enter a valid username.") return actual_username = existing_users[username] @@ -54,8 +55,8 @@ def process_show_user(message): if upload_bytes is None or download_bytes is None: traffic_message = "**Traffic Data:**\nUser not active or no traffic data available." else: - upload_gb = upload_bytes / (1024 ** 3) # Convert bytes to GB - download_gb = download_bytes / (1024 ** 3) # Convert bytes to GB + upload_gb = upload_bytes / (1024 ** 3) + download_gb = download_bytes / (1024 ** 3) totalusage = upload_gb + download_gb traffic_message = ( @@ -68,8 +69,10 @@ def process_show_user(message): bot.reply_to(message, "Failed to parse JSON data. The command output may be malformed.") return + display_username = escape_markdown(actual_username) + formatted_details = ( - f"\nπŸ†” Name: {actual_username}\n" + f"\nπŸ†” Name: {display_username}\n" f"πŸ“Š Traffic Limit: {user_details['max_download_bytes'] / (1024 ** 3):.2f} GB\n" f"πŸ“… Days: {user_details['expiration_days']}\n" f"⏳ Creation: {user_details['account_creation_date']}\n" @@ -87,15 +90,12 @@ def process_show_user(message): result_lines = combined_result.strip().split('\n') uri_v4 = "" - # singbox_sublink = "" normal_sub_sublink = "" for line in result_lines: line = line.strip() if line.startswith("hy2://"): uri_v4 = line - # elif line.startswith("Singbox Sublink:"): - # singbox_sublink = result_lines[result_lines.index(line) + 1].strip() elif line.startswith("Normal-SUB Sublink:"): normal_sub_sublink = result_lines[result_lines.index(line) + 1].strip() @@ -119,8 +119,6 @@ def process_show_user(message): types.InlineKeyboardButton("Block User", callback_data=f"block_user:{actual_username}")) caption = f"{formatted_details}\n\n**IPv4 URI:**\n\n`{uri_v4}`" - # if singbox_sublink: - # caption += f"\n\n**SingBox SUB:**\n{singbox_sublink}" if normal_sub_sublink: caption += f"\n\n**Normal SUB:**\n{normal_sub_sublink}" @@ -135,14 +133,16 @@ def process_show_user(message): @bot.callback_query_handler(func=lambda call: call.data.startswith('edit_') or call.data.startswith('renew_') or call.data.startswith('block_') or call.data.startswith('reset_') or call.data.startswith('ipv6_')) def handle_edit_callback(call): action, username = call.data.split(':') + display_username = escape_markdown(username) + if action == 'edit_username': - msg = bot.send_message(call.message.chat.id, f"Enter new username for {username}:") + msg = bot.send_message(call.message.chat.id, f"Enter new username for {display_username}:") bot.register_next_step_handler(msg, process_edit_username, username) elif action == 'edit_traffic': - msg = bot.send_message(call.message.chat.id, f"Enter new traffic limit (GB) for {username}:") + msg = bot.send_message(call.message.chat.id, f"Enter new traffic limit (GB) for {display_username}:") bot.register_next_step_handler(msg, process_edit_traffic, username) elif action == 'edit_expiration': - msg = bot.send_message(call.message.chat.id, f"Enter new expiration days for {username}:") + msg = bot.send_message(call.message.chat.id, f"Enter new expiration days for {display_username}:") bot.register_next_step_handler(msg, process_edit_expiration, username) elif action == 'renew_password': command = f"python3 {CLI_PATH} edit-user -u {username} -rp" @@ -156,7 +156,7 @@ def handle_edit_callback(call): markup = types.InlineKeyboardMarkup() markup.add(types.InlineKeyboardButton("True", callback_data=f"confirm_block:{username}:true"), types.InlineKeyboardButton("False", callback_data=f"confirm_block:{username}:false")) - bot.send_message(call.message.chat.id, f"Set block status for {username}:", reply_markup=markup) + bot.send_message(call.message.chat.id, f"Set block status for {display_username}:", reply_markup=markup) elif action == 'reset_user': command = f"python3 {CLI_PATH} reset-user -u {username}" result = run_cli_command(command) @@ -177,7 +177,7 @@ def handle_edit_callback(call): bot.send_photo( call.message.chat.id, bio_v6, - caption=f"**IPv6 URI for {username}:**\n\n`{uri_v6}`", + caption=f"**IPv6 URI for {display_username}:**\n\n`{uri_v6}`", parse_mode="Markdown" ) diff --git a/core/scripts/telegrambot/utils/search.py b/core/scripts/telegrambot/utils/search.py index 31821e0..684c87d 100644 --- a/core/scripts/telegrambot/utils/search.py +++ b/core/scripts/telegrambot/utils/search.py @@ -11,7 +11,7 @@ def handle_inline_query(query): bot.answer_inline_query(query.id, results=[], switch_pm_text="Error retrieving users.", switch_pm_user_id=query.from_user.id) return - query_text = query.query.lower() + query_text = query.query.lower().replace('\\_', '_') results = [] if query_text == "block": From ecb08dbd44680f7de4c55790c62f65aacee72fe0 Mon Sep 17 00:00:00 2001 From: Whispering Wind <151555003+ReturnFI@users.noreply.github.com> Date: Wed, 27 Aug 2025 16:41:34 +0330 Subject: [PATCH 08/12] perf(core): optimize bulk user URI generation in wrapper_uri.py --- core/cli.py | 2 +- core/scripts/hysteria2/wrapper_uri.py | 193 +++++++++++++++++++------- 2 files changed, 141 insertions(+), 54 deletions(-) diff --git a/core/cli.py b/core/cli.py index 223ba0e..e3bef53 100644 --- a/core/cli.py +++ b/core/cli.py @@ -160,7 +160,7 @@ def bulk_user_add(traffic_gb: float, expiration_days: int, count: int, prefix: s @click.option('--new-expiration-days', '-ne', required=False, help='Expiration days for the new user', type=int) @click.option('--renew-password', '-rp', is_flag=True, help='Renew password for the user') @click.option('--renew-creation-date', '-rc', is_flag=True, help='Renew creation date for the user') -@click.option('--blocked/--unblocked', 'blocked', default=None, help='Block or unblock the user.') +@click.option('--blocked/--unblocked', 'blocked', '-b', default=None, help='Block or unblock the user.') @click.option('--unlimited-ip/--limited-ip', 'unlimited_ip', default=None, help='Set user to be exempt from or subject to IP limits.') def edit_user(username: str, new_username: str, new_traffic_limit: int, new_expiration_days: int, renew_password: bool, renew_creation_date: bool, blocked: bool | None, unlimited_ip: bool | None): try: diff --git a/core/scripts/hysteria2/wrapper_uri.py b/core/scripts/hysteria2/wrapper_uri.py index 86ae88b..44bfb4f 100644 --- a/core/scripts/hysteria2/wrapper_uri.py +++ b/core/scripts/hysteria2/wrapper_uri.py @@ -1,65 +1,152 @@ -import subprocess -import concurrent.futures -import re -import json +#!/usr/bin/env python3 + +import os import sys +import json +import argparse +from functools import lru_cache +from typing import Dict, List, Any from init_paths import * from paths import * -DEFAULT_ARGS = ["-a", "-n", "-s"] - -def run_show_uri(username): +@lru_cache(maxsize=None) +def load_json_file(file_path: str) -> Any: + if not os.path.exists(file_path): + return None try: - cmd = ["python3", CLI_PATH, "show-user-uri", "-u", username] + DEFAULT_ARGS - result = subprocess.run(cmd, capture_output=True, text=True, check=True) - output = result.stdout - if "Invalid username" in output: - return {"username": username, "error": "User not found"} - return parse_output(username, output) - except subprocess.CalledProcessError as e: - return {"username": username, "error": e.stderr.strip()} + with open(file_path, 'r') as f: + content = f.read() + return json.loads(content) if content else None + except (json.JSONDecodeError, IOError): + return None -def parse_output(username, output): - ipv4 = None - ipv6 = None - normal_sub = None - nodes = [] +@lru_cache(maxsize=None) +def load_env_file(env_file: str) -> Dict[str, str]: + env_vars = {} + if os.path.exists(env_file): + with open(env_file, 'r') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#') and '=' in line: + key, value = line.split('=', 1) + env_vars[key] = value.strip() + return env_vars - ipv4_match = re.search(r"IPv4:\s*(hy2://[^\s]+)", output) - ipv6_match = re.search(r"IPv6:\s*(hy2://[^\s]+)", output) - normal_sub_match = re.search(r"Normal-SUB Sublink:\s*(https?://[^\s]+)", output) - - if ipv4_match: - ipv4 = ipv4_match.group(1) - if ipv6_match: - ipv6 = ipv6_match.group(1) - if normal_sub_match: - normal_sub = normal_sub_match.group(1) - - node_matches = re.findall(r"Node: (.+?) \(IPv[46]\):\s*(hy2://[^\s]+)", output) - for name, uri in node_matches: - nodes.append({"name": name.strip(), "uri": uri}) - - - return { - "username": username, - "ipv4": ipv4, - "ipv6": ipv6, - "nodes": nodes, - "normal_sub": normal_sub +def generate_uri(username: str, auth_password: str, ip: str, port: str, + obfs_password: str, sha256: str, sni: str, ip_version: int, + insecure: bool, fragment_tag: str) -> str: + ip_part = f"[{ip}]" if ip_version == 6 and ':' in ip else ip + uri_base = f"hy2://{username}:{auth_password}@{ip_part}:{port}" + + params = { + "insecure": "1" if insecure else "0", + "sni": sni } + if obfs_password: + params["obfs"] = "salamander" + params["obfs-password"] = obfs_password + if sha256: + params["pinSHA256"] = sha256 + + query_string = "&".join([f"{k}={v}" for k, v in params.items()]) + return f"{uri_base}?{query_string}#{fragment_tag}" -def batch_show_uri(usernames, max_workers=20): - with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: - results = list(executor.map(run_show_uri, usernames)) - return results - -if __name__ == "__main__": - if len(sys.argv) < 2: - print("Usage: python3 show_uri_json.py user1 user2 ...") +def process_users(target_usernames: List[str]) -> List[Dict[str, Any]]: + config = load_json_file(CONFIG_FILE) + all_users = load_json_file(USERS_FILE) + nodes = load_json_file(NODES_JSON_PATH) or [] + + if not config or not all_users: + print("Error: Could not load hysteria2 configuration or user files.", file=sys.stderr) sys.exit(1) - usernames = sys.argv[1:] - output_list = batch_show_uri(usernames) - print(json.dumps(output_list, indent=2)) \ No newline at end of file + port = config.get("listen", "").split(":")[-1] + tls_config = config.get("tls", {}) + sha256 = tls_config.get("pinSHA256", "") + insecure = tls_config.get("insecure", True) + obfs_password = config.get("obfs", {}).get("salamander", {}).get("password", "") + + hy2_env = load_env_file(CONFIG_ENV) + ip4 = hy2_env.get('IP4') + ip6 = hy2_env.get('IP6') + sni = hy2_env.get('SNI', '') + + ns_env = load_env_file(NORMALSUB_ENV) + ns_domain = ns_env.get('HYSTERIA_DOMAIN') + ns_port = ns_env.get('HYSTERIA_PORT') + ns_subpath = ns_env.get('SUBPATH') + + results = [] + + for username in target_usernames: + user_data = all_users.get(username) + if not user_data or "password" not in user_data: + results.append({"username": username, "error": "User not found or password not set"}) + continue + + auth_password = user_data["password"] + user_output = { + "username": username, + "ipv4": None, + "ipv6": None, + "nodes": [], + "normal_sub": None + } + + if ip4 and ip4 != "None": + user_output["ipv4"] = generate_uri( + username, auth_password, ip4, port, obfs_password, sha256, sni, 4, insecure, f"{username}-IPv4" + ) + + if ip6 and ip6 != "None": + user_output["ipv6"] = generate_uri( + username, auth_password, ip6, port, obfs_password, sha256, sni, 6, insecure, f"{username}-IPv6" + ) + + for node in nodes: + node_name, node_ip = node.get("name"), node.get("ip") + if not (node_name and node_ip): + continue + ip_v = 6 if ':' in node_ip else 4 + tag = f"{username}-{node_name}" + uri = generate_uri( + username, auth_password, node_ip, port, obfs_password, sha256, sni, ip_v, insecure, tag + ) + user_output["nodes"].append({"name": node_name, "uri": uri}) + + if ns_domain and ns_port and ns_subpath: + user_output["normal_sub"] = f"https://{ns_domain}:{ns_port}/{ns_subpath}/sub/normal/{auth_password}#{username}" + + results.append(user_output) + + return results + +def main(): + parser = argparse.ArgumentParser( + description="Efficiently generate Hysteria2 URIs for multiple users.", + formatter_class=argparse.RawTextHelpFormatter + ) + parser.add_argument('usernames', nargs='*', help="A list of usernames to process.") + parser.add_argument('--all', action='store_true', help="Process all users from users.json.") + + args = parser.parse_args() + + target_usernames = args.usernames + if args.all: + all_users = load_json_file(USERS_FILE) + if all_users: + target_usernames = list(all_users.keys()) + else: + print("Error: Could not load users.json to process all users.", file=sys.stderr) + sys.exit(1) + + if not target_usernames: + parser.print_help() + sys.exit(1) + + output_list = process_users(target_usernames) + print(json.dumps(output_list, indent=2)) + +if __name__ == "__main__": + main() \ No newline at end of file From c494250f9f938204c712c05a6057c741aebc1706 Mon Sep 17 00:00:00 2001 From: Whispering Wind <151555003+ReturnFI@users.noreply.github.com> Date: Wed, 27 Aug 2025 16:43:33 +0330 Subject: [PATCH 09/12] feat(frontend): add bulk user link export feature to users page --- core/scripts/webpanel/templates/users.html | 120 +++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/core/scripts/webpanel/templates/users.html b/core/scripts/webpanel/templates/users.html index 67c89fc..34933ef 100644 --- a/core/scripts/webpanel/templates/users.html +++ b/core/scripts/webpanel/templates/users.html @@ -57,6 +57,9 @@ + @@ -303,6 +306,28 @@ + + + {% endblock %} {% block javascripts %} @@ -884,6 +909,101 @@ $("#qrcodeModal .close").on("click", function () { $("#qrcodeModal").modal("hide"); }); + + $("#showSelectedLinks").on("click", function () { + const selectedUsers = $(".user-checkbox:checked").map(function () { + return $(this).val(); + }).get(); + + if (selectedUsers.length === 0) { + Swal.fire({ + title: "Warning!", + text: "Please select at least one user.", + icon: "warning", + confirmButtonText: "OK", + }); + return; + } + + Swal.fire({ + title: 'Fetching links...', + text: 'Please wait.', + allowOutsideClick: false, + didOpen: () => { + Swal.showLoading(); + } + }); + + const userUriApiUrlTemplate = "{{ url_for('show_user_uri_api', username='USERNAME_PLACEHOLDER') }}"; + + const fetchPromises = selectedUsers.map(username => { + const url = userUriApiUrlTemplate.replace("USERNAME_PLACEHOLDER", encodeURIComponent(username)); + return fetch(url).then(response => { + if (!response.ok) { + return { username: username, error: `HTTP error! status: ${response.status}` }; + } + return response.json(); + }).catch(error => { + return { username: username, error: error.message }; + }); + }); + + Promise.all(fetchPromises) + .then(results => { + Swal.close(); + + const successfulLinks = results + .filter(res => res && res.normal_sub) + .map(res => res.normal_sub); + + const failedUsers = results + .filter(res => !res || !res.normal_sub) + .map(res => res.username); + + if (failedUsers.length > 0) { + console.error("Failed to fetch links for:", failedUsers); + Swal.fire({ + icon: 'warning', + title: 'Partial Success', + text: `Could not fetch links for the following users: ${failedUsers.join(', ')}`, + }); + } + + if (successfulLinks.length > 0) { + $("#linksTextarea").val(successfulLinks.join('\n')); + $("#showLinksModal").modal("show"); + } else if (failedUsers.length === selectedUsers.length) { + Swal.fire({ + icon: 'error', + title: 'Operation Failed', + text: 'Could not fetch links for any of the selected users.', + }); + } + }); + }); + + $("#copyLinksButton").on("click", function () { + const links = $("#linksTextarea").val(); + if (links) { + navigator.clipboard.writeText(links) + .then(() => { + Swal.fire({ + icon: "success", + title: "All links copied!", + showConfirmButton: false, + timer: 1500, + }); + }) + .catch(err => { + console.error("Failed to copy links: ", err); + Swal.fire({ + icon: "error", + title: "Failed to copy links", + text: "Please copy manually.", + }); + }); + } + }); function filterUsers() { const searchText = $("#searchInput").val().toLowerCase(); From 18d3a1029b452ed5300c723c5f686f5e74b75af4 Mon Sep 17 00:00:00 2001 From: Whispering Wind <151555003+ReturnFI@users.noreply.github.com> Date: Wed, 27 Aug 2025 17:22:04 +0330 Subject: [PATCH 10/12] perf(api): optimize bulk user URI fetching in webpanel Refactored the web panel's user link generation to resolve a major performance bottleneck. Previously, fetching links for N users would trigger N separate script executions, causing significant delays from process startup overhead. - Introduced a new bulk API endpoint (`/api/v1/users/uri/bulk`) that accepts a list of usernames and calls the backend script only once. - Updated the frontend JavaScript in `users.html` to use this new endpoint, replacing N parallel API calls with a single one. - Cleaned up the `wrapper_uri.py` script for better readability and maintainability. --- core/scripts/hysteria2/wrapper_uri.py | 90 +- .../webpanel/routers/api/v1/schema/user.py | 2 + core/scripts/webpanel/routers/api/v1/user.py | 35 +- core/scripts/webpanel/templates/users.html | 798 ++++-------------- 4 files changed, 227 insertions(+), 698 deletions(-) diff --git a/core/scripts/hysteria2/wrapper_uri.py b/core/scripts/hysteria2/wrapper_uri.py index 44bfb4f..57a0d4a 100644 --- a/core/scripts/hysteria2/wrapper_uri.py +++ b/core/scripts/hysteria2/wrapper_uri.py @@ -15,7 +15,7 @@ def load_json_file(file_path: str) -> Any: if not os.path.exists(file_path): return None try: - with open(file_path, 'r') as f: + with open(file_path, 'r', encoding='utf-8') as f: content = f.read() return json.loads(content) if content else None except (json.JSONDecodeError, IOError): @@ -25,7 +25,7 @@ def load_json_file(file_path: str) -> Any: def load_env_file(env_file: str) -> Dict[str, str]: env_vars = {} if os.path.exists(env_file): - with open(env_file, 'r') as f: + with open(env_file, 'r', encoding='utf-8') as f: for line in f: line = line.strip() if line and not line.startswith('#') and '=' in line: @@ -34,51 +34,44 @@ def load_env_file(env_file: str) -> Dict[str, str]: return env_vars def generate_uri(username: str, auth_password: str, ip: str, port: str, - obfs_password: str, sha256: str, sni: str, ip_version: int, - insecure: bool, fragment_tag: str) -> str: + uri_params: Dict[str, str], ip_version: int, fragment_tag: str) -> str: ip_part = f"[{ip}]" if ip_version == 6 and ':' in ip else ip uri_base = f"hy2://{username}:{auth_password}@{ip_part}:{port}" - - params = { - "insecure": "1" if insecure else "0", - "sni": sni - } - if obfs_password: - params["obfs"] = "salamander" - params["obfs-password"] = obfs_password - if sha256: - params["pinSHA256"] = sha256 - - query_string = "&".join([f"{k}={v}" for k, v in params.items()]) + query_string = "&".join([f"{k}={v}" for k, v in uri_params.items()]) return f"{uri_base}?{query_string}#{fragment_tag}" def process_users(target_usernames: List[str]) -> List[Dict[str, Any]]: config = load_json_file(CONFIG_FILE) all_users = load_json_file(USERS_FILE) - nodes = load_json_file(NODES_JSON_PATH) or [] if not config or not all_users: - print("Error: Could not load hysteria2 configuration or user files.", file=sys.stderr) + print("Error: Could not load Hysteria2 configuration or user files.", file=sys.stderr) sys.exit(1) + nodes = load_json_file(NODES_JSON_PATH) or [] port = config.get("listen", "").split(":")[-1] tls_config = config.get("tls", {}) - sha256 = tls_config.get("pinSHA256", "") - insecure = tls_config.get("insecure", True) - obfs_password = config.get("obfs", {}).get("salamander", {}).get("password", "") - hy2_env = load_env_file(CONFIG_ENV) + ns_env = load_env_file(NORMALSUB_ENV) + + base_uri_params = { + "insecure": "1" if tls_config.get("insecure", True) else "0", + "sni": hy2_env.get('SNI', '') + } + obfs_password = config.get("obfs", {}).get("salamander", {}).get("password") + if obfs_password: + base_uri_params["obfs"] = "salamander" + base_uri_params["obfs-password"] = obfs_password + + sha256 = tls_config.get("pinSHA256") + if sha256: + base_uri_params["pinSHA256"] = sha256 + ip4 = hy2_env.get('IP4') ip6 = hy2_env.get('IP6') - sni = hy2_env.get('SNI', '') - - ns_env = load_env_file(NORMALSUB_ENV) - ns_domain = ns_env.get('HYSTERIA_DOMAIN') - ns_port = ns_env.get('HYSTERIA_PORT') - ns_subpath = ns_env.get('SUBPATH') + ns_domain, ns_port, ns_subpath = ns_env.get('HYSTERIA_DOMAIN'), ns_env.get('HYSTERIA_PORT'), ns_env.get('SUBPATH') results = [] - for username in target_usernames: user_data = all_users.get(username) if not user_data or "password" not in user_data: @@ -86,34 +79,20 @@ def process_users(target_usernames: List[str]) -> List[Dict[str, Any]]: continue auth_password = user_data["password"] - user_output = { - "username": username, - "ipv4": None, - "ipv6": None, - "nodes": [], - "normal_sub": None - } + user_output = {"username": username, "ipv4": None, "ipv6": None, "nodes": [], "normal_sub": None} if ip4 and ip4 != "None": - user_output["ipv4"] = generate_uri( - username, auth_password, ip4, port, obfs_password, sha256, sni, 4, insecure, f"{username}-IPv4" - ) - + user_output["ipv4"] = generate_uri(username, auth_password, ip4, port, base_uri_params, 4, f"{username}-IPv4") if ip6 and ip6 != "None": - user_output["ipv6"] = generate_uri( - username, auth_password, ip6, port, obfs_password, sha256, sni, 6, insecure, f"{username}-IPv6" - ) + user_output["ipv6"] = generate_uri(username, auth_password, ip6, port, base_uri_params, 6, f"{username}-IPv6") for node in nodes: - node_name, node_ip = node.get("name"), node.get("ip") - if not (node_name and node_ip): - continue - ip_v = 6 if ':' in node_ip else 4 - tag = f"{username}-{node_name}" - uri = generate_uri( - username, auth_password, node_ip, port, obfs_password, sha256, sni, ip_v, insecure, tag - ) - user_output["nodes"].append({"name": node_name, "uri": uri}) + if node_name := node.get("name"): + if node_ip := node.get("ip"): + ip_v = 6 if ':' in node_ip else 4 + tag = f"{username}-{node_name}" + uri = generate_uri(username, auth_password, node_ip, port, base_uri_params, ip_v, tag) + user_output["nodes"].append({"name": node_name, "uri": uri}) if ns_domain and ns_port and ns_subpath: user_output["normal_sub"] = f"https://{ns_domain}:{ns_port}/{ns_subpath}/sub/normal/{auth_password}#{username}" @@ -123,16 +102,13 @@ def process_users(target_usernames: List[str]) -> List[Dict[str, Any]]: return results def main(): - parser = argparse.ArgumentParser( - description="Efficiently generate Hysteria2 URIs for multiple users.", - formatter_class=argparse.RawTextHelpFormatter - ) + parser = argparse.ArgumentParser(description="Efficiently generate Hysteria2 URIs for multiple users.") parser.add_argument('usernames', nargs='*', help="A list of usernames to process.") parser.add_argument('--all', action='store_true', help="Process all users from users.json.") args = parser.parse_args() - target_usernames = args.usernames + if args.all: all_users = load_json_file(USERS_FILE) if all_users: diff --git a/core/scripts/webpanel/routers/api/v1/schema/user.py b/core/scripts/webpanel/routers/api/v1/schema/user.py index 4f69289..64dc768 100644 --- a/core/scripts/webpanel/routers/api/v1/schema/user.py +++ b/core/scripts/webpanel/routers/api/v1/schema/user.py @@ -18,6 +18,8 @@ class UserInfoResponse(BaseModel): class UserListResponse(RootModel): root: dict[str, UserInfoResponse] +class UsernamesRequest(BaseModel): + usernames: List[str] class AddUserInputBody(BaseModel): username: str diff --git a/core/scripts/webpanel/routers/api/v1/user.py b/core/scripts/webpanel/routers/api/v1/user.py index 6bdd238..4d57a31 100644 --- a/core/scripts/webpanel/routers/api/v1/user.py +++ b/core/scripts/webpanel/routers/api/v1/user.py @@ -1,7 +1,15 @@ import json +from typing import List from fastapi import APIRouter, HTTPException - -from .schema.user import UserListResponse, UserInfoResponse, AddUserInputBody, EditUserInputBody, UserUriResponse, AddBulkUsersInputBody +from .schema.user import ( + UserListResponse, + UserInfoResponse, + AddUserInputBody, + EditUserInputBody, + UserUriResponse, + AddBulkUsersInputBody, + UsernamesRequest +) from .schema.response import DetailResponse import cli_api @@ -79,6 +87,29 @@ async def add_bulk_users_api(body: AddBulkUsersInputBody): raise HTTPException(status_code=500, detail=f"An unexpected error occurred while adding bulk users: {str(e)}") +@router.post('/uri/bulk', response_model=List[UserUriResponse]) +async def show_multiple_user_uris_api(request: UsernamesRequest): + """ + Get URI information for multiple users in a single request for efficiency. + """ + if not request.usernames: + return [] + + try: + uri_data_list = cli_api.show_user_uri_json(request.usernames) + if not uri_data_list: + raise HTTPException(status_code=404, detail='No URI data found for the provided users.') + + valid_responses = [data for data in uri_data_list if not data.get('error')] + + return valid_responses + except cli_api.ScriptNotFoundError as e: + raise HTTPException(status_code=500, detail=f'Server script error: {str(e)}') + except cli_api.CommandExecutionError as e: + raise HTTPException(status_code=400, detail=f'Error executing script: {str(e)}') + except Exception as e: + raise HTTPException(status_code=400, detail=f'Unexpected error: {str(e)}') + @router.get('/{username}', response_model=UserInfoResponse) async def get_user_api(username: str): diff --git a/core/scripts/webpanel/templates/users.html b/core/scripts/webpanel/templates/users.html index 34933ef..873e1c8 100644 --- a/core/scripts/webpanel/templates/users.html +++ b/core/scripts/webpanel/templates/users.html @@ -26,8 +26,8 @@
-
@@ -66,8 +66,8 @@
- {% if users|length == 0 %} -
@@ -203,7 +196,7 @@ @@ -237,7 +230,7 @@ @@ -249,8 +242,7 @@ -