Merge pull request #258 from ReturnFI/hold

Bulk User Export, On-Hold Logic & Performance Optimizations
This commit is contained in:
Whispering Wind
2025-08-27 22:48:38 +03:30
committed by GitHub
14 changed files with 496 additions and 769 deletions

View File

@ -1,18 +1,22 @@
# [1.17.0] - 2025-08-24 # [1.18.0] - 2025-08-27
#### ⚡ Performance
#### ⚡ Authentication * ⚡ **Optimized bulk user URI fetching**:
* 🚀 **Implemented Go HTTP Auth Server** for **maximum performance** * New API endpoint `/api/v1/users/uri/bulk` to fetch multiple user links in a single call
* ⚡ Removed old command-based auth system * Eliminates N separate script executions → **huge speedup** 🚀
* ⚡ Refactored `wrapper_uri.py` for faster bulk processing & maintainability
#### 👥 User Management #### ✨ Features
* **Bulk User Creation** added across: * 📤 **Bulk user link export** directly from the **Users Page**
* 🎨 Distinct **color coding** for user statuses in Web Panel
* ⏸️ **On-Hold User Activation** logic introduced in `traffic.py` (with `creation_date=None` default)
* 🖥️ **Frontend UI** #### 🐛 Fixes & Refactors
* 📡 **API Endpoint**
* 💻 **CLI Command** * 🤖 **Bot**: Properly handle escaped underscores in usernames
* 📜 **Automation Script** * 🛠️ **Webpanel**: Improved handling of malformed user data & more accurate status for on-hold users
* 🔍 New **Online User Filter & Sort** on the Users page * 🐛 Show Go installation correctly
* 🐛 Fixed: underscores now supported in usernames * 🔄 Refactored on-hold user logic into `traffic.py` for central management

View File

@ -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('--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-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('--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.') @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): 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: try:

View File

@ -18,7 +18,7 @@ def add_user(username, traffic_gb, expiration_days, password=None, creation_date
traffic_gb (str): The traffic limit in GB. traffic_gb (str): The traffic limit in GB.
expiration_days (str): The number of days until the account expires. 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. 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. unlimited_user (bool, optional): If True, user is exempt from IP limits. Defaults to False.
Returns: 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.") print("Error: Failed to generate password. Please install 'pwgen' or ensure /proc access.")
return 1 return 1
if not creation_date: if creation_date:
creation_date = datetime.now().strftime("%Y-%m-%d")
else:
if not re.match(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}$", 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.") print("Invalid date format. Expected YYYY-MM-DD.")
return 1 return 1
@ -59,6 +57,8 @@ def add_user(username, traffic_gb, expiration_days, password=None, creation_date
except ValueError: except ValueError:
print("Invalid date. Please provide a valid date in YYYY-MM-DD format.") print("Invalid date. Please provide a valid date in YYYY-MM-DD format.")
return 1 return 1
else:
creation_date = None
if not re.match(r"^[a-zA-Z0-9_]+$", username): if not re.match(r"^[a-zA-Z0-9_]+$", username):
print("Error: Username can only contain letters, numbers, and underscores.") print("Error: Username can only contain letters, numbers, and underscores.")

View File

@ -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} existing_users_lower = {u.lower() for u in users_data}
new_users_to_add = {} new_users_to_add = {}
creation_date = datetime.now().strftime("%Y-%m-%d") creation_date = None
try: try:
password_process = subprocess.run(['pwgen', '-s', '32', str(count)], capture_output=True, text=True, check=True) password_process = subprocess.run(['pwgen', '-s', '32', str(count)], capture_output=True, text=True, check=True)

View File

@ -1,65 +1,128 @@
import subprocess #!/usr/bin/env python3
import concurrent.futures
import re import os
import json
import sys import sys
import json
import argparse
from functools import lru_cache
from typing import Dict, List, Any
from init_paths import * from init_paths import *
from paths import * from paths import *
DEFAULT_ARGS = ["-a", "-n", "-s"] @lru_cache(maxsize=None)
def load_json_file(file_path: str) -> Any:
def run_show_uri(username): if not os.path.exists(file_path):
return None
try: try:
cmd = ["python3", CLI_PATH, "show-user-uri", "-u", username] + DEFAULT_ARGS with open(file_path, 'r', encoding='utf-8') as f:
result = subprocess.run(cmd, capture_output=True, text=True, check=True) content = f.read()
output = result.stdout return json.loads(content) if content else None
if "Invalid username" in output: except (json.JSONDecodeError, IOError):
return {"username": username, "error": "User not found"} return None
return parse_output(username, output)
except subprocess.CalledProcessError as e:
return {"username": username, "error": e.stderr.strip()}
def parse_output(username, output): @lru_cache(maxsize=None)
ipv4 = None def load_env_file(env_file: str) -> Dict[str, str]:
ipv6 = None env_vars = {}
normal_sub = None if os.path.exists(env_file):
nodes = [] 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:
key, value = line.split('=', 1)
env_vars[key] = value.strip()
return env_vars
ipv4_match = re.search(r"IPv4:\s*(hy2://[^\s]+)", output) def generate_uri(username: str, auth_password: str, ip: str, port: str,
ipv6_match = re.search(r"IPv6:\s*(hy2://[^\s]+)", output) uri_params: Dict[str, str], ip_version: int, fragment_tag: str) -> str:
normal_sub_match = re.search(r"Normal-SUB Sublink:\s*(https?://[^\s]+)", output) ip_part = f"[{ip}]" if ip_version == 6 and ':' in ip else ip
uri_base = f"hy2://{username}:{auth_password}@{ip_part}:{port}"
query_string = "&".join([f"{k}={v}" for k, v in uri_params.items()])
return f"{uri_base}?{query_string}#{fragment_tag}"
if ipv4_match: def process_users(target_usernames: List[str]) -> List[Dict[str, Any]]:
ipv4 = ipv4_match.group(1) config = load_json_file(CONFIG_FILE)
if ipv6_match: all_users = load_json_file(USERS_FILE)
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) if not config or not all_users:
for name, uri in node_matches: print("Error: Could not load Hysteria2 configuration or user files.", file=sys.stderr)
nodes.append({"name": name.strip(), "uri": uri})
return {
"username": username,
"ipv4": ipv4,
"ipv6": ipv6,
"nodes": nodes,
"normal_sub": normal_sub
}
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 ...")
sys.exit(1) sys.exit(1)
usernames = sys.argv[1:] nodes = load_json_file(NODES_JSON_PATH) or []
output_list = batch_show_uri(usernames) port = config.get("listen", "").split(":")[-1]
tls_config = config.get("tls", {})
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')
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:
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, base_uri_params, 4, f"{username}-IPv4")
if ip6 and ip6 != "None":
user_output["ipv6"] = generate_uri(username, auth_password, ip6, port, base_uri_params, 6, f"{username}-IPv6")
for node in nodes:
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}"
results.append(user_output)
return results
def main():
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:
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)) print(json.dumps(output_list, indent=2))
if __name__ == "__main__":
main()

View File

@ -1,12 +1,9 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import os
import sys
import time import time
import schedule import schedule
import logging import logging
import subprocess import subprocess
import fcntl import fcntl
import datetime
from pathlib import Path from pathlib import Path
from paths import * from paths import *
@ -23,7 +20,6 @@ logger = logging.getLogger("HysteriaScheduler")
# Constants # Constants
BASE_DIR = Path("/etc/hysteria") BASE_DIR = Path("/etc/hysteria")
VENV_ACTIVATE = BASE_DIR / "hysteria2_venv/bin/activate" VENV_ACTIVATE = BASE_DIR / "hysteria2_venv/bin/activate"
# CLI_PATH = BASE_DIR / "core/cli.py"
LOCK_FILE = "/tmp/hysteria_scheduler.lock" LOCK_FILE = "/tmp/hysteria_scheduler.lock"
def acquire_lock(): def acquire_lock():
@ -41,7 +37,6 @@ def release_lock(lock_fd):
lock_fd.close() lock_fd.close()
def run_command(command, log_success=False): def run_command(command, log_success=False):
activate_cmd = f"source {VENV_ACTIVATE}" activate_cmd = f"source {VENV_ACTIVATE}"
full_cmd = f"{activate_cmd} && {command}" full_cmd = f"{activate_cmd} && {command}"
@ -94,6 +89,7 @@ def main():
schedule.every(1).minutes.do(check_traffic_status) schedule.every(1).minutes.do(check_traffic_status)
schedule.every(6).hours.do(backup_hysteria) schedule.every(6).hours.do(backup_hysteria)
check_traffic_status()
backup_hysteria() backup_hysteria()
while True: while True:

View File

@ -1,10 +1,13 @@
import qrcode import qrcode
import io import io
import json import json
import re
from telebot import types from telebot import types
from utils.command import * from utils.command import *
from utils.common import create_main_markup from utils.common import create_main_markup
def escape_markdown(text):
return str(text).replace('_', '\\_').replace('*', '\\*').replace('`', '\\`')
def create_cancel_markup(back_step=None): def create_cancel_markup(back_step=None):
markup = types.ReplyKeyboardMarkup(resize_keyboard=True, one_time_keyboard=True) 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') @bot.message_handler(func=lambda message: is_admin(message.from_user.id) and message.text == 'Add User')
def add_user(message): 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) bot.register_next_step_handler(msg, process_add_user_step1)
def process_add_user_step1(message): def process_add_user_step1(message):
@ -24,6 +27,12 @@ def process_add_user_step1(message):
return return
username = message.text.strip() 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: if not username:
bot.reply_to(message, "Username cannot be empty. Please enter a valid username:", reply_markup=create_cancel_markup()) 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) 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) users_data = json.loads(result)
existing_users = {user_key.lower() for user_key in users_data.keys()} existing_users = {user_key.lower() for user_key in users_data.keys()}
if username.lower() in existing_users: 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) bot.register_next_step_handler(message, process_add_user_step1)
return return
except json.JSONDecodeError: 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()) bot.reply_to(message, "Process canceled.", reply_markup=create_main_markup())
return return
if message.text == "⬅️ Back": 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) bot.register_next_step_handler(msg, process_add_user_step1)
return return
@ -96,8 +105,7 @@ def process_add_user_step3(message, username, traffic_limit):
bot.send_chat_action(message.chat.id, 'typing') bot.send_chat_action(message.chat.id, 'typing')
lower_username = username.lower() uri_info_command = f"python3 {CLI_PATH} show-user-uri -u \"{username}\" -ip 4 -n"
uri_info_command = f"python3 {CLI_PATH} show-user-uri -u \"{lower_username}\" -ip 4 -n"
uri_info_output = run_cli_command(uri_info_command) uri_info_output = run_cli_command(uri_info_command)
direct_uri = None direct_uri = None
@ -121,18 +129,20 @@ def process_add_user_step3(message, username, traffic_limit):
except (IndexError, AttributeError): except (IndexError, AttributeError):
pass 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_to_generate_qr_for = None
link_type_for_caption = "" link_type_for_caption = ""
if normal_sub_link: if normal_sub_link:
link_to_generate_qr_for = normal_sub_link link_to_generate_qr_for = normal_sub_link
link_type_for_caption = "Normal Subscription 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: elif direct_uri:
link_to_generate_qr_for = direct_uri link_to_generate_qr_for = direct_uri
link_type_for_caption = "Hysteria2 IPv4 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: if link_to_generate_qr_for:
qr_img = qrcode.make(link_to_generate_qr_for) qr_img = qrcode.make(link_to_generate_qr_for)

View File

@ -1,5 +1,3 @@
#show and edituser file
import qrcode import qrcode
import io import io
import json import json
@ -8,6 +6,9 @@ from utils.command import *
from utils.common 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") @bot.callback_query_handler(func=lambda call: call.data == "cancel_show_user")
def handle_cancel_show_user(call): def handle_cancel_show_user(call):
bot.edit_message_text("Operation canceled.", chat_id=call.message.chat.id, message_id=call.message.message_id) 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()} existing_users = {user.lower(): user for user in users.keys()}
if username not in existing_users: 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 return
actual_username = existing_users[username] actual_username = existing_users[username]
@ -54,8 +55,8 @@ def process_show_user(message):
if upload_bytes is None or download_bytes is None: if upload_bytes is None or download_bytes is None:
traffic_message = "**Traffic Data:**\nUser not active or no traffic data available." traffic_message = "**Traffic Data:**\nUser not active or no traffic data available."
else: else:
upload_gb = upload_bytes / (1024 ** 3) # Convert bytes to GB upload_gb = upload_bytes / (1024 ** 3)
download_gb = download_bytes / (1024 ** 3) # Convert bytes to GB download_gb = download_bytes / (1024 ** 3)
totalusage = upload_gb + download_gb totalusage = upload_gb + download_gb
traffic_message = ( 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.") bot.reply_to(message, "Failed to parse JSON data. The command output may be malformed.")
return return
display_username = escape_markdown(actual_username)
formatted_details = ( 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"📊 Traffic Limit: {user_details['max_download_bytes'] / (1024 ** 3):.2f} GB\n"
f"📅 Days: {user_details['expiration_days']}\n" f"📅 Days: {user_details['expiration_days']}\n"
f"⏳ Creation: {user_details['account_creation_date']}\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') result_lines = combined_result.strip().split('\n')
uri_v4 = "" uri_v4 = ""
# singbox_sublink = ""
normal_sub_sublink = "" normal_sub_sublink = ""
for line in result_lines: for line in result_lines:
line = line.strip() line = line.strip()
if line.startswith("hy2://"): if line.startswith("hy2://"):
uri_v4 = line uri_v4 = line
# elif line.startswith("Singbox Sublink:"):
# singbox_sublink = result_lines[result_lines.index(line) + 1].strip()
elif line.startswith("Normal-SUB Sublink:"): elif line.startswith("Normal-SUB Sublink:"):
normal_sub_sublink = result_lines[result_lines.index(line) + 1].strip() 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}")) types.InlineKeyboardButton("Block User", callback_data=f"block_user:{actual_username}"))
caption = f"{formatted_details}\n\n**IPv4 URI:**\n\n`{uri_v4}`" 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: if normal_sub_sublink:
caption += f"\n\n**Normal SUB:**\n{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_')) @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): def handle_edit_callback(call):
action, username = call.data.split(':') action, username = call.data.split(':')
display_username = escape_markdown(username)
if action == 'edit_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) bot.register_next_step_handler(msg, process_edit_username, username)
elif action == 'edit_traffic': 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) bot.register_next_step_handler(msg, process_edit_traffic, username)
elif action == 'edit_expiration': 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) bot.register_next_step_handler(msg, process_edit_expiration, username)
elif action == 'renew_password': elif action == 'renew_password':
command = f"python3 {CLI_PATH} edit-user -u {username} -rp" command = f"python3 {CLI_PATH} edit-user -u {username} -rp"
@ -156,7 +156,7 @@ def handle_edit_callback(call):
markup = types.InlineKeyboardMarkup() markup = types.InlineKeyboardMarkup()
markup.add(types.InlineKeyboardButton("True", callback_data=f"confirm_block:{username}:true"), markup.add(types.InlineKeyboardButton("True", callback_data=f"confirm_block:{username}:true"),
types.InlineKeyboardButton("False", callback_data=f"confirm_block:{username}:false")) 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': elif action == 'reset_user':
command = f"python3 {CLI_PATH} reset-user -u {username}" command = f"python3 {CLI_PATH} reset-user -u {username}"
result = run_cli_command(command) result = run_cli_command(command)
@ -177,7 +177,7 @@ def handle_edit_callback(call):
bot.send_photo( bot.send_photo(
call.message.chat.id, call.message.chat.id,
bio_v6, 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" parse_mode="Markdown"
) )

View File

@ -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) bot.answer_inline_query(query.id, results=[], switch_pm_text="Error retrieving users.", switch_pm_user_id=query.from_user.id)
return return
query_text = query.query.lower() query_text = query.query.lower().replace('\\_', '_')
results = [] results = []
if query_text == "block": if query_text == "block":

View File

@ -18,6 +18,8 @@ class UserInfoResponse(BaseModel):
class UserListResponse(RootModel): class UserListResponse(RootModel):
root: dict[str, UserInfoResponse] root: dict[str, UserInfoResponse]
class UsernamesRequest(BaseModel):
usernames: List[str]
class AddUserInputBody(BaseModel): class AddUserInputBody(BaseModel):
username: str username: str

View File

@ -1,7 +1,15 @@
import json import json
from typing import List
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from .schema.user import (
from .schema.user import UserListResponse, UserInfoResponse, AddUserInputBody, EditUserInputBody, UserUriResponse, AddBulkUsersInputBody UserListResponse,
UserInfoResponse,
AddUserInputBody,
EditUserInputBody,
UserUriResponse,
AddBulkUsersInputBody,
UsernamesRequest
)
from .schema.response import DetailResponse from .schema.response import DetailResponse
import cli_api import cli_api
@ -79,6 +87,29 @@ async def add_bulk_users_api(body: AddBulkUsersInputBody):
raise HTTPException(status_code=500, raise HTTPException(status_code=500,
detail=f"An unexpected error occurred while adding bulk users: {str(e)}") 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) @router.get('/{username}', response_model=UserInfoResponse)
async def get_user_api(username: str): async def get_user_api(username: str):
@ -149,6 +180,7 @@ async def remove_user_api(username: str):
cli_api.kick_user_by_name(username) cli_api.kick_user_by_name(username)
cli_api.traffic_status(display_output=False) cli_api.traffic_status(display_output=False)
cli_api.remove_user(username) cli_api.remove_user(username)
cli_api.traffic_status(display_output=False)
return DetailResponse(detail=f'User {username} has been removed.') return DetailResponse(detail=f'User {username} has been removed.')
except HTTPException: except HTTPException:

View File

@ -20,30 +20,49 @@ class User(BaseModel):
@staticmethod @staticmethod
def __parse_user_data(user_data: dict) -> dict: def __parse_user_data(user_data: dict) -> dict:
essential_keys = [
'password',
'max_download_bytes',
'expiration_days',
'blocked',
'unlimited_user'
]
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) expiration_days = user_data.get('expiration_days', 0)
creation_date_str = user_data.get("account_creation_date")
if expiration_days > 0: if not creation_date_str:
creation_date_str = user_data.get("account_creation_date") display_expiry_days = "On-hold"
display_expiry_days = str(expiration_days) display_expiry_date = "On-hold"
elif expiration_days <= 0:
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:
display_expiry_days = "Unlimited" display_expiry_days = "Unlimited"
display_expiry_date = "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) used_bytes = user_data.get("download_bytes", 0) + user_data.get("upload_bytes", 0)
quota_bytes = user_data.get('max_download_bytes', 0) quota_bytes = user_data.get('max_download_bytes', 0)
used_formatted = User.__format_traffic(used_bytes) 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 percentage = 0
if quota_bytes > 0: if quota_bytes > 0:
@ -64,7 +83,7 @@ class User(BaseModel):
@staticmethod @staticmethod
def __format_traffic(traffic_bytes) -> str: def __format_traffic(traffic_bytes) -> str:
if traffic_bytes == 0: if traffic_bytes <= 0:
return "0 B" return "0 B"
if traffic_bytes < 1024: if traffic_bytes < 1024:
return f'{traffic_bytes} B' return f'{traffic_bytes} B'

View File

@ -26,8 +26,8 @@
</button> </button>
</div> </div>
<div class="mr-2 mb-2"> <div class="mr-2 mb-2">
<button type="button" class="btn btn-sm btn-default filter-button" data-filter="not-active"> <button type="button" class="btn btn-sm btn-warning filter-button" data-filter="on-hold">
<i class="fas fa-exclamation-triangle"></i> NA <i class="fas fa-pause-circle"></i> Hold
</button> </button>
</div> </div>
<div class="mr-2 mb-2"> <div class="mr-2 mb-2">
@ -57,14 +57,17 @@
<button type="button" class="btn btn-sm btn-primary ml-2" data-toggle="modal" data-target="#addUserModal"> <button type="button" class="btn btn-sm btn-primary ml-2" data-toggle="modal" data-target="#addUserModal">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
</button> </button>
<button type="button" class="btn btn-sm btn-info ml-2" id="showSelectedLinks">
<i class="fas fa-link"></i>
</button>
<button type="button" class="btn btn-sm btn-danger ml-2" id="deleteSelected"> <button type="button" class="btn btn-sm btn-danger ml-2" id="deleteSelected">
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
</button> </button>
</div> </div>
</div> </div>
<div class="card-body table-responsive p-0"> <div class="card-body table-responsive p-0">
{% if users|length == 0 %} {% if not users %}
<div class="alert alert-warning" role="alert" style="margin: 20px;"> <div class="alert alert-warning m-3" role="alert">
No users found. No users found.
</div> </div>
{% else %} {% else %}
@ -98,6 +101,10 @@
<i class="fas fa-circle text-success"></i> Online <i class="fas fa-circle text-success"></i> Online
{% elif user['status'] == "Offline" %} {% elif user['status'] == "Offline" %}
<i class="fas fa-circle text-secondary"></i> Offline <i class="fas fa-circle text-secondary"></i> Offline
{% elif user['status'] == "On-hold" %}
<i class="fas fa-circle text-warning"></i> On-hold
{% elif user['status'] == "Conflict" %}
<i class="fas fa-circle text-danger"></i> Conflict
{% else %} {% else %}
<i class="fas fa-circle text-danger"></i> {{ user['status'] }} <i class="fas fa-circle text-danger"></i> {{ user['status'] }}
{% endif %} {% endif %}
@ -146,13 +153,6 @@
</tbody> </tbody>
</table> </table>
{% endif %} {% endif %}
<div class="row mt-3">
<div class="col-sm-12 col-md-7">
<div class="dataTables_paginate paging_simple_numbers" id="userTable_paginate">
{# {{ pagination.links }} #}
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -196,7 +196,7 @@
</div> </div>
<div class="form-check mb-3 requires-iplimit-service" style="display: none;"> <div class="form-check mb-3 requires-iplimit-service" style="display: none;">
<input type="checkbox" class="form-check-input" id="addUnlimited" name="unlimited"> <input type="checkbox" class="form-check-input" id="addUnlimited" name="unlimited">
<label class="form-check-label" for="addUnlimited">Unlimited IP (Exempt from IP limit checks)</label> <label class="form-check-label" for="addUnlimited">Unlimited IP</label>
</div> </div>
<button type="submit" class="btn btn-primary" id="addSubmitButton">Add User</button> <button type="submit" class="btn btn-primary" id="addSubmitButton">Add User</button>
</form> </form>
@ -230,7 +230,7 @@
</div> </div>
<div class="form-check mb-3 requires-iplimit-service" style="display: none;"> <div class="form-check mb-3 requires-iplimit-service" style="display: none;">
<input type="checkbox" class="form-check-input" id="addBulkUnlimited" name="unlimited"> <input type="checkbox" class="form-check-input" id="addBulkUnlimited" name="unlimited">
<label class="form-check-label" for="addBulkUnlimited">Unlimited IP (Exempt from IP limit checks)</label> <label class="form-check-label" for="addBulkUnlimited">Unlimited IP</label>
</div> </div>
<button type="submit" class="btn btn-primary" id="addBulkSubmitButton">Add Bulk Users</button> <button type="submit" class="btn btn-primary" id="addBulkSubmitButton">Add Bulk Users</button>
</form> </form>
@ -242,8 +242,7 @@
</div> </div>
<!-- Edit User Modal --> <!-- Edit User Modal -->
<div class="modal fade" id="editUserModal" tabindex="-1" role="dialog" aria-labelledby="editUserModalLabel" <div class="modal fade" id="editUserModal" tabindex="-1" role="dialog" aria-labelledby="editUserModalLabel" aria-hidden="true">
aria-hidden="true">
<div class="modal-dialog" role="document"> <div class="modal-dialog" role="document">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@ -273,7 +272,7 @@
</div> </div>
<div class="form-check mb-3 requires-iplimit-service" style="display: none;"> <div class="form-check mb-3 requires-iplimit-service" style="display: none;">
<input type="checkbox" class="form-check-input" id="editUnlimitedIp" name="unlimited_ip"> <input type="checkbox" class="form-check-input" id="editUnlimitedIp" name="unlimited_ip">
<label class="form-check-label" for="editUnlimitedIp">Unlimited IP (Exempt from IP limit checks)</label> <label class="form-check-label" for="editUnlimitedIp">Unlimited IP</label>
</div> </div>
<input type="hidden" id="originalUsername" name="username"> <input type="hidden" id="originalUsername" name="username">
<button type="submit" class="btn btn-primary" id="editSubmitButton">Save Changes</button> <button type="submit" class="btn btn-primary" id="editSubmitButton">Save Changes</button>
@ -283,8 +282,7 @@
</div> </div>
</div> </div>
<!-- QR Code Modal --> <!-- QR Code Modal -->
<div class="modal fade" id="qrcodeModal" tabindex="-1" role="dialog" aria-labelledby="qrcodeModalLabel" <div class="modal fade" id="qrcodeModal" tabindex="-1" role="dialog" aria-labelledby="qrcodeModalLabel" aria-hidden="true">
aria-hidden="true">
<div class="modal-dialog" role="document"> <div class="modal-dialog" role="document">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@ -299,619 +297,265 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Show Links Modal -->
<div class="modal fade" id="showLinksModal" tabindex="-1" role="dialog" aria-labelledby="showLinksModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="showLinksModalLabel">Selected User Links</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p>Here are the Normal-SUB links for the selected users:</p>
<textarea id="linksTextarea" class="form-control" rows="10" readonly></textarea>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" id="copyLinksButton">Copy All</button>
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}
{% block javascripts %} {% block javascripts %}
<!-- Include qr-code-styling library -->
<script src="https://cdn.jsdelivr.net/npm/qr-code-styling/lib/qr-code-styling.js"></script> <script src="https://cdn.jsdelivr.net/npm/qr-code-styling/lib/qr-code-styling.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script> <script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script> <script>
$(function () { $(function () {
const usernameRegex = /^[a-zA-Z0-9_]+$/;
function checkIpLimitServiceStatus() { function checkIpLimitServiceStatus() {
fetch('{{ url_for("server_services_status_api") }}') $.getJSON('{{ url_for("server_services_status_api") }}')
.then(response => response.json()) .done(data => {
.then(data => {
if (data.hysteria_iplimit === true) { if (data.hysteria_iplimit === true) {
$('.requires-iplimit-service').show(); $('.requires-iplimit-service').show();
} }
}) })
.catch(error => console.error('Error fetching IP limit service status:', error)); .fail(() => console.error('Error fetching IP limit service status.'));
} }
checkIpLimitServiceStatus(); function validateUsername(inputElement, errorElement) {
const username = $(inputElement).val();
const usernameRegex = /^[a-zA-Z0-9_]+$/;
function validateUsername(username, errorElementId) {
const errorElement = $("#" + errorElementId);
if (!username) {
errorElement.text("");
return false;
}
const isValid = usernameRegex.test(username); const isValid = usernameRegex.test(username);
$(errorElement).text(isValid ? "" : "Usernames can only contain letters, numbers, and underscores.");
if (!isValid) { $(inputElement).closest('form').find('button[type="submit"]').prop('disabled', !isValid);
errorElement.text("Usernames can only contain letters, numbers, and underscores.");
return false;
} else {
errorElement.text("");
return true;
}
} }
$("#addSubmitButton").prop("disabled", true); $('#addUsername, #addBulkPrefix, #editUsername').on('input', function() {
validateUsername(this, `#${this.id}Error`);
$("#addUsername").on("input", function () {
const username = $(this).val();
const isValid = validateUsername(username, "addUsernameError");
$("#addSubmitButton").prop("disabled", !isValid);
});
$("#addBulkPrefix").on("input", function () {
const prefix = $(this).val();
const isValid = validateUsername(prefix, "addBulkPrefixError");
$("#addBulkSubmitButton").prop("disabled", !isValid);
});
$("#editUsername").on("input", function () {
const username = $(this).val();
const isValid = validateUsername(username, "editUsernameError");
$("#editSubmitButton").prop("disabled", !isValid);
}); });
$(".filter-button").on("click", function () { $(".filter-button").on("click", function () {
const filter = $(this).data("filter"); const filter = $(this).data("filter");
$("#selectAll").prop("checked", false); $("#selectAll").prop("checked", false);
$("#userTable tbody tr").each(function () { $("#userTable tbody tr").each(function () {
let showRow = true; let showRow;
switch (filter) { switch (filter) {
case "all": case "on-hold": showRow = $(this).find("td:eq(2) i").hasClass("text-warning"); break;
showRow = true; case "online": showRow = $(this).find("td:eq(2) i").hasClass("text-success"); break;
break; case "enable": showRow = $(this).find("td:eq(7) i").hasClass("text-success"); break;
case "not-active": case "disable": showRow = $(this).find("td:eq(7) i").hasClass("text-danger"); break;
showRow = $(this).find("td:eq(2) i").hasClass("text-danger"); default: showRow = true;
break;
case "online":
showRow = $(this).find("td:eq(2) i").hasClass("text-success");
break;
case "enable":
showRow = $(this).find("td:eq(7) i").hasClass("text-success");
break;
case "disable":
showRow = $(this).find("td:eq(7) i").hasClass("text-danger");
break;
} }
$(this).toggle(showRow).find(".user-checkbox").prop("checked", false);
if (showRow) {
$(this).show();
} else {
$(this).hide();
}
$(this).find(".user-checkbox").prop("checked", false);
}); });
}); });
$("#selectAll").on("change", function () { $("#selectAll").on("change", function () {
$("#userTable tbody tr:visible .user-checkbox").prop("checked", $(this).prop("checked")); $("#userTable tbody tr:visible .user-checkbox").prop("checked", this.checked);
}); });
$("#deleteSelected").on("click", function () { $("#deleteSelected").on("click", function () {
const selectedUsers = $(".user-checkbox:checked").map(function () { const selectedUsers = $(".user-checkbox:checked").map((_, el) => $(el).val()).get();
return $(this).val();
}).get();
if (selectedUsers.length === 0) { if (selectedUsers.length === 0) {
Swal.fire({ return Swal.fire("Warning!", "Please select at least one user to delete.", "warning");
title: "Warning!",
text: "Please select at least one user to delete.",
icon: "warning",
confirmButtonText: "OK",
});
return;
} }
Swal.fire({ Swal.fire({
title: "Are you sure?", title: "Are you sure?",
html: `This will delete the selected users: <b>${selectedUsers.join(", ")}</b>.<br>This action cannot be undone!`, html: `This will delete: <b>${selectedUsers.join(", ")}</b>.<br>This action cannot be undone!`,
icon: "warning", icon: "warning",
showCancelButton: true, showCancelButton: true,
confirmButtonColor: "#3085d6", confirmButtonColor: "#d33",
cancelButtonColor: "#d33",
confirmButtonText: "Yes, delete them!", confirmButtonText: "Yes, delete them!",
}).then((result) => { }).then((result) => {
if (result.isConfirmed) { if (!result.isConfirmed) return;
Promise.all(selectedUsers.map(username => { const urlTemplate = "{{ url_for('remove_user_api', username='U') }}";
const removeUserUrl = "{{ url_for('remove_user_api', username='USERNAME_PLACEHOLDER') }}"; const promises = selectedUsers.map(user => $.ajax({ url: urlTemplate.replace('U', user), method: "DELETE" }));
const url = removeUserUrl.replace("USERNAME_PLACEHOLDER", encodeURIComponent(username)); Promise.all(promises)
return $.ajax({ .then(() => Swal.fire("Success!", "Selected users deleted.", "success").then(() => location.reload()))
url: url, .catch(() => Swal.fire("Error!", "An error occurred while deleting users.", "error"));
method: "DELETE",
contentType: "application/json",
data: JSON.stringify({ username: username }),
});
}))
.then(responses => {
const allSuccessful = responses.every(response => response.detail);
if (allSuccessful) {
Swal.fire({
title: "Success!",
text: "Selected users deleted successfully!",
icon: "success",
confirmButtonText: "OK",
}).then(() => {
location.reload();
});
} else {
Swal.fire({
title: "Error!",
text: "Failed to delete some users.",
icon: "error",
confirmButtonText: "OK",
});
}
})
.catch(error => {
console.error("Error deleting users:", error);
Swal.fire({
title: "Error!",
text: "An error occurred while deleting users.",
icon: "error",
confirmButtonText: "OK",
});
});
}
}); });
}); });
$("#addUserForm").on("submit", function (e) { $("#addUserForm, #addBulkUsersForm").on("submit", function (e) {
e.preventDefault(); e.preventDefault();
if (!validateUsername($("#addUsername").val(), "addUsernameError")) { const form = $(this);
$("#addSubmitButton").prop("disabled", true); const isBulk = form.attr('id') === 'addBulkUsersForm';
return; const url = isBulk ? "{{ url_for('add_bulk_users_api') }}" : "{{ url_for('add_user_api') }}";
} const button = form.find('button[type="submit"]').prop('disabled', true);
$("#addSubmitButton").prop("disabled", true);
const jsonData = { const formData = new FormData(this);
username: $("#addUsername").val(), const jsonData = Object.fromEntries(formData.entries());
traffic_limit: $("#addTrafficLimit").val(),
expiration_days: $("#addExpirationDays").val(), jsonData.unlimited = jsonData.unlimited === 'on';
unlimited: $("#addUnlimited").is(":checked")
};
$.ajax({ $.ajax({
url: " {{ url_for('add_user_api') }} ", url: url,
method: "POST", method: "POST",
contentType: "application/json", contentType: "application/json",
data: JSON.stringify(jsonData), data: JSON.stringify(jsonData),
success: function (response) { })
Swal.fire({ .done(res => Swal.fire("Success!", res.detail, "success").then(() => location.reload()))
title: "Success!", .fail(err => Swal.fire("Error!", err.responseJSON?.detail || "An error occurred.", "error"))
text: response.detail || "User added successfully!", .always(() => button.prop('disabled', false));
icon: "success",
confirmButtonText: "OK",
}).then(() => {
location.reload();
});
},
error: function (jqXHR, textStatus, errorThrown) {
let errorMessage = "An error occurred while adding user.";
if (jqXHR.responseJSON && jqXHR.responseJSON.detail) {
errorMessage = jqXHR.responseJSON.detail;
} else if (jqXHR.status === 409) {
errorMessage = "User '" + jsonData.username + "' already exists.";
$("#addUsernameError").text(errorMessage);
} else if (jqXHR.status === 422) {
errorMessage = jqXHR.responseJSON.detail || "Invalid input provided.";
}
Swal.fire({
title: "Error!",
text: errorMessage,
icon: "error",
confirmButtonText: "OK",
});
$("#addSubmitButton").prop("disabled", false);
}
});
}); });
$("#addBulkUsersForm").on("submit", function(e) { $("#editUserModal").on("show.bs.modal", function (event) {
e.preventDefault(); const user = $(event.relatedTarget).data("user");
if (!validateUsername($("#addBulkPrefix").val(), "addBulkPrefixError")) { const row = $(event.relatedTarget).closest("tr");
$("#addBulkSubmitButton").prop("disabled", true); const trafficText = row.find("td:eq(4)").text();
return; const expiryText = row.find("td:eq(6)").text();
}
$("#addBulkSubmitButton").prop("disabled", true);
const jsonData = { $("#originalUsername").val(user);
prefix: $("#addBulkPrefix").val(), $("#editUsername").val(user);
count: parseInt($("#addBulkCount").val()), $("#editTrafficLimit").val(parseFloat(trafficText.split('/')[1]) || 0);
start_number: parseInt($("#addBulkStartNumber").val()), $("#editExpirationDays").val(parseInt(expiryText) || 0);
traffic_gb: parseFloat($("#addBulkTrafficLimit").val()), $("#editBlocked").prop("checked", !row.find("td:eq(7) i").hasClass("text-success"));
expiration_days: parseInt($("#addBulkExpirationDays").val()), $("#editUnlimitedIp").prop("checked", row.find(".unlimited-ip-cell i").hasClass("text-primary"));
unlimited: $("#addBulkUnlimited").is(":checked") validateUsername('#editUsername', '#editUsernameError');
};
$.ajax({
url: "{{ url_for('add_bulk_users_api') }}",
method: "POST",
contentType: "application/json",
data: JSON.stringify(jsonData),
success: function(response) {
Swal.fire({
title: "Success!",
text: response.detail || "Bulk user creation started successfully!",
icon: "success",
confirmButtonText: "OK",
}).then(() => {
location.reload();
});
},
error: function(jqXHR) {
let errorMessage = "An error occurred during bulk user creation.";
if (jqXHR.responseJSON && jqXHR.responseJSON.detail) {
errorMessage = jqXHR.responseJSON.detail;
}
Swal.fire({
title: "Error!",
text: errorMessage,
icon: "error",
confirmButtonText: "OK",
});
$("#addBulkSubmitButton").prop("disabled", false);
}
});
});
$(document).on("click", ".edit-user", function () {
const username = $(this).data("user");
const row = $(this).closest("tr");
const trafficUsageText = row.find("td:eq(4)").text().trim();
const expiryDaysText = row.find("td:eq(6)").text().trim();
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);
let trafficLimitValue = 0;
if (!trafficUsageText.toLowerCase().includes('/unlimited')) {
const parts = trafficUsageText.split('/');
if (parts.length > 1) {
const limitPart = parts[1].trim();
const match = limitPart.match(/^[\d.]+/);
if (match) {
trafficLimitValue = parseFloat(match[0]);
}
}
}
$("#originalUsername").val(username);
$("#editUsername").val(username);
$("#editTrafficLimit").val(trafficLimitValue);
$("#editExpirationDays").val(expiryDaysValue);
$("#editBlocked").prop("checked", blocked);
$("#editUnlimitedIp").prop("checked", unlimited_ip);
const isValid = validateUsername(username, "editUsernameError");
$("#editUserForm button[type='submit']").prop("disabled", !isValid);
}); });
$("#editUserForm").on("submit", function (e) { $("#editUserForm").on("submit", function (e) {
e.preventDefault(); e.preventDefault();
if (!validateUsername($("#editUsername").val(), "editUsernameError")) { const button = $("#editSubmitButton").prop("disabled", true);
return; const originalUsername = $("#originalUsername").val();
} const url = "{{ url_for('edit_user_api', username='U') }}".replace('U', originalUsername);
$("#editSubmitButton").prop("disabled", true);
const jsonData = { const formData = new FormData(this);
new_username: $("#editUsername").val(), const jsonData = Object.fromEntries(formData.entries());
new_traffic_limit: $("#editTrafficLimit").val() || null, jsonData.blocked = jsonData.blocked === 'on';
new_expiration_days: $("#editExpirationDays").val() || null, jsonData.unlimited_ip = jsonData.unlimited_ip === 'on';
blocked: $("#editBlocked").is(":checked"), if (jsonData.new_username === originalUsername) delete jsonData.new_username;
unlimited_ip: $("#editUnlimitedIp").is(":checked")
};
if (jsonData.new_username === $("#originalUsername").val()) {
delete jsonData.new_username;
}
const editUserUrl = "{{ url_for('edit_user_api', username='USERNAME_PLACEHOLDER') }}";
const url = editUserUrl.replace("USERNAME_PLACEHOLDER", encodeURIComponent($("#originalUsername").val()));
$.ajax({ $.ajax({
url: url, url: url,
method: "PATCH", method: "PATCH",
contentType: "application/json", contentType: "application/json",
data: JSON.stringify(jsonData), data: JSON.stringify(jsonData),
success: function (response) { })
if (response && response.detail) { .done(res => Swal.fire("Success!", res.detail, "success").then(() => location.reload()))
$("#editUserModal").modal("hide"); .fail(err => Swal.fire("Error!", err.responseJSON?.detail, "error"))
Swal.fire({ .always(() => button.prop('disabled', false));
title: "Success!",
text: response.detail,
icon: "success",
confirmButtonText: "OK",
}).then(() => {
location.reload();
});
} else {
$("#editUserModal").modal("hide");
Swal.fire({
title: "Error!",
text: (response && response.error) || "An unknown error occurred.",
icon: "error",
confirmButtonText: "OK",
});
$("#editSubmitButton").prop("disabled", false);
}
},
error: function (jqXHR) {
let errorMessage = "An error occurred while updating user.";
if (jqXHR.responseJSON && jqXHR.responseJSON.detail) {
errorMessage = jqXHR.responseJSON.detail;
}
Swal.fire({
title: "Error!",
text: errorMessage,
icon: "error",
confirmButtonText: "OK",
});
$("#editSubmitButton").prop("disabled", false);
}
});
}); });
// Reset User Button Click $("#userTable").on("click", ".reset-user, .delete-user", function () {
$("#userTable").on("click", ".reset-user", function () { const button = $(this);
const username = $(this).data("user"); const username = button.data("user");
const isDelete = button.hasClass("delete-user");
const action = isDelete ? "delete" : "reset";
const urlTemplate = isDelete ? "{{ url_for('remove_user_api', username='U') }}" : "{{ url_for('reset_user_api', username='U') }}";
Swal.fire({ Swal.fire({
title: "Are you sure?", title: `Are you sure you want to ${action}?`,
html: `This will reset <b>${username}</b>'s data.<br>This action cannot be undone!`, html: `This will ${action} user <b>${username}</b>.`,
icon: "warning", icon: "warning",
showCancelButton: true, showCancelButton: true,
confirmButtonColor: "#3085d6", confirmButtonColor: "#d33",
cancelButtonColor: "#d33", confirmButtonText: `Yes, ${action} it!`,
confirmButtonText: "Yes, reset it!",
}).then((result) => { }).then((result) => {
if (result.isConfirmed) { if (!result.isConfirmed) return;
const resetUserUrl = "{{ url_for('reset_user_api', username='USERNAME_PLACEHOLDER') }}"; $.ajax({
const url = resetUserUrl.replace("USERNAME_PLACEHOLDER", encodeURIComponent(username)); url: urlTemplate.replace("U", encodeURIComponent(username)),
$.ajax({ method: isDelete ? "DELETE" : "GET",
url: url, })
method: "GET", .done(res => Swal.fire("Success!", res.detail, "success").then(() => location.reload()))
contentType: "application/json", .fail(() => Swal.fire("Error!", `Failed to ${action} user.`, "error"));
data: JSON.stringify({ username: username }),
success: function (response) {
if (response.detail) {
Swal.fire({
title: "Success!",
text: response.detail,
icon: "success",
confirmButtonText: "OK",
}).then(() => {
location.reload();
});
} else {
Swal.fire({
title: "Error!",
text: response.error || "Failed to reset user",
icon: "error",
confirmButtonText: "OK",
});
}
},
error: function () {
Swal.fire({
title: "Error!",
text: "An error occurred while resetting user",
icon: "error",
confirmButtonText: "OK",
});
}
});
}
}); });
}); });
// Delete User Button Click
$("#userTable").on("click", ".delete-user", function () {
const username = $(this).data("user");
Swal.fire({
title: "Are you sure?",
html: `This will delete the user <b>${username}</b>.<br>This action cannot be undone!`,
icon: "warning",
showCancelButton: true,
confirmButtonColor: "#3085d6",
cancelButtonColor: "#d33",
confirmButtonText: "Yes, delete it!",
}).then((result) => {
if (result.isConfirmed) {
const removeUserUrl = "{{ url_for('remove_user_api', username='USERNAME_PLACEHOLDER') }}";
const url = removeUserUrl.replace("USERNAME_PLACEHOLDER", encodeURIComponent(username));
$.ajax({
url: url,
method: "DELETE",
contentType: "application/json",
data: JSON.stringify({ username: username }),
success: function (response) {
if (response.detail) {
Swal.fire({
title: "Success!",
text: response.detail,
icon: "success",
confirmButtonText: "OK",
}).then(() => {
location.reload();
});
} else {
Swal.fire({
title: "Error!",
text: response.error || "Failed to delete user",
icon: "error",
confirmButtonText: "OK",
});
}
},
error: function () {
Swal.fire({
title: "Error!",
text: "An error occurred while deleting user",
icon: "error",
confirmButtonText: "OK",
});
}
});
}
});
});
// QR Code Modal
$("#qrcodeModal").on("show.bs.modal", function (event) { $("#qrcodeModal").on("show.bs.modal", function (event) {
const button = $(event.relatedTarget); const username = $(event.relatedTarget).data("username");
const username = button.data("username"); const qrcodesContainer = $("#qrcodesContainer").empty();
const qrcodesContainer = $("#qrcodesContainer"); const url = "{{ url_for('show_user_uri_api', username='U') }}".replace("U", encodeURIComponent(username));
qrcodesContainer.empty(); $.getJSON(url, response => {
[
{ type: "IPv4", link: response.ipv4 },
{ type: "IPv6", link: response.ipv6 },
{ type: "Normal-SUB", link: response.normal_sub }
].forEach(config => {
if (!config.link) return;
const qrId = `qrcode-${config.type}`;
const card = $(`<div class="card d-inline-block m-2"><div class="card-body"><div id="${qrId}" class="mx-auto" style="cursor: pointer;"></div><div class="mt-2 text-center small text-body font-weight-bold">${config.type}</div></div></div>`);
qrcodesContainer.append(card);
new QRCodeStyling({ width: 200, height: 200, data: config.link, margin: 2 }).append(document.getElementById(qrId));
card.on("click", () => navigator.clipboard.writeText(config.link).then(() => Swal.fire({ icon: "success", title: `${config.type} link copied!`, showConfirmButton: false, timer: 1200 })));
});
}).fail(() => Swal.fire("Error!", "Failed to fetch user configuration.", "error"));
});
const userUriApiUrl = "{{ url_for('show_user_uri_api', username='USERNAME_PLACEHOLDER') }}"; $("#showSelectedLinks").on("click", function () {
const url = userUriApiUrl.replace("USERNAME_PLACEHOLDER", encodeURIComponent(username)); const selectedUsers = $(".user-checkbox:checked").map((_, el) => $(el).val()).get();
if (selectedUsers.length === 0) {
return Swal.fire("Warning!", "Please select at least one user.", "warning");
}
Swal.fire({ title: 'Fetching links...', text: 'Please wait.', allowOutsideClick: false, didOpen: () => Swal.showLoading() });
const bulkUriApiUrl = "{{ url_for('show_multiple_user_uris_api') }}";
$.ajax({ $.ajax({
url: url, url: bulkUriApiUrl,
method: "GET", method: 'POST',
dataType: 'json', contentType: 'application/json',
success: function (response) { data: JSON.stringify({ usernames: selectedUsers }),
const configs = [ }).done(results => {
{ type: "IPv4", link: response.ipv4 }, Swal.close();
{ type: "IPv6", link: response.ipv6 }, const allLinks = results.map(res => res.normal_sub).filter(Boolean);
{ type: "Normal-SUB", link: response.normal_sub } const failedCount = selectedUsers.length - allLinks.length;
]; if (failedCount > 0) {
Swal.fire('Warning', `Could not fetch links for ${failedCount} user(s), but others were successful.`, 'warning');
configs.forEach(config => {
if (config.link) {
const displayType = config.type;
const configLink = config.link;
const qrCodeId = `qrcode-${displayType}-${Math.random().toString(36).substring(2, 10)}`;
const card = $(`
<div class="card d-inline-block my-2">
<div class="card-body">
<div id="${qrCodeId}" class="mx-auto cursor-pointer"></div>
<br>
<div class="config-type-text mt-2 text-center">${displayType}</div>
</div>
</div>
`);
qrcodesContainer.append(card);
const qrCodeStyling = new QRCodeStyling({
width: 180,
height: 180,
data: configLink,
margin: 5,
dotsOptions: {
color: "#212121",
type: "square"
},
cornersSquareOptions: {
color: "#212121",
type: "square"
},
backgroundOptions: {
color: "#FAFAFA",
},
imageOptions: {
hideBackgroundDots: true,
}
});
qrCodeStyling.append(document.getElementById(qrCodeId));
card.on("click", function () {
navigator.clipboard.writeText(configLink)
.then(() => {
Swal.fire({
icon: "success",
title: displayType + " link copied!",
showConfirmButton: false,
timer: 1500,
});
})
.catch(err => {
console.error("Failed to copy link: ", err);
Swal.fire({
icon: "error",
title: "Failed to copy link",
text: "Please copy manually.",
});
});
});
}
});
},
error: function (error) {
console.error("Error fetching user URI:", error);
Swal.fire({
title: "Error!",
text: "Failed to fetch user configuration URIs.",
icon: "error",
confirmButtonText: "OK",
});
} }
}); if (allLinks.length > 0) {
$("#linksTextarea").val(allLinks.join('\n'));
$("#showLinksModal").modal("show");
} else {
Swal.fire('Error', `Could not fetch links for any of the selected users.`, 'error');
}
}).fail(() => Swal.fire('Error!', 'An error occurred while fetching the links.', 'error'));
}); });
$("#qrcodeModal .modal-content").on("click", function (e) { $("#copyLinksButton").on("click", () => {
e.stopPropagation(); navigator.clipboard.writeText($("#linksTextarea").val())
}); .then(() => Swal.fire({ icon: "success", title: "All links copied!", showConfirmButton: false, timer: 1200 }));
$("#qrcodeModal").on("hidden.bs.modal", function () {
$("#qrcodesContainer").empty();
});
$("#qrcodeModal .close").on("click", function () {
$("#qrcodeModal").modal("hide");
}); });
function filterUsers() { function filterUsers() {
const searchText = $("#searchInput").val().toLowerCase(); const searchText = $("#searchInput").val().toLowerCase();
$("#userTable tbody tr").each(function () { $("#userTable tbody tr").each(function () {
const username = $(this).find("td:eq(3)").text().toLowerCase(); const username = $(this).find("td:eq(3)").text().toLowerCase();
if (username.includes(searchText)) { $(this).toggle(username.includes(searchText));
$(this).show();
} else {
$(this).hide();
}
}); });
} }
$('#addUserModal').on('show.bs.modal', function (event) { $('#addUserModal').on('show.bs.modal', function () {
$('#addUserForm')[0].reset(); $('#addUserForm, #addBulkUsersForm').trigger('reset');
$('#addBulkUsersForm')[0].reset(); $('#addUsernameError, #addBulkPrefixError').text('');
$('#addUsernameError').text(''); Object.assign(document.getElementById('addTrafficLimit'), {value: 30});
$('#addBulkPrefixError').text(''); Object.assign(document.getElementById('addExpirationDays'), {value: 30});
$('#addTrafficLimit').val('30'); Object.assign(document.getElementById('addBulkTrafficLimit'), {value: 30});
$('#addExpirationDays').val('30'); Object.assign(document.getElementById('addBulkExpirationDays'), {value: 30});
$('#addBulkTrafficLimit').val('30'); $('#addSubmitButton, #addBulkSubmitButton').prop('disabled', true);
$('#addBulkExpirationDays').val('30');
$('#addBulkStartNumber').val('1');
$('#addSubmitButton').prop('disabled', true);
$('#addBulkSubmitButton').prop('disabled', true);
$('#addUserModal a[data-toggle="tab"]').first().tab('show'); $('#addUserModal a[data-toggle="tab"]').first().tab('show');
}); });
$("#searchButton").on("click", filterUsers); $("#searchButton, #searchInput").on("click keyup", filterUsers);
$("#searchInput").on("keyup", filterUsers);
checkIpLimitServiceStatus();
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@ -16,16 +16,6 @@ LOCKFILE = "/tmp/kick.lock"
BACKUP_FILE = f"{USERS_FILE}.bak" BACKUP_FILE = f"{USERS_FILE}.bak"
MAX_WORKERS = 8 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(): def acquire_lock():
"""Acquires a lock file to prevent concurrent execution""" """Acquires a lock file to prevent concurrent execution"""
try: try:
@ -36,14 +26,7 @@ def acquire_lock():
sys.exit(1) sys.exit(1)
def traffic_status(no_gui=False): def traffic_status(no_gui=False):
"""Updates and retrieves traffic statistics for all users. """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
"""
green = '\033[0;32m' green = '\033[0;32m'
cyan = '\033[0;36m' cyan = '\033[0;36m'
NC = '\033[0m' 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" users_data[user_id]["status"] = "Online" if status.is_online else "Offline"
else: else:
users_data[user_id] = { users_data[user_id] = {
"upload_bytes": 0, "upload_bytes": 0, "download_bytes": 0,
"download_bytes": 0,
"status": "Online" if status.is_online else "Offline" "status": "Online" if status.is_online else "Offline"
} }
@ -102,11 +84,23 @@ def traffic_status(no_gui=False):
else: else:
online = user_id in online_status and online_status[user_id].is_online online = user_id in online_status and online_status[user_id].is_online
users_data[user_id] = { users_data[user_id] = {
"upload_bytes": stats.upload_bytes, "upload_bytes": stats.upload_bytes, "download_bytes": stats.download_bytes,
"download_bytes": stats.download_bytes,
"status": "Online" if online else "Offline" "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: with open(USERS_FILE, 'w') as users_file:
json.dump(users_data, users_file, indent=4) 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(f"{user:<15} {green}{formatted_tx:<15}{NC} {cyan}{formatted_rx:<15}{NC} {status:<10}")
print("-------------------------------------------------") print("-------------------------------------------------")
def format_bytes(bytes): def format_bytes(bytes_val):
"""Format bytes as human-readable string""" """Format bytes as human-readable string"""
if bytes < 1024: if bytes_val < 1024: return f"{bytes_val}B"
return f"{bytes}B" elif bytes_val < 1048576: return f"{bytes_val / 1024:.2f}KB"
elif bytes < 1048576: elif bytes_val < 1073741824: return f"{bytes_val / 1048576:.2f}MB"
return f"{bytes / 1024:.2f}KB" elif bytes_val < 1099511627776: return f"{bytes_val / 1073741824:.2f}GB"
elif bytes < 1073741824: else: return f"{bytes_val / 1099511627776:.2f}TB"
return f"{bytes / 1048576:.2f}MB"
elif bytes < 1099511627776:
return f"{bytes / 1073741824:.2f}GB"
else:
return f"{bytes / 1099511627776:.2f}TB"
def kick_users(usernames, secret): def kick_users(usernames, secret):
"""Kicks specified users from the server""" """Kicks specified users from the server"""
try: try:
client = Hysteria2Client( client = Hysteria2Client(base_url=API_BASE_URL, secret=secret)
base_url=API_BASE_URL,
secret=secret
)
client.kick_clients(usernames) client.kick_clients(usernames)
return True return True
except Exception: except Exception:
return False 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""" """Process a single user to check if they should be kicked"""
blocked = user_data.get('blocked', False) if user_data.get('blocked', False): return None
if blocked: account_creation_date = user_data.get('account_creation_date')
return None if not account_creation_date: return None
max_download_bytes = user_data.get('max_download_bytes', 0) max_download_bytes = user_data.get('max_download_bytes', 0)
expiration_days = user_data.get('expiration_days', 0) expiration_days = user_data.get('expiration_days', 0)
account_creation_date = user_data.get('account_creation_date') total_bytes = user_data.get('download_bytes', 0) + user_data.get('upload_bytes', 0)
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
should_block = False
try: try:
current_date = datetime.datetime.now().timestamp() if expiration_days > 0:
creation_date = datetime.datetime.fromisoformat(account_creation_date.replace('Z', '+00:00')) creation_date = datetime.datetime.strptime(account_creation_date, "%Y-%m-%d")
expiration_date = (creation_date + datetime.timedelta(days=expiration_days)).timestamp() expiration_date = creation_date + datetime.timedelta(days=expiration_days)
if datetime.datetime.now() >= expiration_date:
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:
should_block = True should_block = True
if should_block: if not should_block and max_download_bytes > 0 and total_bytes >= max_download_bytes:
users_data[username]['blocked'] = True should_block = True
return username
except Exception: if should_block:
users_data[username]['blocked'] = True
return username
except (ValueError, TypeError):
return None return None
return None return None
@ -206,59 +183,39 @@ def kick_expired_users():
lock_file = acquire_lock() lock_file = acquire_lock()
try: try:
if not os.path.exists(USERS_FILE): return
shutil.copy2(USERS_FILE, BACKUP_FILE) shutil.copy2(USERS_FILE, BACKUP_FILE)
try: try:
with open(CONFIG_FILE, 'r') as f: with open(CONFIG_FILE, 'r') as f:
config = json.load(f) config = json.load(f)
secret = config.get('trafficStats', {}).get('secret', '') secret = config.get('trafficStats', {}).get('secret', '')
if not secret: if not secret: sys.exit(1)
sys.exit(1)
except Exception: except Exception:
shutil.copy2(BACKUP_FILE, USERS_FILE)
sys.exit(1) sys.exit(1)
try: with open(USERS_FILE, 'r') as f:
with open(USERS_FILE, 'r') as f: users_data = json.load(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)
users_to_kick = [] users_to_kick = []
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor: with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
future_to_user = { futures = [executor.submit(process_user, u, d, users_data) for u, d in users_data.items()]
executor.submit(process_user, username, user_data, secret, users_data): username for future in futures:
for username, user_data in users_data.items() result = future.result()
} if result:
users_to_kick.append(result)
for future in future_to_user:
username = future.result()
if username:
users_to_kick.append(username)
if users_to_kick: if users_to_kick:
for retry in range(3): with open(USERS_FILE, 'w') as f:
try: json.dump(users_data, f, indent=4)
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: for i in range(0, len(users_to_kick), 50):
batch_size = 50 batch = users_to_kick[i:i+50]
for i in range(0, len(users_to_kick), batch_size):
batch = users_to_kick[i:i+batch_size]
kick_users(batch, secret) kick_users(batch, secret)
except Exception: except Exception:
shutil.copy2(BACKUP_FILE, USERS_FILE) if os.path.exists(BACKUP_FILE):
shutil.copy2(BACKUP_FILE, USERS_FILE)
sys.exit(1) sys.exit(1)
finally: finally:
fcntl.flock(lock_file, fcntl.LOCK_UN) fcntl.flock(lock_file, fcntl.LOCK_UN)