perf(api): optimize bulk user URI fetching in webpanel

Refactored the web panel's user link generation to resolve a major performance bottleneck. Previously, fetching links for N users would trigger N separate script executions, causing significant delays from process startup overhead.

- Introduced a new bulk API endpoint (`/api/v1/users/uri/bulk`) that accepts a list of usernames and calls the backend script only once.
- Updated the frontend JavaScript in `users.html` to use this new endpoint, replacing N parallel API calls with a single one.
- Cleaned up the `wrapper_uri.py` script for better readability and maintainability.
This commit is contained in:
Whispering Wind
2025-08-27 17:22:04 +03:30
committed by GitHub
parent c494250f9f
commit 18d3a1029b
4 changed files with 227 additions and 698 deletions

View File

@ -15,7 +15,7 @@ def load_json_file(file_path: str) -> Any:
if not os.path.exists(file_path): if not os.path.exists(file_path):
return None return None
try: try:
with open(file_path, 'r') as f: with open(file_path, 'r', encoding='utf-8') as f:
content = f.read() content = f.read()
return json.loads(content) if content else None return json.loads(content) if content else None
except (json.JSONDecodeError, IOError): except (json.JSONDecodeError, IOError):
@ -25,7 +25,7 @@ def load_json_file(file_path: str) -> Any:
def load_env_file(env_file: str) -> Dict[str, str]: def load_env_file(env_file: str) -> Dict[str, str]:
env_vars = {} env_vars = {}
if os.path.exists(env_file): if os.path.exists(env_file):
with open(env_file, 'r') as f: with open(env_file, 'r', encoding='utf-8') as f:
for line in f: for line in f:
line = line.strip() line = line.strip()
if line and not line.startswith('#') and '=' in line: if line and not line.startswith('#') and '=' in line:
@ -34,51 +34,44 @@ def load_env_file(env_file: str) -> Dict[str, str]:
return env_vars return env_vars
def generate_uri(username: str, auth_password: str, ip: str, port: str, def generate_uri(username: str, auth_password: str, ip: str, port: str,
obfs_password: str, sha256: str, sni: str, ip_version: int, uri_params: Dict[str, str], ip_version: int, fragment_tag: str) -> str:
insecure: bool, fragment_tag: str) -> str:
ip_part = f"[{ip}]" if ip_version == 6 and ':' in ip else ip ip_part = f"[{ip}]" if ip_version == 6 and ':' in ip else ip
uri_base = f"hy2://{username}:{auth_password}@{ip_part}:{port}" uri_base = f"hy2://{username}:{auth_password}@{ip_part}:{port}"
query_string = "&".join([f"{k}={v}" for k, v in uri_params.items()])
params = {
"insecure": "1" if insecure else "0",
"sni": sni
}
if obfs_password:
params["obfs"] = "salamander"
params["obfs-password"] = obfs_password
if sha256:
params["pinSHA256"] = sha256
query_string = "&".join([f"{k}={v}" for k, v in params.items()])
return f"{uri_base}?{query_string}#{fragment_tag}" return f"{uri_base}?{query_string}#{fragment_tag}"
def process_users(target_usernames: List[str]) -> List[Dict[str, Any]]: def process_users(target_usernames: List[str]) -> List[Dict[str, Any]]:
config = load_json_file(CONFIG_FILE) config = load_json_file(CONFIG_FILE)
all_users = load_json_file(USERS_FILE) all_users = load_json_file(USERS_FILE)
nodes = load_json_file(NODES_JSON_PATH) or []
if not config or not all_users: if not config or not all_users:
print("Error: Could not load hysteria2 configuration or user files.", file=sys.stderr) print("Error: Could not load Hysteria2 configuration or user files.", file=sys.stderr)
sys.exit(1) sys.exit(1)
nodes = load_json_file(NODES_JSON_PATH) or []
port = config.get("listen", "").split(":")[-1] port = config.get("listen", "").split(":")[-1]
tls_config = config.get("tls", {}) tls_config = config.get("tls", {})
sha256 = tls_config.get("pinSHA256", "")
insecure = tls_config.get("insecure", True)
obfs_password = config.get("obfs", {}).get("salamander", {}).get("password", "")
hy2_env = load_env_file(CONFIG_ENV) 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') ip4 = hy2_env.get('IP4')
ip6 = hy2_env.get('IP6') ip6 = hy2_env.get('IP6')
sni = hy2_env.get('SNI', '') ns_domain, ns_port, ns_subpath = ns_env.get('HYSTERIA_DOMAIN'), ns_env.get('HYSTERIA_PORT'), ns_env.get('SUBPATH')
ns_env = load_env_file(NORMALSUB_ENV)
ns_domain = ns_env.get('HYSTERIA_DOMAIN')
ns_port = ns_env.get('HYSTERIA_PORT')
ns_subpath = ns_env.get('SUBPATH')
results = [] results = []
for username in target_usernames: for username in target_usernames:
user_data = all_users.get(username) user_data = all_users.get(username)
if not user_data or "password" not in user_data: if not user_data or "password" not in user_data:
@ -86,34 +79,20 @@ def process_users(target_usernames: List[str]) -> List[Dict[str, Any]]:
continue continue
auth_password = user_data["password"] auth_password = user_data["password"]
user_output = { user_output = {"username": username, "ipv4": None, "ipv6": None, "nodes": [], "normal_sub": None}
"username": username,
"ipv4": None,
"ipv6": None,
"nodes": [],
"normal_sub": None
}
if ip4 and ip4 != "None": if ip4 and ip4 != "None":
user_output["ipv4"] = generate_uri( user_output["ipv4"] = generate_uri(username, auth_password, ip4, port, base_uri_params, 4, f"{username}-IPv4")
username, auth_password, ip4, port, obfs_password, sha256, sni, 4, insecure, f"{username}-IPv4"
)
if ip6 and ip6 != "None": if ip6 and ip6 != "None":
user_output["ipv6"] = generate_uri( user_output["ipv6"] = generate_uri(username, auth_password, ip6, port, base_uri_params, 6, f"{username}-IPv6")
username, auth_password, ip6, port, obfs_password, sha256, sni, 6, insecure, f"{username}-IPv6"
)
for node in nodes: for node in nodes:
node_name, node_ip = node.get("name"), node.get("ip") if node_name := node.get("name"):
if not (node_name and node_ip): if node_ip := node.get("ip"):
continue ip_v = 6 if ':' in node_ip else 4
ip_v = 6 if ':' in node_ip else 4 tag = f"{username}-{node_name}"
tag = f"{username}-{node_name}" uri = generate_uri(username, auth_password, node_ip, port, base_uri_params, ip_v, tag)
uri = generate_uri( user_output["nodes"].append({"name": node_name, "uri": uri})
username, auth_password, node_ip, port, obfs_password, sha256, sni, ip_v, insecure, tag
)
user_output["nodes"].append({"name": node_name, "uri": uri})
if ns_domain and ns_port and ns_subpath: 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}" user_output["normal_sub"] = f"https://{ns_domain}:{ns_port}/{ns_subpath}/sub/normal/{auth_password}#{username}"
@ -123,16 +102,13 @@ def process_users(target_usernames: List[str]) -> List[Dict[str, Any]]:
return results return results
def main(): def main():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(description="Efficiently generate Hysteria2 URIs for multiple users.")
description="Efficiently generate Hysteria2 URIs for multiple users.",
formatter_class=argparse.RawTextHelpFormatter
)
parser.add_argument('usernames', nargs='*', help="A list of usernames to process.") parser.add_argument('usernames', nargs='*', help="A list of usernames to process.")
parser.add_argument('--all', action='store_true', help="Process all users from users.json.") parser.add_argument('--all', action='store_true', help="Process all users from users.json.")
args = parser.parse_args() args = parser.parse_args()
target_usernames = args.usernames target_usernames = args.usernames
if args.all: if args.all:
all_users = load_json_file(USERS_FILE) all_users = load_json_file(USERS_FILE)
if all_users: if all_users:

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):

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">
@ -66,8 +66,8 @@
</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 %}
@ -153,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>
@ -203,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>
@ -237,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>
@ -249,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">
@ -280,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>
@ -290,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">
@ -331,711 +322,240 @@
{% 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' || expiryDaysText.toLowerCase() === 'on-hold') ? 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 => {
[
const userUriApiUrl = "{{ url_for('show_user_uri_api', username='USERNAME_PLACEHOLDER') }}"; { type: "IPv4", link: response.ipv4 },
const url = userUriApiUrl.replace("USERNAME_PLACEHOLDER", encodeURIComponent(username)); { type: "IPv6", link: response.ipv6 },
{ type: "Normal-SUB", link: response.normal_sub }
$.ajax({ ].forEach(config => {
url: url, if (!config.link) return;
method: "GET", const qrId = `qrcode-${config.type}`;
dataType: 'json', 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>`);
success: function (response) { qrcodesContainer.append(card);
const configs = [ new QRCodeStyling({ width: 200, height: 200, data: config.link, margin: 2 }).append(document.getElementById(qrId));
{ type: "IPv4", link: response.ipv4 }, card.on("click", () => navigator.clipboard.writeText(config.link).then(() => Swal.fire({ icon: "success", title: `${config.type} link copied!`, showConfirmButton: false, timer: 1200 })));
{ type: "IPv6", link: response.ipv6 }, });
{ type: "Normal-SUB", link: response.normal_sub } }).fail(() => Swal.fire("Error!", "Failed to fetch user configuration.", "error"));
];
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",
});
}
});
});
$("#qrcodeModal .modal-content").on("click", function (e) {
e.stopPropagation();
});
$("#qrcodeModal").on("hidden.bs.modal", function () {
$("#qrcodesContainer").empty();
});
$("#qrcodeModal .close").on("click", function () {
$("#qrcodeModal").modal("hide");
}); });
$("#showSelectedLinks").on("click", function () { $("#showSelectedLinks").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.", "warning");
title: "Warning!",
text: "Please select at least one user.",
icon: "warning",
confirmButtonText: "OK",
});
return;
} }
Swal.fire({ Swal.fire({ title: 'Fetching links...', text: 'Please wait.', allowOutsideClick: false, didOpen: () => Swal.showLoading() });
title: 'Fetching links...',
text: 'Please wait.', const bulkUriApiUrl = "{{ url_for('show_multiple_user_uris_api') }}";
allowOutsideClick: false,
didOpen: () => { $.ajax({
Swal.showLoading(); url: bulkUriApiUrl,
method: 'POST',
contentType: 'application/json',
data: JSON.stringify({ usernames: selectedUsers }),
}).done(results => {
Swal.close();
const allLinks = results.map(res => res.normal_sub).filter(Boolean);
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');
} }
}); if (allLinks.length > 0) {
$("#linksTextarea").val(allLinks.join('\n'));
const userUriApiUrlTemplate = "{{ url_for('show_user_uri_api', username='USERNAME_PLACEHOLDER') }}"; $("#showLinksModal").modal("show");
} else {
const fetchPromises = selectedUsers.map(username => { Swal.fire('Error', `Could not fetch links for any of the selected users.`, 'error');
const url = userUriApiUrlTemplate.replace("USERNAME_PLACEHOLDER", encodeURIComponent(username)); }
return fetch(url).then(response => { }).fail(() => Swal.fire('Error!', 'An error occurred while fetching the links.', 'error'));
if (!response.ok) {
return { username: username, error: `HTTP error! status: ${response.status}` };
}
return response.json();
}).catch(error => {
return { username: username, error: error.message };
});
});
Promise.all(fetchPromises)
.then(results => {
Swal.close();
const successfulLinks = results
.filter(res => res && res.normal_sub)
.map(res => res.normal_sub);
const failedUsers = results
.filter(res => !res || !res.normal_sub)
.map(res => res.username);
if (failedUsers.length > 0) {
console.error("Failed to fetch links for:", failedUsers);
Swal.fire({
icon: 'warning',
title: 'Partial Success',
text: `Could not fetch links for the following users: ${failedUsers.join(', ')}`,
});
}
if (successfulLinks.length > 0) {
$("#linksTextarea").val(successfulLinks.join('\n'));
$("#showLinksModal").modal("show");
} else if (failedUsers.length === selectedUsers.length) {
Swal.fire({
icon: 'error',
title: 'Operation Failed',
text: 'Could not fetch links for any of the selected users.',
});
}
});
}); });
$("#copyLinksButton").on("click", function () { $("#copyLinksButton").on("click", () => {
const links = $("#linksTextarea").val(); navigator.clipboard.writeText($("#linksTextarea").val())
if (links) { .then(() => Swal.fire({ icon: "success", title: "All links copied!", showConfirmButton: false, timer: 1200 }));
navigator.clipboard.writeText(links)
.then(() => {
Swal.fire({
icon: "success",
title: "All links copied!",
showConfirmButton: false,
timer: 1500,
});
})
.catch(err => {
console.error("Failed to copy links: ", err);
Swal.fire({
icon: "error",
title: "Failed to copy links",
text: "Please copy manually.",
});
});
}
}); });
function filterUsers() { 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 %}