Merge pull request #254 from ReturnFI/beta

Bulk User Creation & Sorting Improvements
This commit is contained in:
Whispering Wind
2025-08-21 15:40:07 +03:30
committed by GitHub
7 changed files with 336 additions and 33 deletions

View File

@ -138,6 +138,20 @@ def add_user(username: str, traffic_limit: int, expiration_days: int, password:
except Exception as e: except Exception as e:
click.echo(f'{e}', err=True) click.echo(f'{e}', err=True)
@cli.command('bulk-user-add')
@click.option('--traffic-gb', '-t', required=True, help='Traffic limit for each user in GB.', type=float)
@click.option('--expiration-days', '-e', required=True, help='Expiration duration for each user in days.', type=int)
@click.option('--count', '-c', required=True, help='Number of users to create.', type=int)
@click.option('--prefix', '-p', required=True, help='Prefix for usernames.', type=str)
@click.option('--start-number', '-s', default=1, help='Starting number for username suffix.', type=int)
@click.option('--unlimited', is_flag=True, default=False, help='Flag to mark users as unlimited (exempt from IP limits).')
def bulk_user_add(traffic_gb: float, expiration_days: int, count: int, prefix: str, start_number: int, unlimited: bool):
"""Adds multiple users in bulk."""
try:
cli_api.bulk_user_add(traffic_gb, expiration_days, count, prefix, start_number, unlimited)
click.echo(f"Successfully initiated the creation of {count} users with prefix '{prefix}'.")
except Exception as e:
click.echo(f'Error during bulk user addition: {e}', err=True)
@cli.command('edit-user') @cli.command('edit-user')
@click.option('--username', '-u', required=True, help='Username for the user to edit', type=str) @click.option('--username', '-u', required=True, help='Username for the user to edit', type=str)

View File

@ -27,6 +27,7 @@ class Command(Enum):
CHANGE_SNI_HYSTERIA2 = os.path.join(SCRIPT_DIR, 'hysteria2', 'change_sni.py') CHANGE_SNI_HYSTERIA2 = os.path.join(SCRIPT_DIR, 'hysteria2', 'change_sni.py')
GET_USER = os.path.join(SCRIPT_DIR, 'hysteria2', 'get_user.py') GET_USER = os.path.join(SCRIPT_DIR, 'hysteria2', 'get_user.py')
ADD_USER = os.path.join(SCRIPT_DIR, 'hysteria2', 'add_user.py') ADD_USER = os.path.join(SCRIPT_DIR, 'hysteria2', 'add_user.py')
BULK_USER = os.path.join(SCRIPT_DIR, 'hysteria2', 'bulk_users.py')
EDIT_USER = os.path.join(SCRIPT_DIR, 'hysteria2', 'edit_user.sh') EDIT_USER = os.path.join(SCRIPT_DIR, 'hysteria2', 'edit_user.sh')
RESET_USER = os.path.join(SCRIPT_DIR, 'hysteria2', 'reset_user.py') RESET_USER = os.path.join(SCRIPT_DIR, 'hysteria2', 'reset_user.py')
REMOVE_USER = os.path.join(SCRIPT_DIR, 'hysteria2', 'remove_user.py') REMOVE_USER = os.path.join(SCRIPT_DIR, 'hysteria2', 'remove_user.py')
@ -285,6 +286,24 @@ def add_user(username: str, traffic_limit: int, expiration_days: int, password:
run_cmd(command) run_cmd(command)
def bulk_user_add(traffic_gb: float, expiration_days: int, count: int, prefix: str, start_number: int, unlimited: bool):
"""
Executes the bulk user creation script with specified parameters.
"""
command = [
'python3',
Command.BULK_USER.value,
'--traffic-gb', str(traffic_gb),
'--expiration-days', str(expiration_days),
'--count', str(count),
'--prefix', prefix,
'--start-number', str(start_number)
]
if unlimited:
command.append('--unlimited')
run_cmd(command)
def edit_user(username: str, new_username: str | None, new_traffic_limit: int | None, new_expiration_days: int | None, renew_password: bool, renew_creation_date: bool, blocked: bool | None, unlimited_ip: bool | None): def edit_user(username: str, new_username: str | None, new_traffic_limit: int | None, new_expiration_days: int | None, renew_password: bool, renew_creation_date: bool, blocked: bool | None, unlimited_ip: bool | None):
''' '''

View File

@ -60,8 +60,8 @@ def add_user(username, traffic_gb, expiration_days, password=None, creation_date
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
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 and numbers.") print("Error: Username can only contain letters, numbers, and underscores.")
return 1 return 1
if not os.path.isfile(USERS_FILE): if not os.path.isfile(USERS_FILE):

View File

@ -0,0 +1,111 @@
#!/usr/bin/env python3
import json
import sys
import os
import subprocess
import argparse
import re
from datetime import datetime
from init_paths import *
from paths import *
def add_bulk_users(traffic_gb, expiration_days, count, prefix, start_number, unlimited_user):
try:
traffic_bytes = int(float(traffic_gb) * 1073741824)
except ValueError:
print("Error: Traffic limit must be a numeric value.")
return 1
if not os.path.isfile(USERS_FILE):
try:
with open(USERS_FILE, 'w') as f:
json.dump({}, f)
except IOError:
print(f"Error: Could not create {USERS_FILE}.")
return 1
try:
with open(USERS_FILE, 'r+') as f:
try:
users_data = json.load(f)
except json.JSONDecodeError:
print(f"Error: {USERS_FILE} contains invalid JSON.")
return 1
existing_users_lower = {u.lower() for u in users_data}
new_users_to_add = {}
creation_date = datetime.now().strftime("%Y-%m-%d")
try:
password_process = subprocess.run(['pwgen', '-s', '32', str(count)], capture_output=True, text=True, check=True)
passwords = password_process.stdout.strip().split('\n')
except (FileNotFoundError, subprocess.CalledProcessError):
print("Warning: 'pwgen' not found or failed. Falling back to UUID for password generation.")
passwords = [subprocess.check_output(['cat', '/proc/sys/kernel/random/uuid'], text=True).strip() for _ in range(count)]
if len(passwords) < count:
print("Error: Could not generate enough passwords.")
return 1
for i in range(count):
username = f"{prefix}{start_number + i}"
username_lower = username.lower()
if not re.match(r"^[a-zA-Z0-9_]+$", username_lower):
print(f"Error: Generated username '{username}' contains invalid characters. Use only letters, numbers, and underscores.")
continue
if username_lower in existing_users_lower or username_lower in new_users_to_add:
print(f"Warning: User '{username}' already exists. Skipping.")
continue
new_users_to_add[username_lower] = {
"password": passwords[i],
"max_download_bytes": traffic_bytes,
"expiration_days": expiration_days,
"account_creation_date": creation_date,
"blocked": False,
"unlimited_user": unlimited_user
}
# print(f"Preparing to add user: {username}")
if not new_users_to_add:
print("No new users to add.")
return 0
users_data.update(new_users_to_add)
f.seek(0)
json.dump(users_data, f, indent=4)
f.truncate()
print(f"\nSuccessfully added {len(new_users_to_add)} users.")
return 0
except IOError:
print(f"Error: Could not read or write to {USERS_FILE}.")
return 1
except Exception as e:
print(f"An unexpected error occurred: {e}")
return 1
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Add bulk users to Hysteria2.")
parser.add_argument("-t", "--traffic-gb", dest="traffic_gb", type=float, required=True, help="Traffic limit for each user in GB.")
parser.add_argument("-e", "--expiration-days", dest="expiration_days", type=int, required=True, help="Expiration duration for each user in days.")
parser.add_argument("-c", "--count", type=int, required=True, help="Number of users to create.")
parser.add_argument("-p", "--prefix", type=str, required=True, help="Prefix for usernames.")
parser.add_argument("-s", "--start-number", type=int, default=1, help="Starting number for username suffix (default: 1).")
parser.add_argument("-u", "--unlimited", action='store_true', help="Flag to mark users as unlimited (exempt from IP limits).")
args = parser.parse_args()
sys.exit(add_bulk_users(
traffic_gb=args.traffic_gb,
expiration_days=args.expiration_days,
count=args.count,
prefix=args.prefix,
start_number=args.start_number,
unlimited_user=args.unlimited
))

View File

@ -1,5 +1,6 @@
import re
from typing import Optional, List from typing import Optional, List
from pydantic import BaseModel, RootModel, Field from pydantic import BaseModel, RootModel, Field, field_validator
class UserInfoResponse(BaseModel): class UserInfoResponse(BaseModel):
@ -26,6 +27,27 @@ class AddUserInputBody(BaseModel):
creation_date: Optional[str] = None creation_date: Optional[str] = None
unlimited: bool = False unlimited: bool = False
@field_validator('username')
def validate_username(cls, v):
if not re.match(r"^[a-zA-Z0-9_]+$", v):
raise ValueError('Username can only contain letters, numbers, and underscores.')
return v
class AddBulkUsersInputBody(BaseModel):
traffic_gb: float
expiration_days: int
count: int
prefix: str
start_number: int = 1
unlimited: bool = False
@field_validator('prefix')
def validate_prefix(cls, v):
if not re.match(r"^[a-zA-Z0-9_]*$", v):
raise ValueError('Prefix can only contain letters, numbers, and underscores.')
return v
class EditUserInputBody(BaseModel): class EditUserInputBody(BaseModel):
new_username: Optional[str] = None new_username: Optional[str] = None
@ -36,6 +58,12 @@ class EditUserInputBody(BaseModel):
blocked: Optional[bool] = None blocked: Optional[bool] = None
unlimited_ip: Optional[bool] = None unlimited_ip: Optional[bool] = None
@field_validator('new_username')
def validate_new_username(cls, v):
if v and not re.match(r"^[a-zA-Z0-9_]+$", v):
raise ValueError('Username can only contain letters, numbers, and underscores.')
return v
class NodeUri(BaseModel): class NodeUri(BaseModel):
name: str name: str
uri: str uri: str

View File

@ -1,7 +1,7 @@
import json import json
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from .schema.user import UserListResponse, UserInfoResponse, AddUserInputBody, EditUserInputBody, UserUriResponse from .schema.user import UserListResponse, UserInfoResponse, AddUserInputBody, EditUserInputBody, UserUriResponse, AddBulkUsersInputBody
from .schema.response import DetailResponse from .schema.response import DetailResponse
import cli_api import cli_api
@ -57,6 +57,29 @@ async def add_user_api(body: AddUserInputBody):
detail=f"An unexpected error occurred while adding user '{body.username}': {str(e)}") detail=f"An unexpected error occurred while adding user '{body.username}': {str(e)}")
@router.post('/bulk/', response_model=DetailResponse, status_code=201)
async def add_bulk_users_api(body: AddBulkUsersInputBody):
"""
Add multiple users in bulk.
"""
try:
cli_api.bulk_user_add(
traffic_gb=body.traffic_gb,
expiration_days=body.expiration_days,
count=body.count,
prefix=body.prefix,
start_number=body.start_number,
unlimited=body.unlimited
)
return DetailResponse(detail=f"Successfully started adding {body.count} users with prefix '{body.prefix}'.")
except cli_api.CommandExecutionError as e:
raise HTTPException(status_code=400,
detail=f'Failed to add bulk users: {str(e)}')
except Exception as e:
raise HTTPException(status_code=500,
detail=f"An unexpected error occurred while adding bulk users: {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):
""" """

View File

@ -160,8 +160,7 @@
<!-- Add User Modal --> <!-- Add User Modal -->
<div class="modal fade" id="addUserModal" tabindex="-1" role="dialog" aria-labelledby="addUserModalLabel" <div class="modal fade" id="addUserModal" tabindex="-1" role="dialog" aria-labelledby="addUserModalLabel" 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">
@ -171,7 +170,17 @@
</button> </button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form id="addUserForm"> <ul class="nav nav-tabs" id="addUserTab" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="single-user-tab" data-toggle="tab" href="#single-user" role="tab" aria-controls="single-user" aria-selected="true">Add User</a>
</li>
<li class="nav-item">
<a class="nav-link" id="bulk-users-tab" data-toggle="tab" href="#bulk-users" role="tab" aria-controls="bulk-users" aria-selected="false">Bulk Add</a>
</li>
</ul>
<div class="tab-content" id="addUserTabContent">
<div class="tab-pane fade show active" id="single-user" role="tabpanel" aria-labelledby="single-user-tab">
<form id="addUserForm" class="mt-3">
<div class="form-group"> <div class="form-group">
<label for="addUsername">Username</label> <label for="addUsername">Username</label>
<input type="text" class="form-control" id="addUsername" name="username" required> <input type="text" class="form-control" id="addUsername" name="username" required>
@ -183,8 +192,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="addExpirationDays">Expiration Days</label> <label for="addExpirationDays">Expiration Days</label>
<input type="number" class="form-control" id="addExpirationDays" name="expiration_days" <input type="number" class="form-control" id="addExpirationDays" name="expiration_days" required>
required>
</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">
@ -193,9 +201,46 @@
<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>
</div> </div>
<div class="tab-pane fade" id="bulk-users" role="tabpanel" aria-labelledby="bulk-users-tab">
<form id="addBulkUsersForm" class="mt-3">
<div class="form-group">
<label for="addBulkPrefix">Username Prefix</label>
<input type="text" class="form-control" id="addBulkPrefix" name="prefix" required>
<small class="form-text text-danger" id="addBulkPrefixError"></small>
</div>
<div class="form-row">
<div class="form-group col-md-6">
<label for="addBulkCount">Number of Users</label>
<input type="number" class="form-control" id="addBulkCount" name="count" value="10" required>
</div>
<div class="form-group col-md-6">
<label for="addBulkStartNumber">Start Number</label>
<input type="number" class="form-control" id="addBulkStartNumber" name="start_number" value="1" required>
</div>
</div>
<div class="form-row">
<div class="form-group col-md-6">
<label for="addBulkTrafficLimit">Traffic Limit (GB)</label>
<input type="number" class="form-control" id="addBulkTrafficLimit" name="traffic_gb" required>
</div>
<div class="form-group col-md-6">
<label for="addBulkExpirationDays">Expiration Days</label>
<input type="number" class="form-control" id="addBulkExpirationDays" name="expiration_days" required>
</div>
</div>
<div class="form-check mb-3 requires-iplimit-service" style="display: none;">
<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>
</div>
<button type="submit" class="btn btn-primary" id="addBulkSubmitButton">Add Bulk Users</button>
</form>
</div>
</div>
</div>
</div> </div>
</div> </div>
</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">
@ -277,14 +322,18 @@
checkIpLimitServiceStatus(); checkIpLimitServiceStatus();
const usernameRegex = /^[a-zA-Z0-9]+$/; const usernameRegex = /^[a-zA-Z0-9_]+$/;
function validateUsername(username, errorElementId) { function validateUsername(username, errorElementId) {
const isValid = usernameRegex.test(username);
const errorElement = $("#" + errorElementId); const errorElement = $("#" + errorElementId);
if (!username) {
errorElement.text("");
return false;
}
const isValid = usernameRegex.test(username);
if (!isValid) { if (!isValid) {
errorElement.text("Usernames can only contain letters and numbers."); errorElement.text("Usernames can only contain letters, numbers, and underscores.");
return false; return false;
} else { } else {
errorElement.text(""); errorElement.text("");
@ -297,14 +346,18 @@
$("#addUsername").on("input", function () { $("#addUsername").on("input", function () {
const username = $(this).val(); const username = $(this).val();
const isValid = validateUsername(username, "addUsernameError"); const isValid = validateUsername(username, "addUsernameError");
$("#addUserForm button[type='submit']").prop("disabled", !isValid);
$("#addSubmitButton").prop("disabled", !isValid); $("#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 () { $("#editUsername").on("input", function () {
const username = $(this).val(); const username = $(this).val();
const isValid = validateUsername(username, "editUsernameError"); const isValid = validateUsername(username, "editUsernameError");
$("#editUserForm button[type='submit']").prop("disabled", !isValid);
$("#editSubmitButton").prop("disabled", !isValid); $("#editSubmitButton").prop("disabled", !isValid);
}); });
@ -466,6 +519,54 @@
}); });
}); });
$("#addBulkUsersForm").on("submit", function(e) {
e.preventDefault();
if (!validateUsername($("#addBulkPrefix").val(), "addBulkPrefixError")) {
$("#addBulkSubmitButton").prop("disabled", true);
return;
}
$("#addBulkSubmitButton").prop("disabled", true);
const jsonData = {
prefix: $("#addBulkPrefix").val(),
count: parseInt($("#addBulkCount").val()),
start_number: parseInt($("#addBulkStartNumber").val()),
traffic_gb: parseFloat($("#addBulkTrafficLimit").val()),
expiration_days: parseInt($("#addBulkExpirationDays").val()),
unlimited: $("#addBulkUnlimited").is(":checked")
};
$.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 () { $(document).on("click", ".edit-user", function () {
const username = $(this).data("user"); const username = $(this).data("user");
const row = $(this).closest("tr"); const row = $(this).closest("tr");
@ -795,10 +896,17 @@
$('#addUserModal').on('show.bs.modal', function (event) { $('#addUserModal').on('show.bs.modal', function (event) {
$('#addUserForm')[0].reset(); $('#addUserForm')[0].reset();
$('#addBulkUsersForm')[0].reset();
$('#addUsernameError').text(''); $('#addUsernameError').text('');
$('#addBulkPrefixError').text('');
$('#addTrafficLimit').val('30'); $('#addTrafficLimit').val('30');
$('#addExpirationDays').val('30'); $('#addExpirationDays').val('30');
$('#addBulkTrafficLimit').val('30');
$('#addBulkExpirationDays').val('30');
$('#addBulkStartNumber').val('1');
$('#addSubmitButton').prop('disabled', true); $('#addSubmitButton').prop('disabled', true);
$('#addBulkSubmitButton').prop('disabled', true);
$('#addUserModal a[data-toggle="tab"]').first().tab('show');
}); });
$("#searchButton").on("click", filterUsers); $("#searchButton").on("click", filterUsers);