From 04323e1f5c5a1e468c968349481226b2211deb8f Mon Sep 17 00:00:00 2001
From: Zed <124834187+zZedix@users.noreply.github.com>
Date: Sun, 5 Oct 2025 01:52:45 +0330
Subject: [PATCH 1/6] Handle socket.gaierror for invalid hostnames
---
main.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/main.py b/main.py
index e3043aa60..3cd4ff8ed 100644
--- a/main.py
+++ b/main.py
@@ -45,7 +45,7 @@ def check_and_modify_ip(ip_address: str) -> str:
Raises:
ValueError: If the provided IP address is invalid, return localhost.
"""
- try:
+ try:
# Attempt to resolve hostname to IP address
resolved_ip = socket.gethostbyname(ip_address)
@@ -59,7 +59,7 @@ def check_and_modify_ip(ip_address: str) -> str:
else:
return "localhost"
- except ValueError:
+ except (ValueError, socket.gaierror):
return "localhost"
From 55e27f4f212c33ac000f267f5c593bd94aeda65d Mon Sep 17 00:00:00 2001
From: Zed <124834187+zZedix@users.noreply.github.com>
Date: Sun, 5 Oct 2025 11:26:10 +0330
Subject: [PATCH 2/6] Update main.py
---
main.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/main.py b/main.py
index 3cd4ff8ed..03dd0e69a 100644
--- a/main.py
+++ b/main.py
@@ -45,7 +45,7 @@ def check_and_modify_ip(ip_address: str) -> str:
Raises:
ValueError: If the provided IP address is invalid, return localhost.
"""
- try:
+ try:
# Attempt to resolve hostname to IP address
resolved_ip = socket.gethostbyname(ip_address)
From 37042eb6c18a5717e1e30d24bf0936e257579979 Mon Sep 17 00:00:00 2001
From: Zed <124834187+zZedix@users.noreply.github.com>
Date: Sun, 5 Oct 2025 12:22:07 +0330
Subject: [PATCH 3/6] safely detect global IPv4 and close socket properly
---
app/utils/system.py | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/app/utils/system.py b/app/utils/system.py
index 5fbc20717..a615fbc87 100644
--- a/app/utils/system.py
+++ b/app/utils/system.py
@@ -70,6 +70,7 @@ def get_public_ip():
except httpx.RequestError:
pass
+ sock = None
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.connect(("8.8.8.8", 80))
@@ -79,11 +80,10 @@ def get_public_ip():
except (socket.error, IndexError):
pass
finally:
- sock.close()
+ if sock:
+ sock.close()
return "127.0.0.1"
-
-
def get_public_ipv6():
try:
resp = httpx.get("http://api6.ipify.org/", timeout=5).text.strip()
From e27a03a04dfc4e7aaf312ce8b16ca3c4fb48391e Mon Sep 17 00:00:00 2001
From: Zed <124834187+zZedix@users.noreply.github.com>
Date: Sun, 5 Oct 2025 12:24:50 +0330
Subject: [PATCH 4/6] Harden get_subscription_payload against malformed tokens
---
app/utils/jwt.py | 72 ++++++++++++++++++++++++++++++------------------
1 file changed, 45 insertions(+), 27 deletions(-)
diff --git a/app/utils/jwt.py b/app/utils/jwt.py
index 54adf2c99..504690aae 100644
--- a/app/utils/jwt.py
+++ b/app/utils/jwt.py
@@ -60,34 +60,52 @@ async def get_subscription_payload(token: str) -> dict | None:
return
if token.startswith("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."):
- payload = jwt.decode(token, await get_secret_key(), algorithms=["HS256"])
+ try:
+ payload = jwt.decode(token, await get_secret_key(), algorithms=["HS256"])
+ except jwt.exceptions.PyJWTError:
+ return
+
if payload.get("access") == "subscription":
return {
- "username": payload["sub"],
- "created_at": datetime.fromtimestamp(payload["iat"], tz=timezone.utc),
+ "username": payload.get("sub"),
+ "created_at": datetime.fromtimestamp(payload.get("iat", 0), tz=timezone.utc),
}
- else:
- return
- else:
- u_token = token[:-10]
- u_signature = token[-10:]
- try:
- u_token_dec = b64decode(
- (u_token.encode("utf-8") + b"=" * (-len(u_token.encode("utf-8")) % 4)),
- altchars=b"-_",
- validate=True,
- )
- u_token_dec_str = u_token_dec.decode("utf-8")
- except Exception:
- return
- u_token_resign = b64encode(
- sha256((u_token + await get_secret_key()).encode("utf-8")).digest(), altchars=b"-_"
- ).decode("utf-8")[:10]
- if u_signature == u_token_resign:
- u_username = u_token_dec_str.split(",")[0]
- u_created_at = int(u_token_dec_str.split(",")[1])
- return {"username": u_username, "created_at": datetime.fromtimestamp(u_created_at, tz=timezone.utc)}
- else:
- return
- except jwt.exceptions.PyJWTError:
+ return
+
+ u_token = token[:-10]
+ u_signature = token[-10:]
+
+ try:
+ u_token_dec = b64decode(
+ (u_token.encode("utf-8") + b"=" * (-len(u_token.encode("utf-8")) % 4)),
+ altchars=b"-_",
+ validate=True,
+ )
+ u_token_dec_str = u_token_dec.decode("utf-8")
+ except Exception:
+ return
+
+ u_token_resign = b64encode(
+ sha256((u_token + await get_secret_key()).encode("utf-8")).digest(),
+ altchars=b"-_",
+ ).decode("utf-8")[:10]
+
+ if u_signature != u_token_resign:
+ return
+
+ parts = u_token_dec_str.split(",")
+ if len(parts) != 2:
+ return
+
+ u_username, u_created_at_str = parts
+ if not u_created_at_str.isdigit():
+ return
+
+ u_created_at = int(u_created_at_str)
+ return {
+ "username": u_username,
+ "created_at": datetime.fromtimestamp(u_created_at, tz=timezone.utc),
+ }
+
+ except Exception:
return
From 859286deaa3c256ceb9973c5b15f4a14fc24e754 Mon Sep 17 00:00:00 2001
From: Zed <124834187+zZedix@users.noreply.github.com>
Date: Sun, 5 Oct 2025 13:08:14 +0330
Subject: [PATCH 5/6] =?UTF-8?q?=E2=9C=A8=20=D8=A8=D9=87=D8=A8=D9=88=D8=AF?=
=?UTF-8?q?=20README=20=D8=A8=D8=A7=20=D9=84=D9=88=DA=AF=D9=88=DB=8C=20?=
=?UTF-8?q?=D8=AC=D8=AF=DB=8C=D8=AF=20=D9=88=20=D8=B7=D8=B1=D8=A7=D8=AD?=
=?UTF-8?q?=DB=8C=20=D8=B2=DB=8C=D8=A8=D8=A7?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- اضافه کردن لوگوی PasarGuard
- بهبود طراحی بصری با ایموجیها و جداول
- ترجمه کامل به فارسی و انگلیسی
- ساختار بهتر و خوانایی بیشتر
- اضافه کردن بخشهای جدید و بهبود یافته
---
.dockerignore | 24 +
.env.example | 66 +
.gitattributes | 10 +
.github/ISSUE_TEMPLATE/bug_report.md | 33 +
.github/ISSUE_TEMPLATE/feature_request.md | 20 +
.github/workflows/api-codeql.yml | 48 +
.github/workflows/build.yml | 140 +
.github/workflows/frontend-codeql.yml | 48 +
.github/workflows/ruff-check.yml | 33 +
.../workflows/test-database-migrations.yml | 103 +
.gitignore | 170 +
CONTRIBUTING.md | 150 +
Dockerfile | 39 +
LICENSE | 661 +
Makefile | 142 +
PasarGuard.code-workspace | 113 +
README-fa.md | 449 +
README-ru.md | 470 +
README-zh-cn.md | 464 +
README.md | 677 +
alembic.ini | 99 +
app/__init__.py | 116 +
app/core/__init__.py | 0
app/core/abstract_core.py | 21 +
app/core/hosts.py | 76 +
app/core/manager.py | 97 +
app/core/xray.py | 413 +
app/db/__init__.py | 16 +
app/db/base.py | 62 +
app/db/compiles_types.py | 118 +
app/db/crud/__init__.py | 18 +
app/db/crud/admin.py | 217 +
app/db/crud/bulk.py | 416 +
app/db/crud/core.py | 102 +
app/db/crud/general.py | 103 +
app/db/crud/group.py | 149 +
app/db/crud/host.py | 166 +
app/db/crud/node.py | 295 +
app/db/crud/settings.py | 29 +
app/db/crud/user.py | 1064 ++
app/db/crud/user_template.py | 152 +
app/db/migrations/README | 1 +
app/db/migrations/env.py | 89 +
app/db/migrations/script.py.mako | 24 +
..._sub_updated_at_and_sub_last_user_agent.py | 30 +
...025d427831dd_sub_last_user_agent_length.py | 34 +
...9a5_add_randomizednoalpn_and_unsafe_to_.py | 83 +
.../07f9bbb3db4e_user_last_status_change.py | 49 +
.../084b8004104c_cast_alpn_as_enumarray.py | 91 +
.../08b381fc1bc7_update_shadowsocks_method.py | 75 +
...2b_add_sub_template_sub_domain_profile_.py | 34 +
...6e_make_data_limit_and_expire_duration_.py | 56 +
app/db/migrations/versions/0f720f5c54dd_.py | 24 +
.../16e19723febc_migrate_to_groups.py | 302 +
app/db/migrations/versions/1ad79b97fdcf_.py | 24 +
...dd_extra_sttings_to_usertemplates_table.py | 28 +
.../versions/1cf7d159fdbb_add_online_at.py | 28 +
.../1dc5f89b706a_drop_proxies_table.py | 35 +
.../1f6e978be88e_migrate_to_sqlalchemy_v2.py | 244 +
...c_add_threshold_to_notificationreminder.py | 28 +
app/db/migrations/versions/2313cdc30da3_.py | 24 +
...231de97dc3_add_use_sni_as_host_to_hosts.py | 27 +
.../versions/2ea33513efc0_noise_for_sqlite.py | 32 +
.../305943d779c4_add_h3_to_alpn_enum.py | 119 +
...92220c0d0_add_support_random_user_agent.py | 28 +
.../versions/343ad7904b19_remove_tls_table.py | 38 +
.../35760ebfabd2_add_status_to_hosts.py | 45 +
...5f7f8fa9cf2_add_sub_revoked_at_to_users.py | 28 +
.../migrations/versions/37692c1c9715_nodes.py | 47 +
...proxy_settings_column_to_users_table.py.py | 82 +
.../3c466ce2ab63_round_float_values.py | 61 +
.../3cf36a5fde73_init_system_table.py | 39 +
.../470465472326_add_path_to_hosts.py | 26 +
.../4e66033a2b9b_separate_nodes_config.py | 111 +
.../versions/4eb0a0eb835f_reformat_hosts.py | 69 +
...8_drop_proxy_outbound_and_sockopt_from_.py | 27 +
.../versions/508061427170_fix_timezones.py | 178 +
.../51e941ed9018_deactive_user_status.py | 119 +
.../versions/53586547c10e_node_api_key.py | 27 +
...fc_last_status_change_for_expired_users.py | 75 +
.../5575fe410515_add_telegram_id_to_admin.py | 30 +
.../versions/57fda18cd9e6_add_xray_version.py | 28 +
.../versions/58a5b64175a8_node_stats_table.py | 40 +
...6e7b165_add_password_reset_at_to_admins.py | 28 +
.../migrations/versions/5b84d88804a1_fix.py | 48 +
.../versions/671621870b02_init_admin.py | 44 +
...ca039166_next_plan_to_template_relation.py | 32 +
...1_add_data_limit_reset_strategy_and_on_.py | 31 +
.../714f227201a7_fix_user_template.py | 35 +
.../77c86a261126_nodes_coefficient.py | 28 +
.../versions/7a0dbb8a2f65_init_tls_table.py | 39 +
app/db/migrations/versions/7a93bcd44713_.py | 24 +
.../7cbe9d91ac11_proxyhost_security_added.py | 40 +
.../versions/81554fa3c345_admin_discord_id.py | 28 +
.../versions/852d951c9c08_drop_extra_index.py | 46 +
.../89bcb1419c66_fix_user_status_enum.py | 104 +
.../versions/8e849e06f131_proxy_table.py | 81 +
...fe407cf56c9_add_general_settings_to_app.py | 45 +
...40d6eec_add_is_disabled_to_admins_table.py | 28 +
.../versions/947ebbd8debe_add_on_hold.py | 122 +
.../versions/94a5cc12c0d6_init_user_table.py | 42 +
..._change_fragments_and_noise_from_regex_.py | 178 +
.../97dd9311ab93_add_user_templates.py | 46 +
...e_drop_mux_enable_column_in_hosts_table.py | 28 +
.../versions/9af04c077ede_init_settings.py | 248 +
...0a2_add_created_at_field_to_users_table.py | 33 +
...cd_user_template_status_and_reset_usage.py | 45 +
.../versions/9d5a518ae432_init_jwt_table.py | 37 +
...a4831e991a_add_ech_config_list_to_hosts.py | 28 +
...f0_add_allow_in_secure_and_disable_host.py | 34 +
.../a0d3d400ea75_admin_is_sudo_field.py | 29 +
...a6e3fff39291_add_notification_reminders.py | 38 +
.../a9cfd5611a82_add_noise_settings.py | 28 +
.../ac26f36783a2_increase_username_length.py | 40 +
...nd_mux_enable_fragment_setting_to_hosts.py | 34 +
.../versions/b15eba6e5867_add_host_sni.py | 26 +
...b25e7e6be241_added_users_usage_to_admin.py | 28 +
.../migrations/versions/b3378dc6de01_hosts.py | 36 +
.../be0c5f840473_fix_multiple_heads_error.py | 24 +
.../beb47f520963_gather_node_logs_option.py | 28 +
.../c106bb40c861_alpn_fingerprint_hosts.py | 35 +
.../c3cd674b9bcd_added_next_plan_for_user.py | 37 +
.../versions/c41c441de44c_gozargah_node.py | 50 +
.../versions/c47250b790eb_add_user_note.py | 28 +
...5c734bd3da2_add_priority_to_proxy_hosts.py | 150 +
...bab_drop_excloud_inbounds_and_template_.py | 40 +
.../ccbf9d322ae3_user_auto_delete_in_days.py | 28 +
...6aaf82_add_is_disabled_to_usertemplates.py | 28 +
..._add_userusageresetlogs_model_and_data_.py | 42 +
...ename_admin_users_usage_to_used_traffic.py | 24 +
.../d0a3960f5dad_usagelog_table_for_admin.py | 35 +
...d607d3ca5246_change_enumarray_to_string.py | 137 +
..._move_data_from_proxies_table_to_users_.py | 73 +
.../dd725e4d3628_fix_mysql_collations.py | 130 +
...a563_rename_deactive_status_to_disabled.py | 115 +
...5f15c3f_merge_fad8b1997c3a_a0d3d400ea75.py | 24 +
.../e422f859847f_refactor_sub_updated_at.py | 60 +
.../versions/e4a86bc8ec7b_node_user_usages.py | 39 +
.../versions/e56f1c781e46_fix_on_hold.py | 29 +
...b4_increase_length_of_the_host_and_sni_.py | 41 +
...993f1a_inbounds_table_excluded_inbounds.py | 41 +
...3e_add_headers_and_transports_settings_.py | 32 +
app/db/migrations/versions/ece13c4c6f65_.py | 24 +
.../f2b9caa23e16_change_expire_to_datetime.py | 76 +
.../versions/f44ec4769d5d_fix_alpn_enum.py | 48 +
.../fad8b1997c3a_case_insensitive_username.py | 85 +
...fbfc49f01004_drop_fire_on_either_column.py | 28 +
.../versions/fc01b1520e72_add_node_usages.py | 73 +
...796f840a4_remove_certficiate_from_nodes.py | 28 +
app/db/models.py | 616 +
app/jobs/__init__.py | 13 +
app/jobs/cleanup_subscription_updates.py | 81 +
app/jobs/dependencies.py | 3 +
app/jobs/inboud.py | 26 +
app/jobs/node_checker.py | 101 +
app/jobs/node_stats.py | 52 +
app/jobs/record_usages.py | 305 +
app/jobs/remove_expired_users.py | 26 +
app/jobs/reset_user_data_usage.py | 47 +
app/jobs/review_users.py | 203 +
app/jobs/send_notifications.py | 115 +
app/models/admin.py | 99 +
app/models/core.py | 54 +
app/models/group.py | 46 +
app/models/host.py | 240 +
app/models/node.py | 164 +
app/models/proxy.py | 49 +
app/models/settings.py | 222 +
app/models/stats.py | 77 +
app/models/system.py | 18 +
app/models/user.py | 179 +
app/models/user_template.py | 68 +
app/models/validators.py | 226 +
app/node/__init__.py | 113 +
app/node/user.py | 60 +
app/notification/__init__.py | 192 +
app/notification/client.py | 113 +
app/notification/discord/__init__.py | 48 +
app/notification/discord/admin.py | 97 +
app/notification/discord/colors.py | 7 +
app/notification/discord/core.py | 63 +
app/notification/discord/group.py | 62 +
app/notification/discord/host.py | 73 +
app/notification/discord/messages.py | 252 +
app/notification/discord/node.py | 86 +
app/notification/discord/user.py | 164 +
app/notification/discord/user_template.py | 67 +
app/notification/discord/utils.py | 32 +
app/notification/telegram/__init__.py | 48 +
app/notification/telegram/admin.py | 73 +
app/notification/telegram/core.py | 46 +
app/notification/telegram/group.py | 41 +
app/notification/telegram/host.py | 49 +
app/notification/telegram/messages.py | 301 +
app/notification/telegram/node.py | 59 +
app/notification/telegram/user.py | 140 +
app/notification/telegram/user_template.py | 52 +
app/notification/telegram/utils.py | 32 +
app/notification/webhook/__init__.py | 129 +
app/operation/__init__.py | 158 +
app/operation/admin.py | 127 +
app/operation/core.py | 75 +
app/operation/group.py | 87 +
app/operation/host.py | 109 +
app/operation/node.py | 350 +
app/operation/settings.py | 44 +
app/operation/subscription.py | 157 +
app/operation/system.py | 65 +
app/operation/user.py | 509 +
app/operation/user_template.py | 68 +
app/routers/__init__.py | 24 +
app/routers/admin.py | 154 +
app/routers/authentication.py | 100 +
app/routers/core.py | 85 +
app/routers/group.py | 205 +
app/routers/home.py | 52 +
app/routers/host.py | 85 +
app/routers/node.py | 205 +
app/routers/settings.py | 27 +
app/routers/subscription.py | 63 +
app/routers/system.py | 61 +
app/routers/user.py | 358 +
app/routers/user_template.py | 75 +
app/settings/__init__.py | 68 +
app/subscription/__init__.py | 16 +
app/subscription/base.py | 47 +
app/subscription/clash.py | 392 +
app/subscription/funcs.py | 76 +
app/subscription/links.py | 460 +
app/subscription/outline.py | 48 +
app/subscription/share.py | 292 +
app/subscription/singbox.py | 345 +
app/subscription/xray.py | 680 +
app/telegram/__init__.py | 125 +
app/telegram/handlers/__init__.py | 11 +
app/telegram/handlers/admin/__init__.py | 17 +
app/telegram/handlers/admin/bulk_actions.py | 163 +
app/telegram/handlers/admin/confirm_action.py | 43 +
app/telegram/handlers/admin/main_menu.py | 56 +
app/telegram/handlers/admin/user.py | 622 +
app/telegram/handlers/base.py | 60 +
app/telegram/handlers/client/__init__.py | 7 +
app/telegram/handlers/client/show_info.py | 42 +
app/telegram/handlers/error_handler.py | 40 +
app/telegram/keyboards/__init__.py | 0
app/telegram/keyboards/admin.py | 49 +
app/telegram/keyboards/base.py | 19 +
app/telegram/keyboards/bulk_actions.py | 33 +
app/telegram/keyboards/confim_action.py | 16 +
app/telegram/keyboards/group.py | 41 +
app/telegram/keyboards/user.py | 157 +
app/telegram/middlewares/__init__.py | 9 +
app/telegram/middlewares/acl.py | 36 +
app/telegram/utils/filters.py | 13 +
app/telegram/utils/forms.py | 29 +
app/telegram/utils/shared.py | 39 +
app/telegram/utils/texts.py | 263 +
app/templates/__init__.py | 21 +
app/templates/clash/README.md | 42 +
app/templates/clash/default.yml | 13 +
app/templates/filters.py | 41 +
app/templates/home/index.html | 1942 +++
app/templates/singbox/README.md | 42 +
app/templates/singbox/default.json | 85 +
app/templates/subscription/index.html | 203 +
app/templates/user_agent/default.json | 104 +
app/templates/user_agent/grpc.json | 20 +
app/templates/xray/README.md | 44 +
app/templates/xray/default.json | 58 +
app/utils/crypto.py | 76 +
app/utils/helpers.py | 76 +
app/utils/jwt.py | 93 +
app/utils/logger.py | 94 +
app/utils/responses.py | 43 +
app/utils/store.py | 28 +
app/utils/system.py | 114 +
build_dashboard.sh | 5 +
cli/README.md | 60 +
cli/__init__.py | 49 +
cli/admin.py | 226 +
cli/main.py | 63 +
cli/system.py | 58 +
cli_wrapper.sh | 2 +
config.py | 99 +
dashboard/.env.example | 1 +
dashboard/.gitignore | 25 +
dashboard/.prettierrc.json | 21 +
dashboard/README.md | 42 +
dashboard/__init__.py | 62 +
dashboard/build.zip | Bin 0 -> 1785169 bytes
dashboard/bun.lock | 2319 +++
dashboard/components.json | 21 +
dashboard/custom.d.ts | 6 +
dashboard/eslint.config.js | 25 +
dashboard/index.html | 22 +
dashboard/orval.config.ts | 24 +
dashboard/package-lock.json | 12735 ++++++++++++++++
dashboard/package.json | 114 +
.../favicon/android-chrome-192x192.png | Bin 0 -> 16698 bytes
.../favicon/android-chrome-512x512.png | Bin 0 -> 58809 bytes
.../statics/favicon/apple-touch-icon.png | Bin 0 -> 58809 bytes
dashboard/public/statics/favicon/favicon.ico | Bin 0 -> 575 bytes
.../public/statics/favicon/logo-dark.png | Bin 0 -> 264842 bytes
dashboard/public/statics/favicon/logo-pwa.png | Bin 0 -> 283405 bytes
dashboard/public/statics/favicon/logo.png | Bin 0 -> 263181 bytes
.../public/statics/favicon/mstile-150x150.png | Bin 0 -> 11714 bytes
.../public/statics/favicon/mstile-310x310.png | Bin 0 -> 31771 bytes
.../public/statics/favicon/mstile-70x70.png | Bin 0 -> 4045 bytes
dashboard/public/statics/locales/en.json | 1502 ++
dashboard/public/statics/locales/fa.json | 1482 ++
dashboard/public/statics/locales/ru.json | 1491 ++
dashboard/public/statics/locales/zh.json | 1534 ++
dashboard/react-router.config.ts | 10 +
dashboard/src/App.tsx | 20 +
dashboard/src/assets/react.svg | 1 +
dashboard/src/components/ActionButtons.tsx | 560 +
dashboard/src/components/AdminStatistics.tsx | 73 +
dashboard/src/components/AdminStatusBadge.tsx | 59 +
dashboard/src/components/CopyButton.tsx | 45 +
dashboard/src/components/Footer.tsx | 23 +
dashboard/src/components/Language.tsx | 41 +
dashboard/src/components/LineCountFilter.tsx | 52 +
dashboard/src/components/LoadingSpinner.tsx | 27 +
dashboard/src/components/OnlineBadge.tsx | 82 +
dashboard/src/components/OnlineStatus.tsx | 61 +
dashboard/src/components/PageTransition.tsx | 237 +
dashboard/src/components/RouteGuard.tsx | 53 +
dashboard/src/components/SinceLogsFilter.tsx | 72 +
dashboard/src/components/Statistics.tsx | 74 +
dashboard/src/components/StatusBadge.tsx | 75 +
dashboard/src/components/StatusLogsFilter.tsx | 168 +
dashboard/src/components/TerminalLine.tsx | 127 +
dashboard/src/components/TopLoadingBar.tsx | 234 +
.../src/components/UsageSliderCompact.tsx | 34 +
dashboard/src/components/UsersStatistics.tsx | 134 +
.../src/components/admins/AdminsTable.tsx | 281 +
dashboard/src/components/admins/columns.tsx | 174 +
.../src/components/admins/data-table.tsx | 211 +
dashboard/src/components/admins/filters.tsx | 207 +
.../src/components/bulk/SelectorPanel.tsx | 169 +
.../charts/AllNodesStackedBarChart.tsx | 563 +
.../components/charts/AreaCostumeChart.tsx | 526 +
.../src/components/charts/CostumeBarChart.tsx | 345 +
.../src/components/charts/EmptyState.tsx | 92 +
.../src/components/charts/TimeSelector.tsx | 30 +
.../src/components/common/AdminsSelector.tsx | 137 +
.../src/components/common/GroupsSelector.tsx | 142 +
.../components/common/TimeRangeSelector.tsx | 99 +
.../dashboard/DashboardAdminStatistics.tsx | 26 +
.../dashboard/DashboardStatistics.tsx | 223 +
.../dashboard/admin-statistics-card.tsx | 67 +
.../components/dashboard/data-usage-chart.tsx | 337 +
.../dashboard/users-statistics-card.tsx | 56 +
.../src/components/dialogs/AdminModal.tsx | 397 +
.../components/dialogs/AdvanceSearchModal.tsx | 261 +
.../components/dialogs/CoreConfigModal.tsx | 826 +
.../src/components/dialogs/GroupModal.tsx | 156 +
.../src/components/dialogs/HostModal.tsx | 2801 ++++
.../src/components/dialogs/NodeModal.tsx | 719 +
.../src/components/dialogs/QRCodeModal.tsx | 44 +
.../src/components/dialogs/SetOwnerModal.tsx | 130 +
.../src/components/dialogs/ShortcutsModal.tsx | 202 +
.../src/components/dialogs/UsageModal.tsx | 376 +
.../src/components/dialogs/UserModal.tsx | 2064 +++
.../dialogs/UserOnlineStatsModal.tsx | 572 +
.../dialogs/UserSubscriptionClientsModal.tsx | 401 +
.../components/dialogs/UserTemplateModal.tsx | 438 +
dashboard/src/components/github-star.tsx | 19 +
dashboard/src/components/groups/Group.tsx | 132 +
dashboard/src/components/groups/Groups.tsx | 102 +
dashboard/src/components/hosts/Hosts.tsx | 822 +
.../src/components/hosts/SortableHost.tsx | 237 +
.../src/components/layout/sidebar-toggle.tsx | 22 +
dashboard/src/components/layout/sidebar.tsx | 356 +
dashboard/src/components/nav-main.tsx | 80 +
dashboard/src/components/nav-secondary.tsx | 39 +
dashboard/src/components/nav-user.tsx | 115 +
dashboard/src/components/nodes/Node.tsx | 238 +
dashboard/src/components/nodes/Nodes.tsx | 159 +
dashboard/src/components/page-header.tsx | 51 +
dashboard/src/components/settings/Core.tsx | 76 +
dashboard/src/components/settings/Cores.tsx | 132 +
dashboard/src/components/settings/Logs.tsx | 130 +
dashboard/src/components/spinner.tsx | 43 +
.../src/components/statistics/Statistics.tsx | 150 +
.../statistics/SystemStatisticsSection.tsx | 217 +
.../statistics/UserStatisticsSection.tsx | 121 +
.../src/components/templates/UserTemplate.tsx | 192 +
dashboard/src/components/theme-provider.tsx | 688 +
dashboard/src/components/theme-toggle.tsx | 44 +
dashboard/src/components/ui/accordion.tsx | 44 +
dashboard/src/components/ui/alert-dialog.tsx | 108 +
dashboard/src/components/ui/alert.tsx | 32 +
dashboard/src/components/ui/avatar.tsx | 21 +
dashboard/src/components/ui/badge.tsx | 32 +
dashboard/src/components/ui/breadcrumb.tsx | 57 +
dashboard/src/components/ui/button.tsx | 50 +
dashboard/src/components/ui/calendar.tsx | 210 +
dashboard/src/components/ui/card.tsx | 33 +
dashboard/src/components/ui/carousel.tsx | 183 +
dashboard/src/components/ui/chart.tsx | 250 +
dashboard/src/components/ui/checkbox.tsx | 23 +
dashboard/src/components/ui/collapsible.tsx | 9 +
dashboard/src/components/ui/command.tsx | 87 +
dashboard/src/components/ui/dialog.tsx | 71 +
dashboard/src/components/ui/drawer.tsx | 47 +
dashboard/src/components/ui/dropdown-menu.tsx | 173 +
dashboard/src/components/ui/form.tsx | 104 +
dashboard/src/components/ui/hover-card.tsx | 26 +
dashboard/src/components/ui/input.tsx | 31 +
dashboard/src/components/ui/label.tsx | 14 +
dashboard/src/components/ui/loader-button.tsx | 18 +
dashboard/src/components/ui/pagination.tsx | 71 +
.../src/components/ui/password-input.tsx | 64 +
.../src/components/ui/persian-calendar.tsx | 212 +
dashboard/src/components/ui/popover.tsx | 31 +
dashboard/src/components/ui/progress.tsx | 18 +
dashboard/src/components/ui/radio-group.tsx | 30 +
dashboard/src/components/ui/scroll-area.tsx | 51 +
dashboard/src/components/ui/select.tsx | 105 +
dashboard/src/components/ui/separator.tsx | 21 +
dashboard/src/components/ui/sheet.tsx | 74 +
dashboard/src/components/ui/sidebar.tsx | 578 +
dashboard/src/components/ui/skeleton.tsx | 7 +
dashboard/src/components/ui/sonner.tsx | 32 +
dashboard/src/components/ui/switch.tsx | 23 +
dashboard/src/components/ui/table.tsx | 47 +
dashboard/src/components/ui/tabs.tsx | 34 +
dashboard/src/components/ui/textarea.tsx | 19 +
dashboard/src/components/ui/toggle-group.tsx | 47 +
dashboard/src/components/ui/toggle.tsx | 36 +
dashboard/src/components/ui/tooltip.tsx | 31 +
dashboard/src/components/users/columns.tsx | 167 +
dashboard/src/components/users/data-table.tsx | 184 +
dashboard/src/components/users/filters.tsx | 294 +
.../src/components/users/users-table.tsx | 309 +
dashboard/src/constants/Project.ts | 5 +
dashboard/src/constants/Proxies.ts | 1 +
dashboard/src/constants/UserSettings.ts | 60 +
dashboard/src/entry.client.tsx | 13 +
dashboard/src/hooks/use-admin.ts | 45 +
dashboard/src/hooks/use-clipboard.ts | 57 +
dashboard/src/hooks/use-dir-detection.tsx | 8 +
dashboard/src/hooks/use-dynamic-errors.ts | 93 +
dashboard/src/hooks/use-mobile.tsx | 19 +
dashboard/src/hooks/use-store.ts | 14 +
dashboard/src/hooks/use-toast.ts | 189 +
dashboard/src/hooks/use-top-loading-bar.ts | 356 +
dashboard/src/index.css | 234 +
dashboard/src/lib/dayjs.ts | 15 +
dashboard/src/lib/menu-list.ts | 84 +
dashboard/src/lib/utils.ts | 6 +
dashboard/src/locales/i18n.ts | 40 +
dashboard/src/pages/_dashboard._index.tsx | 526 +
dashboard/src/pages/_dashboard.admins.tsx | 197 +
dashboard/src/pages/_dashboard.bulk.data.tsx | 335 +
.../src/pages/_dashboard.bulk.expire.tsx | 353 +
.../src/pages/_dashboard.bulk.groups.tsx | 337 +
dashboard/src/pages/_dashboard.bulk.proxy.tsx | 290 +
dashboard/src/pages/_dashboard.bulk.tsx | 95 +
dashboard/src/pages/_dashboard.groups.tsx | 24 +
dashboard/src/pages/_dashboard.hosts.tsx | 261 +
.../src/pages/_dashboard.nodes._index.tsx | 5 +
.../src/pages/_dashboard.nodes.cores.tsx | 223 +
dashboard/src/pages/_dashboard.nodes.logs.tsx | 407 +
dashboard/src/pages/_dashboard.nodes.tsx | 102 +
.../src/pages/_dashboard.settings.cleanup.tsx | 638 +
.../src/pages/_dashboard.settings.discord.tsx | 260 +
.../src/pages/_dashboard.settings.general.tsx | 217 +
.../_dashboard.settings.notifications.tsx | 703 +
.../_dashboard.settings.subscriptions.tsx | 819 +
.../pages/_dashboard.settings.telegram.tsx | 503 +
.../src/pages/_dashboard.settings.theme.tsx | 364 +
dashboard/src/pages/_dashboard.settings.tsx | 242 +
.../src/pages/_dashboard.settings.webhook.tsx | 565 +
dashboard/src/pages/_dashboard.statistics.tsx | 100 +
dashboard/src/pages/_dashboard.templates.tsx | 138 +
dashboard/src/pages/_dashboard.tsx | 36 +
dashboard/src/pages/_dashboard.users.tsx | 182 +
dashboard/src/pages/login.tsx | 219 +
dashboard/src/router.tsx | 184 +
dashboard/src/service/api/index.ts | 6125 ++++++++
dashboard/src/service/http.ts | 46 +
dashboard/src/sw-register.ts | 17 +
dashboard/src/utils/authStorage.ts | 11 +
dashboard/src/utils/dateFormatter.tsx | 114 +
dashboard/src/utils/formatByte.ts | 23 +
dashboard/src/utils/isEmptyObject.ts | 4 +
dashboard/src/utils/logsUtils.ts | 150 +
dashboard/src/utils/query-client.ts | 3 +
dashboard/src/utils/react-query.ts | 3 +
dashboard/src/utils/userAgentParser.ts | 117 +
dashboard/src/utils/userPreferenceStorage.ts | 10 +
dashboard/src/vite-env.d.ts | 2 +
dashboard/tailwind.config.js | 289 +
dashboard/tsconfig.app.json | 28 +
dashboard/tsconfig.json | 17 +
dashboard/tsconfig.node.json | 22 +
dashboard/vite.config.mts | 99 +
docker-compose.yml | 8 +
install_service.sh | 27 +
main.py | 152 +
pasarguard-cli.py | 24 +
pasarguard-tui.py | 53 +
pyproject.toml | 77 +
pytest.ini | 10 +
start.sh | 3 +
tests/api/__init__.py | 94 +
tests/api/conftest.py | 125 +
tests/api/test_a_admin.py | 111 +
tests/api/test_b_core.py | 418 +
tests/api/test_c_group.py | 73 +
tests/api/test_d_host.py | 79 +
tests/api/test_e_user.py | 217 +
tests/api/test_f_user_template.py | 89 +
tests/api/test_g_bulk.py | 134 +
tests/api/test_h_system.py | 20 +
tests/conftest.py | 28 +
tui/README.md | 42 +
tui/__init__.py | 29 +
tui/admin.py | 481 +
tui/help.py | 36 +
tui/style.tcss | 91 +
tui_wrapper.sh | 2 +
uv.lock | 2183 +++
525 files changed, 96551 insertions(+)
create mode 100644 .dockerignore
create mode 100644 .env.example
create mode 100644 .gitattributes
create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md
create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md
create mode 100644 .github/workflows/api-codeql.yml
create mode 100644 .github/workflows/build.yml
create mode 100644 .github/workflows/frontend-codeql.yml
create mode 100644 .github/workflows/ruff-check.yml
create mode 100644 .github/workflows/test-database-migrations.yml
create mode 100644 .gitignore
create mode 100644 CONTRIBUTING.md
create mode 100644 Dockerfile
create mode 100644 LICENSE
create mode 100644 Makefile
create mode 100644 PasarGuard.code-workspace
create mode 100644 README-fa.md
create mode 100644 README-ru.md
create mode 100644 README-zh-cn.md
create mode 100644 README.md
create mode 100644 alembic.ini
create mode 100644 app/__init__.py
create mode 100644 app/core/__init__.py
create mode 100644 app/core/abstract_core.py
create mode 100644 app/core/hosts.py
create mode 100644 app/core/manager.py
create mode 100644 app/core/xray.py
create mode 100644 app/db/__init__.py
create mode 100644 app/db/base.py
create mode 100644 app/db/compiles_types.py
create mode 100644 app/db/crud/__init__.py
create mode 100644 app/db/crud/admin.py
create mode 100644 app/db/crud/bulk.py
create mode 100644 app/db/crud/core.py
create mode 100644 app/db/crud/general.py
create mode 100644 app/db/crud/group.py
create mode 100644 app/db/crud/host.py
create mode 100644 app/db/crud/node.py
create mode 100644 app/db/crud/settings.py
create mode 100644 app/db/crud/user.py
create mode 100644 app/db/crud/user_template.py
create mode 100644 app/db/migrations/README
create mode 100644 app/db/migrations/env.py
create mode 100644 app/db/migrations/script.py.mako
create mode 100644 app/db/migrations/versions/015cf1dc6eca_sub_updated_at_and_sub_last_user_agent.py
create mode 100644 app/db/migrations/versions/025d427831dd_sub_last_user_agent_length.py
create mode 100644 app/db/migrations/versions/04a5ec93e9a5_add_randomizednoalpn_and_unsafe_to_.py
create mode 100644 app/db/migrations/versions/07f9bbb3db4e_user_last_status_change.py
create mode 100644 app/db/migrations/versions/084b8004104c_cast_alpn_as_enumarray.py
create mode 100644 app/db/migrations/versions/08b381fc1bc7_update_shadowsocks_method.py
create mode 100644 app/db/migrations/versions/0b62f893092b_add_sub_template_sub_domain_profile_.py
create mode 100644 app/db/migrations/versions/0d22271ee06e_make_data_limit_and_expire_duration_.py
create mode 100644 app/db/migrations/versions/0f720f5c54dd_.py
create mode 100644 app/db/migrations/versions/16e19723febc_migrate_to_groups.py
create mode 100644 app/db/migrations/versions/1ad79b97fdcf_.py
create mode 100644 app/db/migrations/versions/1ccf7b18b283_add_extra_sttings_to_usertemplates_table.py
create mode 100644 app/db/migrations/versions/1cf7d159fdbb_add_online_at.py
create mode 100644 app/db/migrations/versions/1dc5f89b706a_drop_proxies_table.py
create mode 100644 app/db/migrations/versions/1f6e978be88e_migrate_to_sqlalchemy_v2.py
create mode 100644 app/db/migrations/versions/21226bc711ac_add_threshold_to_notificationreminder.py
create mode 100644 app/db/migrations/versions/2313cdc30da3_.py
create mode 100644 app/db/migrations/versions/2b231de97dc3_add_use_sni_as_host_to_hosts.py
create mode 100644 app/db/migrations/versions/2ea33513efc0_noise_for_sqlite.py
create mode 100644 app/db/migrations/versions/305943d779c4_add_h3_to_alpn_enum.py
create mode 100644 app/db/migrations/versions/31f92220c0d0_add_support_random_user_agent.py
create mode 100644 app/db/migrations/versions/343ad7904b19_remove_tls_table.py
create mode 100644 app/db/migrations/versions/35760ebfabd2_add_status_to_hosts.py
create mode 100644 app/db/migrations/versions/35f7f8fa9cf2_add_sub_revoked_at_to_users.py
create mode 100644 app/db/migrations/versions/37692c1c9715_nodes.py
create mode 100644 app/db/migrations/versions/3b59f3680c90_add_groups_table_and_add_proxy_settings_column_to_users_table.py.py
create mode 100644 app/db/migrations/versions/3c466ce2ab63_round_float_values.py
create mode 100644 app/db/migrations/versions/3cf36a5fde73_init_system_table.py
create mode 100644 app/db/migrations/versions/470465472326_add_path_to_hosts.py
create mode 100644 app/db/migrations/versions/4e66033a2b9b_separate_nodes_config.py
create mode 100644 app/db/migrations/versions/4eb0a0eb835f_reformat_hosts.py
create mode 100644 app/db/migrations/versions/4f045f53bef8_drop_proxy_outbound_and_sockopt_from_.py
create mode 100644 app/db/migrations/versions/508061427170_fix_timezones.py
create mode 100644 app/db/migrations/versions/51e941ed9018_deactive_user_status.py
create mode 100644 app/db/migrations/versions/53586547c10e_node_api_key.py
create mode 100644 app/db/migrations/versions/54c4b8c525fc_last_status_change_for_expired_users.py
create mode 100644 app/db/migrations/versions/5575fe410515_add_telegram_id_to_admin.py
create mode 100644 app/db/migrations/versions/57fda18cd9e6_add_xray_version.py
create mode 100644 app/db/migrations/versions/58a5b64175a8_node_stats_table.py
create mode 100644 app/db/migrations/versions/5a4446e7b165_add_password_reset_at_to_admins.py
create mode 100644 app/db/migrations/versions/5b84d88804a1_fix.py
create mode 100644 app/db/migrations/versions/671621870b02_init_admin.py
create mode 100644 app/db/migrations/versions/68edca039166_next_plan_to_template_relation.py
create mode 100644 app/db/migrations/versions/6980e98bba01_add_data_limit_reset_strategy_and_on_.py
create mode 100644 app/db/migrations/versions/714f227201a7_fix_user_template.py
create mode 100644 app/db/migrations/versions/77c86a261126_nodes_coefficient.py
create mode 100644 app/db/migrations/versions/7a0dbb8a2f65_init_tls_table.py
create mode 100644 app/db/migrations/versions/7a93bcd44713_.py
create mode 100644 app/db/migrations/versions/7cbe9d91ac11_proxyhost_security_added.py
create mode 100644 app/db/migrations/versions/81554fa3c345_admin_discord_id.py
create mode 100644 app/db/migrations/versions/852d951c9c08_drop_extra_index.py
create mode 100644 app/db/migrations/versions/89bcb1419c66_fix_user_status_enum.py
create mode 100644 app/db/migrations/versions/8e849e06f131_proxy_table.py
create mode 100644 app/db/migrations/versions/8fe407cf56c9_add_general_settings_to_app.py
create mode 100644 app/db/migrations/versions/931ed40d6eec_add_is_disabled_to_admins_table.py
create mode 100644 app/db/migrations/versions/947ebbd8debe_add_on_hold.py
create mode 100644 app/db/migrations/versions/94a5cc12c0d6_init_user_table.py
create mode 100644 app/db/migrations/versions/95da30deba8d_change_fragments_and_noise_from_regex_.py
create mode 100644 app/db/migrations/versions/97dd9311ab93_add_user_templates.py
create mode 100644 app/db/migrations/versions/9aa6559916be_drop_mux_enable_column_in_hosts_table.py
create mode 100644 app/db/migrations/versions/9af04c077ede_init_settings.py
create mode 100644 app/db/migrations/versions/9b60be6cd0a2_add_created_at_field_to_users_table.py
create mode 100644 app/db/migrations/versions/9cfffef342cd_user_template_status_and_reset_usage.py
create mode 100644 app/db/migrations/versions/9d5a518ae432_init_jwt_table.py
create mode 100644 app/db/migrations/versions/9fa4831e991a_add_ech_config_list_to_hosts.py
create mode 100644 app/db/migrations/versions/a0715c2615f0_add_allow_in_secure_and_disable_host.py
create mode 100644 app/db/migrations/versions/a0d3d400ea75_admin_is_sudo_field.py
create mode 100644 app/db/migrations/versions/a6e3fff39291_add_notification_reminders.py
create mode 100644 app/db/migrations/versions/a9cfd5611a82_add_noise_settings.py
create mode 100644 app/db/migrations/versions/ac26f36783a2_increase_username_length.py
create mode 100644 app/db/migrations/versions/adda2dd4a741_add_sockopt_proxy_outbound_mux_enable_fragment_setting_to_hosts.py
create mode 100644 app/db/migrations/versions/b15eba6e5867_add_host_sni.py
create mode 100644 app/db/migrations/versions/b25e7e6be241_added_users_usage_to_admin.py
create mode 100644 app/db/migrations/versions/b3378dc6de01_hosts.py
create mode 100644 app/db/migrations/versions/be0c5f840473_fix_multiple_heads_error.py
create mode 100644 app/db/migrations/versions/beb47f520963_gather_node_logs_option.py
create mode 100644 app/db/migrations/versions/c106bb40c861_alpn_fingerprint_hosts.py
create mode 100644 app/db/migrations/versions/c3cd674b9bcd_added_next_plan_for_user.py
create mode 100644 app/db/migrations/versions/c41c441de44c_gozargah_node.py
create mode 100644 app/db/migrations/versions/c47250b790eb_add_user_note.py
create mode 100644 app/db/migrations/versions/c5c734bd3da2_add_priority_to_proxy_hosts.py
create mode 100644 app/db/migrations/versions/cb99b515fbab_drop_excloud_inbounds_and_template_.py
create mode 100644 app/db/migrations/versions/ccbf9d322ae3_user_auto_delete_in_days.py
create mode 100644 app/db/migrations/versions/cf67676aaf82_add_is_disabled_to_usertemplates.py
create mode 100644 app/db/migrations/versions/d02dcfbf1517_add_userusageresetlogs_model_and_data_.py
create mode 100644 app/db/migrations/versions/d085fae205b6_rename_admin_users_usage_to_used_traffic.py
create mode 100644 app/db/migrations/versions/d0a3960f5dad_usagelog_table_for_admin.py
create mode 100644 app/db/migrations/versions/d607d3ca5246_change_enumarray_to_string.py
create mode 100644 app/db/migrations/versions/db68f8d3d40b_move_data_from_proxies_table_to_users_.py
create mode 100644 app/db/migrations/versions/dd725e4d3628_fix_mysql_collations.py
create mode 100644 app/db/migrations/versions/e3f0e888a563_rename_deactive_status_to_disabled.py
create mode 100644 app/db/migrations/versions/e410e5f15c3f_merge_fad8b1997c3a_a0d3d400ea75.py
create mode 100644 app/db/migrations/versions/e422f859847f_refactor_sub_updated_at.py
create mode 100644 app/db/migrations/versions/e4a86bc8ec7b_node_user_usages.py
create mode 100644 app/db/migrations/versions/e56f1c781e46_fix_on_hold.py
create mode 100644 app/db/migrations/versions/e7b869e999b4_increase_length_of_the_host_and_sni_.py
create mode 100644 app/db/migrations/versions/e91236993f1a_inbounds_table_excluded_inbounds.py
create mode 100644 app/db/migrations/versions/eaa9f30f983e_add_headers_and_transports_settings_.py
create mode 100644 app/db/migrations/versions/ece13c4c6f65_.py
create mode 100644 app/db/migrations/versions/f2b9caa23e16_change_expire_to_datetime.py
create mode 100644 app/db/migrations/versions/f44ec4769d5d_fix_alpn_enum.py
create mode 100644 app/db/migrations/versions/fad8b1997c3a_case_insensitive_username.py
create mode 100644 app/db/migrations/versions/fbfc49f01004_drop_fire_on_either_column.py
create mode 100644 app/db/migrations/versions/fc01b1520e72_add_node_usages.py
create mode 100644 app/db/migrations/versions/fe7796f840a4_remove_certficiate_from_nodes.py
create mode 100644 app/db/models.py
create mode 100644 app/jobs/__init__.py
create mode 100644 app/jobs/cleanup_subscription_updates.py
create mode 100644 app/jobs/dependencies.py
create mode 100644 app/jobs/inboud.py
create mode 100644 app/jobs/node_checker.py
create mode 100644 app/jobs/node_stats.py
create mode 100644 app/jobs/record_usages.py
create mode 100644 app/jobs/remove_expired_users.py
create mode 100644 app/jobs/reset_user_data_usage.py
create mode 100644 app/jobs/review_users.py
create mode 100644 app/jobs/send_notifications.py
create mode 100644 app/models/admin.py
create mode 100644 app/models/core.py
create mode 100644 app/models/group.py
create mode 100644 app/models/host.py
create mode 100644 app/models/node.py
create mode 100644 app/models/proxy.py
create mode 100644 app/models/settings.py
create mode 100644 app/models/stats.py
create mode 100644 app/models/system.py
create mode 100644 app/models/user.py
create mode 100644 app/models/user_template.py
create mode 100644 app/models/validators.py
create mode 100644 app/node/__init__.py
create mode 100644 app/node/user.py
create mode 100644 app/notification/__init__.py
create mode 100644 app/notification/client.py
create mode 100644 app/notification/discord/__init__.py
create mode 100644 app/notification/discord/admin.py
create mode 100644 app/notification/discord/colors.py
create mode 100644 app/notification/discord/core.py
create mode 100644 app/notification/discord/group.py
create mode 100644 app/notification/discord/host.py
create mode 100644 app/notification/discord/messages.py
create mode 100644 app/notification/discord/node.py
create mode 100644 app/notification/discord/user.py
create mode 100644 app/notification/discord/user_template.py
create mode 100644 app/notification/discord/utils.py
create mode 100644 app/notification/telegram/__init__.py
create mode 100644 app/notification/telegram/admin.py
create mode 100644 app/notification/telegram/core.py
create mode 100644 app/notification/telegram/group.py
create mode 100644 app/notification/telegram/host.py
create mode 100644 app/notification/telegram/messages.py
create mode 100644 app/notification/telegram/node.py
create mode 100644 app/notification/telegram/user.py
create mode 100644 app/notification/telegram/user_template.py
create mode 100644 app/notification/telegram/utils.py
create mode 100644 app/notification/webhook/__init__.py
create mode 100644 app/operation/__init__.py
create mode 100644 app/operation/admin.py
create mode 100644 app/operation/core.py
create mode 100644 app/operation/group.py
create mode 100644 app/operation/host.py
create mode 100644 app/operation/node.py
create mode 100644 app/operation/settings.py
create mode 100644 app/operation/subscription.py
create mode 100644 app/operation/system.py
create mode 100644 app/operation/user.py
create mode 100644 app/operation/user_template.py
create mode 100644 app/routers/__init__.py
create mode 100644 app/routers/admin.py
create mode 100644 app/routers/authentication.py
create mode 100644 app/routers/core.py
create mode 100644 app/routers/group.py
create mode 100644 app/routers/home.py
create mode 100644 app/routers/host.py
create mode 100644 app/routers/node.py
create mode 100644 app/routers/settings.py
create mode 100644 app/routers/subscription.py
create mode 100644 app/routers/system.py
create mode 100644 app/routers/user.py
create mode 100644 app/routers/user_template.py
create mode 100644 app/settings/__init__.py
create mode 100644 app/subscription/__init__.py
create mode 100644 app/subscription/base.py
create mode 100644 app/subscription/clash.py
create mode 100644 app/subscription/funcs.py
create mode 100644 app/subscription/links.py
create mode 100644 app/subscription/outline.py
create mode 100644 app/subscription/share.py
create mode 100644 app/subscription/singbox.py
create mode 100644 app/subscription/xray.py
create mode 100644 app/telegram/__init__.py
create mode 100644 app/telegram/handlers/__init__.py
create mode 100644 app/telegram/handlers/admin/__init__.py
create mode 100644 app/telegram/handlers/admin/bulk_actions.py
create mode 100644 app/telegram/handlers/admin/confirm_action.py
create mode 100644 app/telegram/handlers/admin/main_menu.py
create mode 100644 app/telegram/handlers/admin/user.py
create mode 100644 app/telegram/handlers/base.py
create mode 100644 app/telegram/handlers/client/__init__.py
create mode 100644 app/telegram/handlers/client/show_info.py
create mode 100644 app/telegram/handlers/error_handler.py
create mode 100644 app/telegram/keyboards/__init__.py
create mode 100644 app/telegram/keyboards/admin.py
create mode 100644 app/telegram/keyboards/base.py
create mode 100644 app/telegram/keyboards/bulk_actions.py
create mode 100644 app/telegram/keyboards/confim_action.py
create mode 100644 app/telegram/keyboards/group.py
create mode 100644 app/telegram/keyboards/user.py
create mode 100644 app/telegram/middlewares/__init__.py
create mode 100644 app/telegram/middlewares/acl.py
create mode 100644 app/telegram/utils/filters.py
create mode 100644 app/telegram/utils/forms.py
create mode 100644 app/telegram/utils/shared.py
create mode 100644 app/telegram/utils/texts.py
create mode 100644 app/templates/__init__.py
create mode 100644 app/templates/clash/README.md
create mode 100644 app/templates/clash/default.yml
create mode 100644 app/templates/filters.py
create mode 100644 app/templates/home/index.html
create mode 100644 app/templates/singbox/README.md
create mode 100644 app/templates/singbox/default.json
create mode 100644 app/templates/subscription/index.html
create mode 100644 app/templates/user_agent/default.json
create mode 100644 app/templates/user_agent/grpc.json
create mode 100644 app/templates/xray/README.md
create mode 100644 app/templates/xray/default.json
create mode 100644 app/utils/crypto.py
create mode 100644 app/utils/helpers.py
create mode 100644 app/utils/jwt.py
create mode 100644 app/utils/logger.py
create mode 100644 app/utils/responses.py
create mode 100644 app/utils/store.py
create mode 100644 app/utils/system.py
create mode 100755 build_dashboard.sh
create mode 100644 cli/README.md
create mode 100644 cli/__init__.py
create mode 100644 cli/admin.py
create mode 100644 cli/main.py
create mode 100644 cli/system.py
create mode 100644 cli_wrapper.sh
create mode 100644 config.py
create mode 100644 dashboard/.env.example
create mode 100644 dashboard/.gitignore
create mode 100644 dashboard/.prettierrc.json
create mode 100644 dashboard/README.md
create mode 100644 dashboard/__init__.py
create mode 100644 dashboard/build.zip
create mode 100644 dashboard/bun.lock
create mode 100644 dashboard/components.json
create mode 100644 dashboard/custom.d.ts
create mode 100644 dashboard/eslint.config.js
create mode 100644 dashboard/index.html
create mode 100644 dashboard/orval.config.ts
create mode 100644 dashboard/package-lock.json
create mode 100644 dashboard/package.json
create mode 100644 dashboard/public/statics/favicon/android-chrome-192x192.png
create mode 100644 dashboard/public/statics/favicon/android-chrome-512x512.png
create mode 100644 dashboard/public/statics/favicon/apple-touch-icon.png
create mode 100644 dashboard/public/statics/favicon/favicon.ico
create mode 100644 dashboard/public/statics/favicon/logo-dark.png
create mode 100644 dashboard/public/statics/favicon/logo-pwa.png
create mode 100644 dashboard/public/statics/favicon/logo.png
create mode 100644 dashboard/public/statics/favicon/mstile-150x150.png
create mode 100644 dashboard/public/statics/favicon/mstile-310x310.png
create mode 100644 dashboard/public/statics/favicon/mstile-70x70.png
create mode 100644 dashboard/public/statics/locales/en.json
create mode 100644 dashboard/public/statics/locales/fa.json
create mode 100644 dashboard/public/statics/locales/ru.json
create mode 100644 dashboard/public/statics/locales/zh.json
create mode 100644 dashboard/react-router.config.ts
create mode 100644 dashboard/src/App.tsx
create mode 100644 dashboard/src/assets/react.svg
create mode 100644 dashboard/src/components/ActionButtons.tsx
create mode 100644 dashboard/src/components/AdminStatistics.tsx
create mode 100644 dashboard/src/components/AdminStatusBadge.tsx
create mode 100644 dashboard/src/components/CopyButton.tsx
create mode 100644 dashboard/src/components/Footer.tsx
create mode 100644 dashboard/src/components/Language.tsx
create mode 100644 dashboard/src/components/LineCountFilter.tsx
create mode 100644 dashboard/src/components/LoadingSpinner.tsx
create mode 100644 dashboard/src/components/OnlineBadge.tsx
create mode 100644 dashboard/src/components/OnlineStatus.tsx
create mode 100644 dashboard/src/components/PageTransition.tsx
create mode 100644 dashboard/src/components/RouteGuard.tsx
create mode 100644 dashboard/src/components/SinceLogsFilter.tsx
create mode 100644 dashboard/src/components/Statistics.tsx
create mode 100644 dashboard/src/components/StatusBadge.tsx
create mode 100644 dashboard/src/components/StatusLogsFilter.tsx
create mode 100644 dashboard/src/components/TerminalLine.tsx
create mode 100644 dashboard/src/components/TopLoadingBar.tsx
create mode 100644 dashboard/src/components/UsageSliderCompact.tsx
create mode 100644 dashboard/src/components/UsersStatistics.tsx
create mode 100644 dashboard/src/components/admins/AdminsTable.tsx
create mode 100644 dashboard/src/components/admins/columns.tsx
create mode 100644 dashboard/src/components/admins/data-table.tsx
create mode 100644 dashboard/src/components/admins/filters.tsx
create mode 100644 dashboard/src/components/bulk/SelectorPanel.tsx
create mode 100644 dashboard/src/components/charts/AllNodesStackedBarChart.tsx
create mode 100644 dashboard/src/components/charts/AreaCostumeChart.tsx
create mode 100644 dashboard/src/components/charts/CostumeBarChart.tsx
create mode 100644 dashboard/src/components/charts/EmptyState.tsx
create mode 100644 dashboard/src/components/charts/TimeSelector.tsx
create mode 100644 dashboard/src/components/common/AdminsSelector.tsx
create mode 100644 dashboard/src/components/common/GroupsSelector.tsx
create mode 100644 dashboard/src/components/common/TimeRangeSelector.tsx
create mode 100644 dashboard/src/components/dashboard/DashboardAdminStatistics.tsx
create mode 100644 dashboard/src/components/dashboard/DashboardStatistics.tsx
create mode 100644 dashboard/src/components/dashboard/admin-statistics-card.tsx
create mode 100644 dashboard/src/components/dashboard/data-usage-chart.tsx
create mode 100644 dashboard/src/components/dashboard/users-statistics-card.tsx
create mode 100644 dashboard/src/components/dialogs/AdminModal.tsx
create mode 100644 dashboard/src/components/dialogs/AdvanceSearchModal.tsx
create mode 100644 dashboard/src/components/dialogs/CoreConfigModal.tsx
create mode 100644 dashboard/src/components/dialogs/GroupModal.tsx
create mode 100644 dashboard/src/components/dialogs/HostModal.tsx
create mode 100644 dashboard/src/components/dialogs/NodeModal.tsx
create mode 100644 dashboard/src/components/dialogs/QRCodeModal.tsx
create mode 100644 dashboard/src/components/dialogs/SetOwnerModal.tsx
create mode 100644 dashboard/src/components/dialogs/ShortcutsModal.tsx
create mode 100644 dashboard/src/components/dialogs/UsageModal.tsx
create mode 100644 dashboard/src/components/dialogs/UserModal.tsx
create mode 100644 dashboard/src/components/dialogs/UserOnlineStatsModal.tsx
create mode 100644 dashboard/src/components/dialogs/UserSubscriptionClientsModal.tsx
create mode 100644 dashboard/src/components/dialogs/UserTemplateModal.tsx
create mode 100644 dashboard/src/components/github-star.tsx
create mode 100644 dashboard/src/components/groups/Group.tsx
create mode 100644 dashboard/src/components/groups/Groups.tsx
create mode 100644 dashboard/src/components/hosts/Hosts.tsx
create mode 100644 dashboard/src/components/hosts/SortableHost.tsx
create mode 100644 dashboard/src/components/layout/sidebar-toggle.tsx
create mode 100644 dashboard/src/components/layout/sidebar.tsx
create mode 100644 dashboard/src/components/nav-main.tsx
create mode 100644 dashboard/src/components/nav-secondary.tsx
create mode 100644 dashboard/src/components/nav-user.tsx
create mode 100644 dashboard/src/components/nodes/Node.tsx
create mode 100644 dashboard/src/components/nodes/Nodes.tsx
create mode 100644 dashboard/src/components/page-header.tsx
create mode 100644 dashboard/src/components/settings/Core.tsx
create mode 100644 dashboard/src/components/settings/Cores.tsx
create mode 100644 dashboard/src/components/settings/Logs.tsx
create mode 100644 dashboard/src/components/spinner.tsx
create mode 100644 dashboard/src/components/statistics/Statistics.tsx
create mode 100644 dashboard/src/components/statistics/SystemStatisticsSection.tsx
create mode 100644 dashboard/src/components/statistics/UserStatisticsSection.tsx
create mode 100644 dashboard/src/components/templates/UserTemplate.tsx
create mode 100644 dashboard/src/components/theme-provider.tsx
create mode 100644 dashboard/src/components/theme-toggle.tsx
create mode 100644 dashboard/src/components/ui/accordion.tsx
create mode 100644 dashboard/src/components/ui/alert-dialog.tsx
create mode 100644 dashboard/src/components/ui/alert.tsx
create mode 100644 dashboard/src/components/ui/avatar.tsx
create mode 100644 dashboard/src/components/ui/badge.tsx
create mode 100644 dashboard/src/components/ui/breadcrumb.tsx
create mode 100644 dashboard/src/components/ui/button.tsx
create mode 100644 dashboard/src/components/ui/calendar.tsx
create mode 100644 dashboard/src/components/ui/card.tsx
create mode 100644 dashboard/src/components/ui/carousel.tsx
create mode 100644 dashboard/src/components/ui/chart.tsx
create mode 100644 dashboard/src/components/ui/checkbox.tsx
create mode 100644 dashboard/src/components/ui/collapsible.tsx
create mode 100644 dashboard/src/components/ui/command.tsx
create mode 100644 dashboard/src/components/ui/dialog.tsx
create mode 100644 dashboard/src/components/ui/drawer.tsx
create mode 100644 dashboard/src/components/ui/dropdown-menu.tsx
create mode 100644 dashboard/src/components/ui/form.tsx
create mode 100644 dashboard/src/components/ui/hover-card.tsx
create mode 100644 dashboard/src/components/ui/input.tsx
create mode 100644 dashboard/src/components/ui/label.tsx
create mode 100644 dashboard/src/components/ui/loader-button.tsx
create mode 100644 dashboard/src/components/ui/pagination.tsx
create mode 100644 dashboard/src/components/ui/password-input.tsx
create mode 100644 dashboard/src/components/ui/persian-calendar.tsx
create mode 100644 dashboard/src/components/ui/popover.tsx
create mode 100644 dashboard/src/components/ui/progress.tsx
create mode 100644 dashboard/src/components/ui/radio-group.tsx
create mode 100644 dashboard/src/components/ui/scroll-area.tsx
create mode 100644 dashboard/src/components/ui/select.tsx
create mode 100644 dashboard/src/components/ui/separator.tsx
create mode 100644 dashboard/src/components/ui/sheet.tsx
create mode 100644 dashboard/src/components/ui/sidebar.tsx
create mode 100644 dashboard/src/components/ui/skeleton.tsx
create mode 100644 dashboard/src/components/ui/sonner.tsx
create mode 100644 dashboard/src/components/ui/switch.tsx
create mode 100644 dashboard/src/components/ui/table.tsx
create mode 100644 dashboard/src/components/ui/tabs.tsx
create mode 100644 dashboard/src/components/ui/textarea.tsx
create mode 100644 dashboard/src/components/ui/toggle-group.tsx
create mode 100644 dashboard/src/components/ui/toggle.tsx
create mode 100644 dashboard/src/components/ui/tooltip.tsx
create mode 100644 dashboard/src/components/users/columns.tsx
create mode 100644 dashboard/src/components/users/data-table.tsx
create mode 100644 dashboard/src/components/users/filters.tsx
create mode 100644 dashboard/src/components/users/users-table.tsx
create mode 100644 dashboard/src/constants/Project.ts
create mode 100644 dashboard/src/constants/Proxies.ts
create mode 100644 dashboard/src/constants/UserSettings.ts
create mode 100644 dashboard/src/entry.client.tsx
create mode 100644 dashboard/src/hooks/use-admin.ts
create mode 100644 dashboard/src/hooks/use-clipboard.ts
create mode 100644 dashboard/src/hooks/use-dir-detection.tsx
create mode 100644 dashboard/src/hooks/use-dynamic-errors.ts
create mode 100644 dashboard/src/hooks/use-mobile.tsx
create mode 100644 dashboard/src/hooks/use-store.ts
create mode 100644 dashboard/src/hooks/use-toast.ts
create mode 100644 dashboard/src/hooks/use-top-loading-bar.ts
create mode 100644 dashboard/src/index.css
create mode 100644 dashboard/src/lib/dayjs.ts
create mode 100644 dashboard/src/lib/menu-list.ts
create mode 100644 dashboard/src/lib/utils.ts
create mode 100644 dashboard/src/locales/i18n.ts
create mode 100644 dashboard/src/pages/_dashboard._index.tsx
create mode 100644 dashboard/src/pages/_dashboard.admins.tsx
create mode 100644 dashboard/src/pages/_dashboard.bulk.data.tsx
create mode 100644 dashboard/src/pages/_dashboard.bulk.expire.tsx
create mode 100644 dashboard/src/pages/_dashboard.bulk.groups.tsx
create mode 100644 dashboard/src/pages/_dashboard.bulk.proxy.tsx
create mode 100644 dashboard/src/pages/_dashboard.bulk.tsx
create mode 100644 dashboard/src/pages/_dashboard.groups.tsx
create mode 100644 dashboard/src/pages/_dashboard.hosts.tsx
create mode 100644 dashboard/src/pages/_dashboard.nodes._index.tsx
create mode 100644 dashboard/src/pages/_dashboard.nodes.cores.tsx
create mode 100644 dashboard/src/pages/_dashboard.nodes.logs.tsx
create mode 100644 dashboard/src/pages/_dashboard.nodes.tsx
create mode 100644 dashboard/src/pages/_dashboard.settings.cleanup.tsx
create mode 100644 dashboard/src/pages/_dashboard.settings.discord.tsx
create mode 100644 dashboard/src/pages/_dashboard.settings.general.tsx
create mode 100644 dashboard/src/pages/_dashboard.settings.notifications.tsx
create mode 100644 dashboard/src/pages/_dashboard.settings.subscriptions.tsx
create mode 100644 dashboard/src/pages/_dashboard.settings.telegram.tsx
create mode 100644 dashboard/src/pages/_dashboard.settings.theme.tsx
create mode 100644 dashboard/src/pages/_dashboard.settings.tsx
create mode 100644 dashboard/src/pages/_dashboard.settings.webhook.tsx
create mode 100644 dashboard/src/pages/_dashboard.statistics.tsx
create mode 100644 dashboard/src/pages/_dashboard.templates.tsx
create mode 100644 dashboard/src/pages/_dashboard.tsx
create mode 100644 dashboard/src/pages/_dashboard.users.tsx
create mode 100644 dashboard/src/pages/login.tsx
create mode 100644 dashboard/src/router.tsx
create mode 100644 dashboard/src/service/api/index.ts
create mode 100644 dashboard/src/service/http.ts
create mode 100644 dashboard/src/sw-register.ts
create mode 100644 dashboard/src/utils/authStorage.ts
create mode 100644 dashboard/src/utils/dateFormatter.tsx
create mode 100644 dashboard/src/utils/formatByte.ts
create mode 100644 dashboard/src/utils/isEmptyObject.ts
create mode 100644 dashboard/src/utils/logsUtils.ts
create mode 100644 dashboard/src/utils/query-client.ts
create mode 100644 dashboard/src/utils/react-query.ts
create mode 100644 dashboard/src/utils/userAgentParser.ts
create mode 100644 dashboard/src/utils/userPreferenceStorage.ts
create mode 100644 dashboard/src/vite-env.d.ts
create mode 100644 dashboard/tailwind.config.js
create mode 100644 dashboard/tsconfig.app.json
create mode 100644 dashboard/tsconfig.json
create mode 100644 dashboard/tsconfig.node.json
create mode 100644 dashboard/vite.config.mts
create mode 100644 docker-compose.yml
create mode 100755 install_service.sh
create mode 100644 main.py
create mode 100644 pasarguard-cli.py
create mode 100644 pasarguard-tui.py
create mode 100644 pyproject.toml
create mode 100644 pytest.ini
create mode 100644 start.sh
create mode 100644 tests/api/__init__.py
create mode 100644 tests/api/conftest.py
create mode 100644 tests/api/test_a_admin.py
create mode 100644 tests/api/test_b_core.py
create mode 100644 tests/api/test_c_group.py
create mode 100644 tests/api/test_d_host.py
create mode 100644 tests/api/test_e_user.py
create mode 100644 tests/api/test_f_user_template.py
create mode 100644 tests/api/test_g_bulk.py
create mode 100644 tests/api/test_h_system.py
create mode 100644 tests/conftest.py
create mode 100644 tui/README.md
create mode 100644 tui/__init__.py
create mode 100644 tui/admin.py
create mode 100644 tui/help.py
create mode 100644 tui/style.tcss
create mode 100644 tui_wrapper.sh
create mode 100644 uv.lock
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 000000000..8c690bf3c
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,24 @@
+**/node_modules
+*.log
+*.md
+*.pyc
+*.pyd
+*.pyo
+.docker*
+.env*
+.git*
+.idea
+.vscode
+Dockerfile*
+__pycache__
+db.sqlite3
+db.sqlite3-journal
+docker-compose*.yml
+v2ray-core
+xray-core
+tests
+venv*
+.venv*
+LICENSE
+install_service.sh
+Marzban.code-workspace
diff --git a/.env.example b/.env.example
new file mode 100644
index 000000000..9194bdbe5
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,66 @@
+UVICORN_HOST = "0.0.0.0"
+UVICORN_PORT = 8000
+# ALLOWED_ORIGINS=http://localhost,http://localhost:8000,http://example.com
+
+## We highly recommend add admin using `pasarguard cli` tool and do not use
+## the following variables which is somehow hard codded infrmation.
+# SUDO_USERNAME = "admin"
+# SUDO_PASSWORD = "admin"
+
+# UVICORN_UDS = "/run/pasarguard.socket"
+# UVICORN_SSL_CERTFILE = "/var/lib/pasarguard/certs/example.com/fullchain.pem"
+# UVICORN_SSL_KEYFILE = "/var/lib/pasarguard/certs/example.com/key.pem"
+# UVICORN_SSL_CA_TYPE = "public"
+
+# DASHBOARD_PATH = "/dashboard/"
+
+# SUBSCRIPTION_PATH = "sub"
+# USER_SUBSCRIPTION_CLIENTS_LIMIT = 10
+
+# CUSTOM_TEMPLATES_DIRECTORY="/var/lib/pasarguard/templates/"
+# CLASH_SUBSCRIPTION_TEMPLATE="clash/my-custom-template.yml"
+# SUBSCRIPTION_PAGE_TEMPLATE="subscription/index.html"
+# HOME_PAGE_TEMPLATE="home/index.html"
+# XRAY_SUBSCRIPTION_TEMPLATE="xray/default.json"
+# SINGBOX_SUBSCRIPTION_TEMPLATE="singbox/default.json"
+
+## External config to import into v2ray format subscription
+# EXTERNAL_CONFIG = "config://..."
+
+# SQLALCHEMY_DATABASE_URL = "sqlite+aiosqlite:///db.sqlite3"
+# SQLALCHEMY_DATABASE_URL="postgresql+asyncpg://postgres:DB_PASSWORD@localhost:5432/pasarguard"
+# SQLALCHEMY_DATABASE_URL="mysql+asyncmy://root:DB_PASSWORD@127.0.0.1/pasarguard"
+# SQLALCHEMY_POOL_SIZE = 10
+# SQLALCHEMY_MAX_OVERFLOW = 30
+
+### Use negative values to disable auto-delete by default
+# USERS_AUTODELETE_DAYS = -1
+# USER_AUTODELETE_INCLUDE_LIMITED_ACCOUNTS = false
+
+### for developers
+# DOCS=True
+# DEBUG=True
+# DO_NOT_LOG_TELEGRAM_BOT=True
+# SAVE_LOGS_TO_FILE=False
+# LOG_FILE_PATH="pasarguard.log"
+# LOG_BACKUP_COUNT=72
+# LOG_ROTATION_ENABLED=False
+# LOG_ROTATION_INTERVAL=1
+# LOG_ROTATION_UNIT="H" # "S", "M", "H", "D", "W0"-"W6", "midnight"
+# LOG_MAX_BYTES=10485760 # 10 MB
+# ECHO_SQL_QUERIES=False
+# VITE_BASE_API="https://example.com/"
+# JWT_ACCESS_TOKEN_EXPIRE_MINUTES = 1440
+
+# due to high amount of data, this job is only available for postgresql and timescaledb
+# ENABLE_RECORDING_NODES_STATS = False
+
+# JOB_CORE_HEALTH_CHECK_INTERVAL = 10
+# JOB_RECORD_NODE_USAGES_INTERVAL = 30
+# JOB_RECORD_USER_USAGES_INTERVAL = 10
+# JOB_REVIEW_USERS_INTERVAL = 10
+# JOB_SEND_NOTIFICATIONS_INTERVAL = 30
+# JOB_GHATER_NODES_STATS_INTERVAL = 25
+# JOB_REMOVE_OLD_INBOUNDS_INTERVAL = 600
+# JOB_REMOVE_EXPIRED_USERS_INTERVAL = 3600
+# JOB_RESET_USER_DATA_USAGE_INTERVAL = 600
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 000000000..3c3288210
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,10 @@
+# Ignore frontend files for language detection
+dashboard/** linguist-vendored
+*.ts linguist-vendored
+*.tsx linguist-vendored
+*.js linguist-vendored
+*.jsx linguist-vendored
+
+# Emphasize Python files
+*.py linguist-detectable
+app/** linguist-detectable
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 000000000..d349818af
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,33 @@
+---
+name: Bug report
+about: Create a report to help us improve
+title: Bug title
+labels: bug
+assignees: ''
+
+---
+
+**Describe the bug**
+A clear and concise description of what the bug is.
+
+**To Reproduce**
+Steps to reproduce the behavior:
+1. Go to '...'
+2. Click on '....'
+3. Scroll down to '....'
+4. See error
+
+**Expected behavior**
+A clear and concise description of what you expected to happen.
+
+**Screenshots**
+If applicable, add screenshots to help explain your problem.
+
+**Machine details (please complete the following information):**
+ - OS: [e.g. ubuntu 20]
+- Python version: [e.g 3.8]
+- Nodejs version: [e.g 16.17]
+- Browser [e.g. chrome, safari]
+
+**Additional context**
+Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 000000000..6819cf2c4
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,20 @@
+---
+name: Feature request
+about: Suggest an idea for this project
+title: Feature title
+labels: enhancement
+assignees: ''
+
+---
+
+**Is your feature request related to a problem? Please describe.**
+A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
+
+**Describe the solution you'd like**
+A clear and concise description of what you want to happen.
+
+**Describe alternatives you've considered**
+A clear and concise description of any alternative solutions or features you've considered.
+
+**Additional context**
+Add any other context or screenshots about the feature request here.
diff --git a/.github/workflows/api-codeql.yml b/.github/workflows/api-codeql.yml
new file mode 100644
index 000000000..6da610e83
--- /dev/null
+++ b/.github/workflows/api-codeql.yml
@@ -0,0 +1,48 @@
+name: "API CodeQL"
+
+on:
+ push:
+ branches:
+ - "*"
+ paths-ignore:
+ - "dashboard/**"
+ pull_request:
+ branches:
+ - "*"
+ paths-ignore:
+ - "dashboard/**"
+ schedule:
+ - cron: "0 11 * * 3"
+
+jobs:
+ analyze:
+ name: Analyze
+ runs-on: "ubuntu-latest"
+ timeout-minutes: 120
+ permissions:
+ security-events: write
+ actions: read
+ contents: read
+
+ strategy:
+ fail-fast: false
+ matrix:
+ language:
+ - "python"
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v3
+ with:
+ languages: ${{ matrix.language }}
+
+ - name: Autobuild
+ uses: github/codeql-action/autobuild@v3
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v3
+ with:
+ category: "/language:${{matrix.language}}"
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 000000000..9b8497da9
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,140 @@
+name: Build-Docker
+
+on:
+ release:
+ types: [created]
+
+env:
+ IMAGE: pasarguard/${{ github.event.repository.name }}:${{ github.ref_name }}
+ GHCR_IMAGE: ghcr.io/pasarguard/${{ github.event.repository.name }}:${{ github.ref_name }}
+ IMAGE_LATEST: pasarguard/${{ github.event.repository.name }}:latest
+ GHCR_IMAGE_LATEST: ghcr.io/pasarguard/${{ github.event.repository.name }}:latest
+
+jobs:
+ build-dashboard:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Install dependencies
+ working-directory: ./dashboard
+ run: bun install --frozen-lockfile
+
+ - name: Build dashboard
+ run: ./build_dashboard.sh
+
+ - name: Upload dashboard build
+ uses: actions/upload-artifact@v4
+ with:
+ name: dashboard-build
+ path: ./dashboard/build/
+ retention-days: 1
+
+ build-images:
+ needs: build-dashboard
+ strategy:
+ matrix:
+ arch: [amd64, arm64]
+ include:
+ - arch: amd64
+ platform: linux/amd64
+ runner: ubuntu-24.04
+ - arch: arm64
+ platform: linux/arm64
+ runner: ubuntu-24.04-arm
+ runs-on: ${{ matrix.runner }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Download dashboard build
+ uses: actions/download-artifact@v4
+ with:
+ name: dashboard-build
+ path: ./dashboard/build/
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Login to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Login to GitHub Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.repository_owner }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build and push ${{ matrix.arch }} image
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ platforms: ${{ matrix.platform }}
+ push: true
+ file: ./Dockerfile
+ tags: |
+ ${{ env.IMAGE }}-${{ matrix.arch }}
+ ${{ env.GHCR_IMAGE }}-${{ matrix.arch }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
+ push-manifest:
+ needs: [build-images]
+ runs-on: ubuntu-latest
+ steps:
+ - name: Login to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Login to GitHub Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.repository_owner }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Create tagged manifest
+ uses: int128/docker-manifest-create-action@v2
+ with:
+ tags: ${{ env.IMAGE }}
+ sources: |
+ ${{ env.IMAGE }}-amd64
+ ${{ env.IMAGE }}-arm64
+
+ - name: Create tagged manifest for GitHub
+ uses: int128/docker-manifest-create-action@v2
+ with:
+ tags: ${{ env.GHCR_IMAGE }}
+ sources: |
+ ${{ env.GHCR_IMAGE }}-amd64
+ ${{ env.GHCR_IMAGE }}-arm64
+
+ - name: Create latest manifest if not prerelease
+ if: ${{ github.event.release.prerelease != true }}
+ uses: int128/docker-manifest-create-action@v2
+ with:
+ tags: ${{ env.IMAGE_LATEST }}
+ sources: |
+ ${{ env.IMAGE }}-amd64
+ ${{ env.IMAGE }}-arm64
+
+ - name: Create latest manifest for GitHub if not prerelease
+ if: ${{ github.event.release.prerelease != true }}
+ uses: int128/docker-manifest-create-action@v2
+ with:
+ tags: ${{ env.GHCR_IMAGE_LATEST }}
+ sources: |
+ ${{ env.GHCR_IMAGE }}-amd64
+ ${{ env.GHCR_IMAGE }}-arm64
\ No newline at end of file
diff --git a/.github/workflows/frontend-codeql.yml b/.github/workflows/frontend-codeql.yml
new file mode 100644
index 000000000..f549b9fef
--- /dev/null
+++ b/.github/workflows/frontend-codeql.yml
@@ -0,0 +1,48 @@
+name: "Frontend CodeQL"
+
+on:
+ push:
+ branches:
+ - "*"
+ paths:
+ - "dashboard/**"
+ pull_request:
+ branches:
+ - "*"
+ paths:
+ - "dashboard/**"
+ schedule:
+ - cron: "0 11 * * 3"
+
+jobs:
+ analyze:
+ name: Analyze
+ runs-on: "ubuntu-latest"
+ timeout-minutes: 120
+ permissions:
+ security-events: write
+ actions: read
+ contents: read
+
+ strategy:
+ fail-fast: false
+ matrix:
+ language:
+ - "javascript-typescript"
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v3
+ with:
+ languages: ${{ matrix.language }}
+
+ - name: Autobuild
+ uses: github/codeql-action/autobuild@v3
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v3
+ with:
+ category: "/language:${{matrix.language}}"
diff --git a/.github/workflows/ruff-check.yml b/.github/workflows/ruff-check.yml
new file mode 100644
index 000000000..9d7b57dd2
--- /dev/null
+++ b/.github/workflows/ruff-check.yml
@@ -0,0 +1,33 @@
+name: Code Format Checker
+on:
+ push:
+ branches:
+ - "*"
+ paths:
+ - "**/*.py"
+
+ pull_request:
+ branches:
+ - "*"
+ paths:
+ - "**/*.py"
+
+jobs:
+ ruff:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v4
+ with:
+ python-version: "3.12"
+
+ - name: Install Ruff
+ run: |
+ python -m pip install --upgrade pip
+ pip install ruff
+ - name: Run Ruff Format
+ run: |
+ ruff check --output-format=github .
diff --git a/.github/workflows/test-database-migrations.yml b/.github/workflows/test-database-migrations.yml
new file mode 100644
index 000000000..ecab8d487
--- /dev/null
+++ b/.github/workflows/test-database-migrations.yml
@@ -0,0 +1,103 @@
+name: Test Database Migrations
+
+on:
+ push:
+ paths:
+ - "**.py"
+ pull_request:
+ paths:
+ - "**.py"
+
+jobs:
+ test-databases:
+ runs-on: ubuntu-latest
+ services:
+ postgres:
+ image: postgres:latest
+ env:
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: testdb
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd="pg_isready"
+ --health-interval=10s
+ --health-timeout=5s
+ --health-retries=5
+ timescaledb:
+ image: timescale/timescaledb:latest-pg17
+ env:
+ POSTGRES_PASSWORD: timescale
+ POSTGRES_DB: testdb
+ ports:
+ - 5433:5432
+ options: >-
+ --health-cmd="pg_isready"
+ --health-interval=10s
+ --health-timeout=5s
+ --health-retries=5
+ mysql:
+ image: mysql:latest
+ env:
+ MYSQL_ROOT_PASSWORD: root
+ MYSQL_DATABASE: testdb
+ ports:
+ - 3306:3306
+ options: >-
+ --health-cmd="mysqladmin ping -hlocalhost -uroot -proot"
+ --health-interval=10s
+ --health-timeout=5s
+ --health-retries=5
+ mariadb:
+ image: mariadb:10.6
+ env:
+ MYSQL_ROOT_PASSWORD: root
+ MYSQL_DATABASE: testdb
+ MYSQL_USER: testuser
+ MYSQL_PASSWORD: testpassword
+ ports:
+ - 3307:3306
+ options: >-
+ --health-cmd "mysqladmin ping -h localhost -u testuser --password=testpassword"
+ --health-interval=10s
+ --health-timeout=5s
+ --health-retries=5
+
+ strategy:
+ matrix:
+ db: [sqlite, postgres, timescaledb, mysql, mariadb]
+ include:
+ - db: sqlite
+ url: sqlite+aiosqlite:///./test.db
+
+ - db: postgres
+ url: postgresql+asyncpg://postgres:postgres@localhost:5432/testdb
+
+ - db: timescaledb
+ url: postgresql+asyncpg://postgres:timescale@localhost:5433/testdb
+
+ - db: mysql
+ url: mysql+asyncmy://root:root@localhost:3306/testdb
+
+ - db: mariadb
+ url: mysql+asyncmy://testuser:testpassword@localhost:3307/testdb
+
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - name: Set DATABASE_URL
+ run: |
+ echo "TEST_FROM=github" >> $GITHUB_ENV
+ echo "SQLALCHEMY_DATABASE_URL=${{ matrix.url }}" >> $GITHUB_ENV
+
+ - name: Install dependencies
+ run: make setup
+
+ - name: Run Alembic Migrations
+ run: make run-migration
+
+ - name: Run Tests
+ run: make test
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 000000000..c2945ec84
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,170 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+*.sqlite3
+*.sqlite3-journal
+*.db
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+# For a library or package, you might want to ignore these files since the code is
+# intended to run in multiple environments; otherwise, check them in:
+.python-version
+
+# pipenv
+# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+# However, in case of collaboration, if having platform-specific dependencies or dependencies
+# having no cross-platform support, pipenv may install dependencies that don't work, or not
+# install all needed dependencies.
+#Pipfile.lock
+
+# poetry
+# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
+# This is especially recommended for binary packages to ensure reproducibility, and is more
+# commonly ignored for libraries.
+# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
+#poetry.lock
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.venv
+venv/
+venv.bak/
+.vscode/
+.cursor/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+# PyCharm
+# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
+# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
+# and can be added to the global gitignore or merged into this file. For a more nuclear
+# option (not recommended) you can uncomment the following to ignore the entire idea folder.
+.idea/
+
+.env
+v2ray-core
+xray-core
+
+# don't track custom/dev.json/yaml/yml configs, such as xray-custom.json or clash-dev.yaml
+*-custom.json
+*-custom.yaml
+*-custom.yml
+*-dev.json
+*-dev.yml
+*-dev.yaml
+*-debug.*
+*-test.*
+
+*Zone.Identifier
+.DS_Store
+
+#ignore node-module for dashboard
+node_modules/
+deadlock-analysis.md
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 000000000..e08f0f67d
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,150 @@
+# Contribute to PasarGuard
+
+Thanks for considering contributing to **PasarGuard**!
+
+## 🙋 Questions
+
+Please **don’t use GitHub Issues** to ask questions. Instead, use one of the following platforms:
+
+- 💬 Telegram: [@Pasar_Guard](https://t.me/pasar_guard)
+- 🗣️ GitHub Discussions: [PasarGuard Discussions](https://github.com/pasarguard/panel/discussions)
+
+## 🐞 Reporting Issues
+
+When reporting a bug or issue, please include:
+
+- ✅ What you expected to happen
+- ❌ What actually happened (include server logs or browser errors)
+- ⚙️ Your `xray` JSON config and `.env` settings (censor sensitive info)
+- 🔢 Your PasarGuard version and Docker version (if applicable)
+
+---
+
+# 🚀 Submitting a Pull Request
+
+If there's no open issue for your idea, consider opening one for discussion **before submitting a PR**.
+
+You can contribute to any issue that:
+
+- Has no PR linked
+- Has no maintainer assigned
+
+No need to ask for permission!
+
+## 🔀 Branching Strategy
+
+- Always branch off of the `next` branch
+- Keep `main` stable and production-ready
+
+---
+
+# 📁 Project Structure
+
+```text
+.
+├── app # Backend code (FastAPI - Python)
+├── cli # CLI code (Typer - Python)
+├── tui # TUI code (Textual - Python)
+├── dashboard # Frontend code (React - TypeScript)
+└── tests # API tests
+```
+
+---
+
+## 🧠 Backend (FastAPI)
+
+The backend is built with **FastAPI** and **SQLAlchemy**:
+
+- **Pydantic models**: `app/models`
+- **Database models & operations**: `app/db`
+- **backend logic should go in**: `app/operations`
+- **Migrations (Alembic)**: `app/db/migrations`
+
+🧩 **Note**: Ensure **all backend logic is organized and implemented in the `operations` module**. This keeps route handling, database access, and service logic clearly separated and easier to maintain.
+
+### 📘 API Docs (Swagger / ReDoc)
+
+Enable the `DOCS` flag in your `.env` file to access:
+
+- Swagger UI: [http://localhost:8000/docs](http://localhost:8000/docs)
+- ReDoc: [http://localhost:8000/redoc](http://localhost:8000/redoc)
+
+### 🎯 Code Formatting
+
+Format and lint code with:
+
+```bash
+make check
+make format
+```
+
+### 🗃️ Database Migrations
+
+To apply Alembic migrations to your database, run:
+
+```bash
+make run-migration
+```
+
+---
+
+## 💻 Frontend (React + Tailwind)
+
+> ⚠️ **We no longer upload pre-built frontend files.**
+
+The frontend is located in the `dashboard` directory and is built using:
+
+- **React + TypeScript**
+- **Tailwind CSS (Shadcn UI)**
+
+To build:
+
+```bash
+bun install
+```
+
+Remove the `dashboard/build` directory and restart the Python backend — the frontend will auto-rebuild (except in debug mode).
+
+### 🧩 Component Guidelines
+
+- Follow **Tailwind + Shadcn** best practices
+- Keep components **single-purpose**
+- Prioritize **readability** and **maintainability**
+
+---
+
+## 🛠️ PasarGuard CLI
+
+PasarGuard’s CLI is built using [Typer](https://typer.tiangolo.com/).
+
+- CLI codebase: `cli/`
+
+---
+
+## 🛠️ PasarGuard TUI
+
+PasarGuard’s TUI is built using [Textual](https://textual.textualize.io/).
+
+- TUI codebase: `tui/`
+
+---
+
+## 🐛 Debug Mode
+
+To run the project in debug mode with auto-reload, you can set the environment variable DEBUG to true. then by running the main.py, the backend and frontend will run separately on different ports.
+
+Note that you must first install the necessary npm packages by running npm install inside the dashboard directory before running in debug mode.
+
+Install frontend dependencies:
+
+```bash
+make install-front
+```
+
+Run the backend (`main.py`)
+
+> ⚠️ In debug mode, the frontend will **not rebuild automatically** if you delete `dashboard/build`.
+
+---
+
+Feel free to reach out via [Telegram](https://t.me/pasar_guard) or GitHub Discussions if you have any questions. Happy contributing! 🚀
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 000000000..62defc9a6
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,39 @@
+ARG PYTHON_VERSION=3.12
+
+FROM ghcr.io/astral-sh/uv:python$PYTHON_VERSION-bookworm-slim AS builder
+ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ gcc \
+ python3-dev \
+ libc6-dev \
+ && rm -rf /var/lib/apt/lists/*
+
+ENV UV_PYTHON_DOWNLOADS=0
+
+WORKDIR /build
+RUN --mount=type=cache,target=/root/.cache/uv \
+ --mount=type=bind,source=uv.lock,target=uv.lock \
+ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
+ uv sync --frozen --no-install-project --no-dev
+ADD . /build
+RUN --mount=type=cache,target=/root/.cache/uv \
+ uv sync --frozen --no-dev
+
+
+FROM python:$PYTHON_VERSION-slim-bookworm
+
+COPY --from=builder /build /code
+WORKDIR /code
+
+ENV PATH="/code/.venv/bin:$PATH"
+
+COPY cli_wrapper.sh /usr/bin/pasarguard-cli
+RUN chmod +x /usr/bin/pasarguard-cli
+
+COPY tui_wrapper.sh /usr/bin/pasarguard-tui
+RUN chmod +x /usr/bin/pasarguard-tui
+
+RUN chmod +x /code/start.sh
+
+ENTRYPOINT ["/code/start.sh"]
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 000000000..425d0879d
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 21 January 2023
+
+ Copyright (C) 2023 Gozargah organization.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+.
diff --git a/Makefile b/Makefile
new file mode 100644
index 000000000..d5c913da9
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,142 @@
+# Makefile to check and set up Python 3.12 and a virtual environment
+
+PYTHON_VERSION=3.12
+VENV_DIR=.venv
+
+# Check if Python 3.12 is installed, if not, install it
+.PHONY: check-python
+check-python:
+ @if ! python${PYTHON_VERSION} --version | grep -q "$(PYTHON_VERSION)"; then \
+ echo "Python $(PYTHON_VERSION) is not installed. Installing..."; \
+ sudo add-apt-repository -y ppa:deadsnakes/ppa && sudo apt update && sudo apt install -y python$(PYTHON_VERSION) python$(PYTHON_VERSION)-venv || { \
+ echo "Failed to install Python $(PYTHON_VERSION). Please install it manually."; \
+ exit 1; \
+ }; \
+ else \
+ echo "Python $(PYTHON_VERSION) is installed."; \
+ fi
+
+.PHONY: install_uv
+install_uv:
+ @if ! uv --help >/dev/null 2>&1; then \
+ echo "uv not found. Installing..."; \
+ curl -LsSf https://astral.sh/uv/install.sh | sh; \
+ echo "uv installed. Ensure ~/.cargo/bin is in your PATH."; \
+ else \
+ echo "uv is already installed."; \
+ fi
+
+# Install Python dependencies from pyproject.toml
+.PHONY: requirements
+requirements:
+ @uv sync
+
+# Check if nvm is installed, if not, install it
+.PHONY: check-nvm
+check-nvm:
+ @if ! command -v nvm > /dev/null 2>&1; then \
+ echo "nvm not found. Installing..."; \
+ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash || { \
+ echo "Failed to install nvm. Please install it manually."; \
+ exit 1; \
+ }; \
+ export NVM_DIR="$$HOME/.nvm"; \
+ [ -s "$$NVM_DIR/nvm.sh" ] && . "$$NVM_DIR/nvm.sh"; \
+ [ -s "$$NVM_DIR/bash_completion" ] && . "$$NVM_DIR/bash_completion"; \
+ echo "nvm installed. Version: `nvm --version`"; \
+ else \
+ echo "nvm is already installed. Version: `nvm --version`"; \
+ fi
+
+# Check if nodejs is installed, if not, install it
+.PHONY: check-nodejs
+check-nodejs: check-nvm
+ @if ! node -v > /dev/null 2>&1; then \
+ echo "nodejs not found. Installing..."; \
+ nvm install 22 || { \
+ echo "Failed to install nodejs. Please install it manually."; \
+ exit 1; \
+ }; \
+ else \
+ echo "nodejs is already installed."; \
+ fi
+
+# Check if bun is installed, if not, install it
+.PHONY: check-bun
+check-bun: check-nodejs
+ @if ! bun --version > /dev/null 2>&1; then \
+ echo "bun not found. Installing..."; \
+ curl -fsSL https://bun.sh/install | bash || { \
+ echo "Failed to install bun. Please install it manually."; \
+ exit 1; \
+ }; \
+ else \
+ echo "bun is already installed."; \
+ fi
+
+# Install frontend dependencies (Node.js packages)
+.PHONY: install-front
+install-front: check-bun
+ @cd dashboard && bun install
+
+# Run database migrations using Alembic
+.PHONY: run-migration
+run-migration:
+ @uv run alembic upgrade head
+
+# run PasarGuard
+.PHONY: run
+run:
+ @uv run main.py
+
+# run pasarguard-cli
+.PHONY: run-cli
+run-cli:
+ @uv run pasarguard-cli.py
+
+# run pasarguard-tui
+.PHONY: run-tui
+run-tui:
+ @uv run pasarguard-tui.py
+
+
+# Run tests
+.PHONY: test
+test:
+ @uv run pytest tests/
+
+# Run tests-watch
+.PHONY: test-whatch
+test-whatch:
+ @uv run ptw
+
+# Run PasarGuard with watchfiles
+.PHONY: run-watch
+run-watch:
+ @echo "Running application with watchfiles..."
+ @uv run watchfiles --filter python "uv run main.py" .
+
+# Check code
+.PHONY: check
+check:
+ @uv run ruff check .
+
+# Format code
+.PHONY: format
+format:
+ @uv run ruff format .
+
+# Clean the environment
+.PHONY: clean
+clean:
+ @rm -rf $(VENV_DIR)
+ @echo "Virtual environment removed."
+
+# Setup environment: check Python, install uv, and sync requirements
+.PHONY: setup
+setup: check-python install_uv requirements
+
+# Format code (front-end)
+.PHONY: fformat
+fformat:
+ @cd dashboard && bun run prettier . --write
diff --git a/PasarGuard.code-workspace b/PasarGuard.code-workspace
new file mode 100644
index 000000000..dbcebcc9b
--- /dev/null
+++ b/PasarGuard.code-workspace
@@ -0,0 +1,113 @@
+{
+ "remote.extensionKind": {
+ "GitHub.copilot": [
+ "ui"
+ ],
+ "GitHub.copilot-chat": [
+ "ui"
+ ]
+ },
+ "folders": [
+ {
+ "path": "."
+ }
+ ],
+ "settings": {
+ "python.analysis.inlayHints.variableTypes": true,
+ "python.analysis.autoImportCompletions": true,
+ "python.analysis.completeFunctionParens": true,
+ "python.analysis.inlayHints.pytestParameters": true,
+ "python.analysis.inlayHints.callArgumentNames": "all",
+ "python.analysis.inlayHints.functionReturnTypes": true,
+ "ruff.enable": true,
+ "ruff.format.preview": true,
+ "ruff.fixAll": true,
+ "ruff.organizeImports": true,
+ "ruff.showSyntaxErrors": true,
+ "[python]": {
+ "editor.defaultFormatter": "charliermarsh.ruff",
+ "editor.formatOnSave": true,
+ "editor.codeActionsOnSave": {
+ "source.fixAll.ruff": "explicit",
+ "source.organizeImports.ruff": "explicit"
+ }
+ },
+ "editor.codeActionsOnSave": {
+ "source.organizeImports": "never"
+ },
+ "remote.localPortHost": "allInterfaces",
+ "[javascript]": {
+ "javascript.autoClosingTags": true,
+ "editor.codeActionsOnSave": {
+ "source.fixAll": "never",
+ "source.fixAll.eslint": "explicit",
+ "source.organizeImports": "explicit"
+ },
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
+ "[javascriptreact]": {
+ "editor.codeActionsOnSave": {
+ "source.fixAll": "never",
+ "source.fixAll.eslint": "explicit",
+ "source.organizeImports": "explicit"
+ },
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
+ "[typescript]": {
+ "typescript.autoClosingTags": true,
+ "typescript.suggest.enabled": true,
+ "editor.codeActionsOnSave": {
+ "source.fixAll": "never",
+ "source.fixAll.eslint": "explicit",
+ "source.organizeImports": "explicit"
+ },
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
+ "[typescriptreact]": {
+ "editor.codeActionsOnSave": {
+ "source.fixAll": "never",
+ "source.fixAll.eslint": "explicit",
+ "source.organizeImports": "explicit"
+ },
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
+ "emmet.includeLanguages": {
+ "javascript": "javascriptreact"
+ },
+ "css.enabledLanguages": [
+ "html",
+ "jsx"
+ ],
+ "files.autoSave": "afterDelay",
+ "python.testing.pytestArgs": [
+ "."
+ ],
+ "python.testing.unittestEnabled": false,
+ "python.testing.pytestEnabled": true
+ },
+ "launch": {
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "Python Debugger: Current File",
+ "type": "debugpy",
+ "request": "launch",
+ "program": "main.py",
+ "console": "integratedTerminal"
+ }
+ ],
+ "compounds": []
+ },
+ "extensions": {
+ "recommendations": [
+ "donjayamanne.python-extension-pack",
+ "ms-vscode.vscode-typescript-next",
+ "yoavbls.pretty-ts-errors",
+ "dbaeumer.vscode-eslint",
+ "esbenp.prettier-vscode",
+ "charliermarsh.ruff",
+ "tamasfe.even-better-toml",
+ "Bar.python-import-helper"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/README-fa.md b/README-fa.md
new file mode 100644
index 000000000..93de7b4f2
--- /dev/null
+++ b/README-fa.md
@@ -0,0 +1,449 @@
+
+
+## فهرست مطالب
+
+- [بررسی اجمالی](#بررسی-اجمالی)
+ - [چرا پاسارگارد؟](#چرا-پاسارگارد)
+ - [امکانات](#امکانات)
+- [راهنمای نصب](#راهنمای-نصب)
+- [تنظیمات](#تنظیمات)
+- [داکیومنت](#داکیومنت)
+- [استفاده از API](#استفاده-از-api)
+- [پشتیبان گیری از پاسارگارد](#پشتیبان-گیری-از-پاسارگارد)
+- [ربات تلگرام](#ربات-تلگرام)
+- [رابط خط فرمان (CLI) پاسارگارد](#رابط-خط-فرمان-cli-پاسارگارد)
+- [نود](#نود)
+- [ارسال اعلانها به آدرس وبهوک](#ارسال-اعلانها-به-آدرس-وبهوک)
+- [کمک مالی](#کمک-مالی)
+- [لایسنس](#لایسنس)
+- [مشارکت در توسعه](#مشارکت-در-توسعه)
+
+# بررسی اجمالی
+
+پاسارگارد یک نرم افزار (وب اپلیکیشن) مدیریت پروکسی است که امکان مدیریت چند صد حساب پروکسی را با قدرت و دسترسی بالا فراهم میکند. پاسارگارد از [Xray-core](https://github.com/XTLS/Xray-core) قدرت گرفته و با Python و React پیاده سازی شده است.
+
+## چرا پاسارگارد؟
+
+پاسارگارد دارای یک رابط کاربری ساده است که قابلیت های زیادی دارد. پاسارگارد امکان ایجاد چند نوع پروکسی برای کاربر ها را فراهم میکند بدون اینکه به تنظیمات پیچیده ای نیاز داشته باشید. به کمک رابط کاربری تحت وب پاسارگارد، شما میتوانید کاربران را مانیتور، ویرایش و در صورت نیاز، محدود کنید.
+
+### امکانات
+
+- **رابط کاربری تحت وب** آماده
+- به صورت **REST API** پیاده سازی شده
+- پشتیبانی از پروتکل های **Vmess**, **VLESS**, **Trojan** و **Shadowsocks**
+- امکان فعالسازی **چندین پروتکل** برای هر یوزر
+- امکان ساخت **چندین کاربر** بر روی یک inbound
+- پشتیبانی از **چندین inbound** بر روی **یک port** (به کمک fallbacks)
+- محدودیت بر اساس مصرف **ترافیک** و **تاریخ انقضا**
+- محدودیت **ترافیک دوره ای** (به عنوان مثال روزانه، هفتگی و غیره)
+- پشتیبانی از **Subscription link** سازگار با **V2ray** _(مثل نرم افزار های V2RayNG, SingBox, Nekoray و...)_ و **Clash**
+- ساخت **لینک اشتراک گذاری** و **QRcode** به صورت خودکار
+- مانیتورینگ منابع سرور و **مصرف ترافیک**
+- پشتیبانی از تنظیمات xray
+- پشتیبانی از **TLS**
+- **ربات تلگرام**
+- **رابط خط فرمان (CLI)** داخلی
+- قابلیت ایجاد **چندین مدیر** (تکمیل نشده است)
+
+# راهنمای نصب
+
+### ⚠️ دستورات زیر نسخه های پیش از انتشار (آلفا/بتا) را نصب می کنند
+
+با دستور زیر پاسارگارد را با دیتابیس SQLite نصب کنید:
+
+```bash
+sudo bash -c "$(curl -sL https://github.com/PasarGuard/scripts/raw/main/pasarguard.sh)" @ install --pre-release
+```
+
+با دستور زیر پاسارگارد را با دیتابیس MySQL نصب کنید:
+
+```bash
+sudo bash -c "$(curl -sL https://github.com/PasarGuard/scripts/raw/main/pasarguard.sh)" @ install --database mysql --pre-release
+```
+
+با دستور زیر پاسارگارد را با دیتابیس MariaDB نصب کنید:
+
+```bash
+sudo bash -c "$(curl -sL https://github.com/PasarGuard/scripts/raw/main/pasarguard.sh)" @ install --database mariadb --pre-release
+```
+
+با دستور زیر پاسارگارد را با دیتابیس PostgreSQL نصب کنید:
+
+```bash
+sudo bash -c "$(curl -sL https://github.com/PasarGuard/scripts/raw/main/pasarguard.sh)" @ install --database postgresql --pre-release
+```
+
+وقتی نصب تمام شد:
+
+- شما لاگ های پاسارگارد رو مشاهده میکنید که میتوانید با بستن ترمینال یا فشار دادن `Ctrl+C` از آن خارج شوید
+- فایل های پاسارگارد در پوشه `/opt/pasarguard` قرار میگیرند
+- فایل تنظیمات در مسیر `/opt/pasarguard/.env` قرار میگیرد ([تنظیمات](#تنظیمات) را مشاهده کنید)
+- فایل های مهم (اطلاعات) پاسارگارد در مسیر `/var/lib/pasarguard` قرار میگیرند
+ به دلایل امنیتی، داشبورد پاسارگارد از طریق آیپی قابل دسترسی نیست. بنابراین، باید برای دامنه خود [گواهی SSL](https://pasarguard.github.io/PasarGuard/fa/examples/issue-ssl-certificate) بگیرید و از طریق آدرس https://YOUR_DOMAIN:8000/dashboard/ وارد داشبورد پاسارگارد شوید (نام دامنه خود را جایگزین YOUR_DOMAIN کنید)
+- همچنین میتوانید از فوروارد کردن پورت SSH برای دسترسی لوکال به داشبورد پاسارگارد بدون دامنه استفاده کنید. نام کاربری و آیپی سرور خود را جایگزین `user@serverip` کنید و دستور زیر را اجرا کنید:
+
+```bash
+ssh -L 8000:localhost:8000 user@serverip
+```
+
+در نهایت، میتوانید لینک زیر را در مرورگر خود وارد کنید تا به داشبورد پاسارگارد دسترسی پیدا کنید:
+
+http://localhost:8000/dashboard/
+
+به محض بستن ترمینال SSH، دسترسی شما به داشبورد قطع خواهد شد. بنابراین، این روش تنها برای تست کردن توصیه میشود.
+
+در مرحله بعد, باید یک ادمین سودو بسازید
+
+```bash
+pasarguard cli admin create --sudo
+```
+
+تمام! حالا با این اطلاعات میتوانید وارد پاسارگارد شوید
+
+برای مشاهده راهنمای اسکریپت پاسارگارد دستور زیر را اجرا کنید
+
+```bash
+pasarguard --help
+```
+
+اگر مشتاق هستید که پاسارگارد رو با پایتون و به صورت دستی اجرا کنید، مراحل زیر را مشاهده کنید
+
+
+
نصب به صورت دستی (پیچیده)
+
+لطفا xray را نصب کنید.
+شما میتواند به کمک [Xray-install](https://github.com/XTLS/Xray-install) این کار را انجام دهید.
+
+```bash
+bash -c "$(curl -L https://github.com/XTLS/Xray-install/raw/main/install-release.sh)" @ install
+```
+
+پروژه را clone کنید و dependency ها را نصب کنید. دقت کنید که نسخه پایتون شما Python>=3.12.7 باشد.
+
+```bash
+git clone https://github.com/PasarGuard/panel.git
+cd PasarGuard
+curl -LsSf https://astral.sh/uv/install.sh | sh
+uv sync
+```
+
+همچنین میتواند از [Python Virtualenv](https://pypi.org/project/virtualenv/) هم استفاده کنید.
+
+سپس کامند زیر را اجرا کنید تا دیتابیس تنظیم شود.
+
+```bash
+uv run alembic upgrade head
+```
+
+اگر می خواهید از `PasarGuard-cli` استفاده کنید، باید آن را به یک فایل در `$PATH` خود لینک و قابل اجرا (executable) کنید. سپس تکمیل خودکار (auto-completion) آن را نصب کنید:
+
+```bash
+sudo ln -s $(pwd)/PasarGuard-cli.py /usr/bin/pasarguard-cli
+sudo chmod +x /usr/bin/pasarguard-cli
+pasarguard-cli completion install
+```
+
+حالا یک کپی از `.env.example` با نام `.env` بسازید و با یک ادیتور آن را باز کنید و تنظیمات دلخواه خود را انجام دهید. یه عنوان مثال نام کاربری و رمز عبور را می توانید در این فایل تغییر دهید.
+
+```bash
+cp .env.example .env
+nano .env
+```
+
+> برای اطلاعات بیشتر بخش [تنظیمات](#تنظیمات) را مطالعه کنید.
+
+در انتها, پاسارگارد را به کمک دستور زیر اجرا کنید.
+
+```bash
+uv run main.py
+```
+
+اجرا با استفاده از systemctl در لینوکس
+
+```
+systemctl enable /var/lib/pasarguard/PasarGuard.service
+systemctl start PasarGuard
+```
+
+اجرا با nginx
+
+```
+server {
+ listen 443 ssl http2;
+ listen [::]:443 ssl http2;
+ server_name example.com;
+
+ ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
+
+ location ~* /(dashboard|statics|sub|api|docs|redoc|openapi.json) {
+ proxy_pass http://0.0.0.0:8000;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ }
+}
+```
+
+or
+
+```
+server {
+ listen 443 ssl http2;
+ listen [::]:443 ssl http2;
+ server_name PasarGuard.example.com;
+
+ ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
+
+ location / {
+ proxy_pass http://0.0.0.0:8000;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ }
+}
+```
+
+به صورت پیشفرض پاسارگارد در آدرس `http://localhost:8000/dashboard` اجرا میشود. شما میتوانید با تغییر `UVICORN_HOST` و `UVICORN_PORT`، هاست و پورت را تغییر دهید.
+
+
+
+# تنظیمات
+
+> متغیر های زیر در فایل `env` یا `.env` استفاده میشوند. شما می توانید با تعریف و تغییر آن ها، تنظیمات پاسارگارد را تغییر دهید.
+
+| توضیحات | متغیر |
+| -------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------: |
+| نام کاربری مدیر کل | SUDO_USERNAME |
+| رمز عبور مدیر کل | SUDO_PASSWORD |
+| آدرس دیتابیس ([بر اساس مستندات SQLAlchemy](https://docs.sqlalchemy.org/en/20/core/engines.html#database-urls)) | SQLALCHEMY_DATABASE_URL |
+| (پیشفرض: `10`) | SQLALCHEMY_POOL_SIZE |
+| (پیشفرض: `30`) | SQLALCHEMY_MAX_OVERFLOW |
+| آدرس هاستی که پاسارگارد روی آن اجرا میشود (پیشفرض: `0.0.0.0`) | UVICORN_HOST |
+| پورتی که پاسارگارد روی آن اجرا میشود (پیشفرض: `8000`) | UVICORN_PORT |
+| اجرای پاسارگارد بر روی یک Unix domain socket | UVICORN_UDS |
+| آدرس گواهی SSL به جهت ایمن کردن پنل پاسارگارد | UVICORN_SSL_CERTFILE |
+| آدرس کلید گواهی SSL | UVICORN_SSL_KEYFILE |
+| نوع گواهینامه مرجع SSL. از «خصوصی» برای آزمایش CA با امضای خود استفاده کنید (پیشفرض: `public`) | UVICORN_SSL_CA_TYPE |
+| مسیر فایل json تنظیمات xray (پیشفرض: `xray_config.json`) | XRAY_JSON |
+| مسیر باینری xray (پیشفرض: `/usr/local/bin/xray`) | XRAY_EXECUTABLE_PATH |
+| مسیر asset های xray (پیشفرض: `/usr/local/share/xray`) | XRAY_ASSETS_PATH |
+| پیشوند (یا هاست) آدرس های اشتراکی (زمانی کاربرد دارد که نیاز دارید دامنه subscription link ها با دامنه پنل متفاوت باشد) | XRAY_SUBSCRIPTION_URL_PREFIX |
+| تگ inboundای که به عنوان fallback استفاده میشود. | XRAY_FALLBACKS_INBOUND_TAG |
+| تگ های inbound ای که لازم نیست در کانفیگ های ساخته شده وجود داشته باشند. | XRAY_EXCLUDE_INBOUND_TAGS |
+| آدرس محل template های شخصی سازی شده کاربر | CUSTOM_TEMPLATES_DIRECTORY |
+| تمپلیت مورد استفاده برای تولید کانفیگ های Clash (پیشفرض: `clash/default.yml`) | CLASH_SUBSCRIPTION_TEMPLATE |
+| تمپلیت صفحه اطلاعات اشتراک کاربر (پیشفرض `subscription/index.html`) | SUBSCRIPTION_PAGE_TEMPLATE |
+| تمپلیت مورد استفاده برای تولید کانفیگ های xray (پیشفرض: `xray/default.yml`) | XRAY_SUBSCRIPTION_TEMPLATE |
+| تمپلیت مورد استفاده برای تولید کانفیگ های singbox (پیشفرض: `singbox/default.yml`) | SINGBOX_SUBSCRIPTION_TEMPLATE |
+| تمپلیت صفحه اول (پیشفرض: `home/index.html`) | HOME_PAGE_TEMPLATE |
+| توکن ربات تلگرام (دریافت از [@botfather](https://t.me/botfather)) | TELEGRAM_API_TOKEN |
+| آیدی عددی ادمین در تلگرام (دریافت از [@userinfobot](https://t.me/userinfobot)) | TELEGRAM_ADMIN_ID |
+| اجرای ربات از طریق پروکسی | TELEGRAM_PROXY_URL |
+| مدت زمان انقضا توکن دسترسی به پنل پاسارگارد, `0` به معنای بدون تاریخ انقضا است (پیشفرض: `1440`) | JWT_ACCESS_TOKEN_EXPIRE_MINUTES |
+| فعال سازی داکیومنتیشن به آدرس `/docs` و `/redoc`(پیشفرض: `False`) | DOCS |
+| فعالسازی حالت توسعه (development) (پیشفرض: `False`) | DEBUG |
+| آدرس webhook که تغییرات حالت یک کاربر به آن ارسال میشوند. اگر این متغیر مقدار داشته باشد، ارسال پیامها انجام میشوند. | WEBHOOK_ADDRESS |
+| متغیری که به عنوان `x-webhook-secret` در header ارسال میشود. (پیشفرض: `None`) | WEBHOOK_SECRET |
+| تعداد دفعاتی که برای ارسال یک پیام، در صورت تشخیص خطا در ارسال تلاش دوباره شود (پیشفرض `3`) | NUMBER_OF_RECURRENT_NOTIFICATIONS |
+| مدت زمان بین هر ارسال دوباره پیام در صورت تشخیص خطا در ارسال به ثانیه (پیشفرض: `180`) | RECURRENT_NOTIFICATIONS_TIMEOUT |
+| هنگام رسیدن مصرف کاربر به چه درصدی پیام اخطار به آدرس وبهوک ارسال شود (پیشفرض: `80`) | NOTIFY_REACHED_USAGE_PERCENT |
+| چند روز مانده به انتهای سرویس پیام اخطار به آدرس وبهوک ارسال شود (پیشفرض: `3`) | NOTIFY_DAYS_LEFT |
+| حذف خودکار کاربران منقضی شده (و بطور اختیاری محدود شده) پس از گذشت این تعداد روز (مقادیر منفی این قابلیت را به طور پیشفرض غیرفعال می کنند. پیشفرض: `-1`) | USERS_AUTODELETE_DAYS |
+| تعیین اینکه کاربران محدودشده شامل حذف خودکار بشوند یا نه | USER_AUTODELETE_INCLUDE_LIMITED_ACCOUNTS |
+| شما می توانید مسیر api خود را برای اشتراک تغییر دهید | XRAY_SUBSCRIPTION_PATH |
+| با توجه به حجم بالای داده، این کار فقط برای postgresql و timescaledb در دسترس است | ENABLE_RECORDING_NODES_STATS |
+| فعال کردن کانفیگ سفارشی JSON برای همه برنامههایی که از آن پشتیبانی میکنند (پیشفرض: `False`) | USE_CUSTOM_JSON_DEFAULT |
+| فعال کردن کانفیگ سفارشی JSON فقط برای برنامهی V2rayNG (پیشفرض: `False`) | USE_CUSTOM_JSON_FOR_V2RAYNG |
+| فعال کردن کانفیگ سفارشی JSON فقط برای برنامهی Streisand (پیشفرض: `False`) | USE_CUSTOM_JSON_FOR_STREISAND |
+| فعال کردن کانفیگ سفارشی JSON فقط برای برنامهی V2rayN (پیشفرض: `False`) | USE_CUSTOM_JSON_FOR_V2RAYN |
+
+# داکیومنت
+
+[داکیومنت پاسارگارد](https://pasarguard.github.io/PasarGuard) تمامی آموزشهای ضروری برای شروع را فراهم میکند و در سه زبان فارسی، انگلیسی و روسی در دسترس است. این داکیومنت نیاز به تلاش زیادی دارد تا تمامی جنبههای پروژه را به طور کامل پوشش دهد. ما از کمک و همکاری شما برای بهبود آن استقبال و قدردانی میکنیم. میتوانید در این صفحه [گیتهاب](https://github.com/Gozargah/pasarguard.github.io) مشارکت کنید.
+
+# استفاده از API
+
+پاسارگارد به توسعه دهندگانAPI REST ارائه می دهد. برای مشاهده اسناد API در قالب Swagger UI یا ReDoc، متغیر `DOCS=True` را در تنظیمات خود ست کنید و در مرورگر به مسیر `/docs` و `/redoc` بروید.
+
+# پشتیبان گیری از پاسارگارد
+
+بهتر است همیشه از فایل های پاسارگارد خود نسخه پشتیبان تهیه کنید تا در صورت خرابی سیستم یا حذف تصادفی اطلاعات از دست نروند. مراحل تهیه نسخه پشتیبان از پاسارگارد به شرح زیر است:
+
+1. به طور پیش فرض، تمام فایل های مهم پاسارگارد در `/var/lib/pasarguard` ذخیره می شوند (در نسخه داکر). کل پوشه `/var/lib/pasarguard` را در یک مکان پشتیبان مورد نظر خود، مانند هارد دیسک خارجی یا فضای ذخیره سازی ابری کپی کنید.
+2. علاوه بر این، مطمئن شوید که از فایل env خود که حاوی متغیرهای تنظیمات شما است و همچنین فایل پیکربندی Xray خود نسخه پشتیبان تهیه کنید.
+
+خدمات پشتیبانگیری پاسارگارد به طور کارآمد تمام فایلهای ضروری را فشرده کرده و آنها را به ربات تلگرام مشخص شده شما ارسال میکند. این خدمات از پایگاههای داده SQLite، MySQL و MariaDB پشتیبانی میکند. یکی از ویژگیهای اصلی آن، خودکار بودن است که به شما اجازه میدهد تا پشتیبانگیریها را هر ساعت برنامهریزی کنید. محدودیتی در مورد محدودیتهای آپلود تلگرام برای رباتها وجود ندارد؛ اگر فایل شما بزرگتر از میزان محدودیت تلگرام باشد، به دو یا چند بخش تقسیم شده و ارسال میشود. علاوه بر این، شما میتوانید در هر زمان پشتیبانگیری فوری انجام دهید.
+
+نصب آخرین ورژن پاسارگارد کامند:
+
+```bash
+sudo bash -c "$(curl -sL https://github.com/PasarGuard/scripts/raw/main/pasarguard.sh)" @ install-script
+```
+
+راهاندازی سرویس پشتیبان گیری:
+
+```bash
+pasarguard backup-service
+```
+
+پشتیبان گیری فوری:
+
+```bash
+pasarguard backup
+```
+
+با انجام این مراحل، می توانید اطمینان حاصل کنید که از تمام فایل ها و داده های پاسارگارد خود یک نسخه پشتیبان تهیه کرده اید. به خاطر داشته باشید که نسخه های پشتیبان خود را به طور مرتب به روز کنید تا آنها را به روز نگه دارید.
+
+# ربات تلگرام
+
+پاسارگارد دارای یک ربات تلگرام داخلی است که می تواند مدیریت سرور، ایجاد و حذف کاربر و ارسال نوتیفیکیشن را انجام دهد. این ربات را می توان با انجام چند مرحله ساده به راحتی فعال کرد
+
+برای فعال کردن ربات تلگرام:
+
+1. در تنظیمات، متغیر`TELEGRAM_API_TOKEN` را به API TOKEN ربات تلگرام خود تنظیم کنید.
+2. همینطور، متغیر`TELEGRAM_ADMIN_ID` را به شناسه عددی حساب تلگرام خود تنظیم کنید. شما میتوانید شناسه خود را از [@userinfobot](https://t.me/userinfobot) دریافت کنید.
+
+# رابط خط فرمان (CLI) پاسارگارد
+
+پاسارگارد دارای یک رابط خط فرمان (CLI) داخلی به نام `PasarGuard-cli` است که به مدیران اجازه می دهد با آن ارتباط مستقیم داشته باشند.
+
+اگر پاسارگارد را با استفاده از اسکریپت نصب آسان نصب کرده اید، می توانید با اجرای دستور زیر به دستورات cli دسترسی پیدا کنید
+
+```bash
+pasarguard cli [OPTIONS] COMMAND [ARGS]...
+```
+
+برای اطلاعات بیشتر، می توانید [مستندات CLI پاسارگارد](./cli/README.md) را مطالعه کنید.
+
+# رابط کاربری متنی (TUI) پاسارگارد
+
+پاسارگارد همچنین یک رابط کاربری متنی (TUI) برای مدیریت تعاملی مستقیم در ترمینال شما فراهم می کند.
+
+اگر پاسارگارد را با استفاده از اسکریپت نصب آسان نصب کرده اید، می توانید با اجرای دستور زیر به TUI دسترسی پیدا کنید:
+
+```bash
+pasarguard tui
+```
+
+برای اطلاعات بیشتر، می توانید [مستندات TUI پاسارگارد](./tui/README.md) را مطالعه کنید.
+
+# نود
+
+پروژه پاسارگارد [نود](https://github.com/PasarGuard/node) را معرفی می کند که توزیع زیرساخت را متحول می کند. با نود، می توانید زیرساخت خود را در چندین مکان توزیع کنید و از مزایایی مانند افزونگی، در دسترس بودن بالا، مقیاس پذیری و انعطاف پذیری بهره مند شوید. نود به کاربران این امکان را می دهد که به سرورهای مختلف متصل شوند و به آنها انعطاف پذیری انتخاب و اتصال به چندین سرور را به جای محدود شدن به تنها یک سرور ارائه می دهد.
+برای اطلاعات دقیق تر و دستورالعمل های نصب، لطفاً به [مستندات رسمی PasarGuard-node](https://github.com/PasarGuard/node) مراجعه کنید.
+
+# ارسال اعلانها به آدرس وبهوک
+
+شما میتوانید آدرسی را برای پاسارگارد فراهم کنید تا تغییرات کاربران را به صورت اعلان برای شما ارسال کند.
+
+اعلانها به صورت یک درخواست POST به آدرسی که در `WEBHOOK_ADDRESS` فراهم شده به همراه مقدار تعیین شده در `WEBHOOK_SECRET` به عنوان `x-webhook-secret` در header درخواست ارسال میشوند.
+
+نمونهای از درخواست ارسال شده توسط پاسارگارد:
+
+```
+Headers:
+Host: 0.0.0.0:9000
+User-Agent: python-requests/2.28.1
+Accept-Encoding: gzip, deflate
+Accept: */*
+Connection: keep-alive
+x-webhook-secret: something-very-very-secret
+Content-Length: 107
+Content-Type: application/json
+
+
+
+Body:
+{"username": "PasarGuard_test_user", "action": "user_updated", "enqueued_at": 1680506457.636369, "tries": 0}
+```
+
+انواع مختلف actionهایی که پاسارگارد ارسال میکند: `user_created`, `user_updated`, `user_deleted`, `user_limited`, `user_expired`, `user_disabled`, `user_enabled`
+
+# کمک مالی
+
+اگر پاسارگارد را برای شما مفید بوده و میخواهید از توسعه آن حمایت کنید، میتوانید کمک مالی کنید، [اینجا کلیک کنید](https://donate.gozargah.pro)
+
+از حمایت شما متشکرم!
+
+# لایسنس
+
+توسعه یافته شده در [ناشناس!] و منتشر شده تحت لایسنس [AGPL-3.0](./LICENSE).
+
+# مشارکت در توسعه
+
+این ❤️🔥 تقدیم به همهی کسایی که در توسعه پاسارگارد مشارکت میکنند! اگر میخواهید مشارکت داشته باشید، لطفاً [دستورالعملهای مشارکت](CONTRIBUTING.md) ما را بررسی کنید و در صورت تمایل Pull Request ارسال کنید یا یک Issue باز کنید. همچنین از شما برای پیوستن به گروه [تلگرام](https://t.me/Pasar_Guard) ما برای حمایت یا کمک به راهنمایی استقبال می کنیم.
+
+لطفا اگر امکانش رو دارید، با بررسی [لیست کار ها](https://github.com/PasarGuard/panel/issues) به ما در بهبود پاسارگارد کمک کنید. کمک های شما با آغوش باز پذیرفته میشه.
+
+
+با تشکر از همه همکارانی که به بهبود پاسارگارد کمک کردند:
+
+
+## Оглавление
+
+- [Введение](#введение)
+ - [Почему PasarGuard?](#почему-PasarGuard)
+ - [Функции](#функции)
+- [Руководство по установке](#руководство-по-установке)
+- [Конфигурация](#конфигурация)
+- [документация](#документация)
+- [API](#api)
+- [Backup](#backup)
+- [Telegram Bot](#telegram-bot)
+- [PasarGuard CLI](#PasarGuard-cli)
+- [PasarGuard Node](#node)
+- [Webhook уведомления](#webhook-уведомления)
+- [Поддержка](#поддержка)
+- [Лицензия](#лицензия)
+- [Участники](#участники)
+
+# Введение
+
+PasarGuard — это инструмент управления прокси-серверами, который предоставляет простой и удобный пользовательский интерфейс для управления сотнями учетных записей прокси на базе [Xray-core](https://github.com/XTLS/Xray-core) и созданный с использованием Python и ReactJS.
+
+## Почему PasarGuard?
+
+PasarGuard удобен в использовании, многофункционален и надежен. Он позволяет создавать различные прокси для пользователей без сложной настройки. С помощью встроенного веб-интерфейса можно контролировать, изменять и ограничивать пользователей.
+
+### Функции
+
+- Готовый **Web UI**
+- **REST API** бэкэнд
+- Поддержка [**множества узлов**](#PasarGuard-node) (для распределения инфраструктуры и масштабируемости)
+- Поддержка протоколов **Vmess**, **VLESS**, **Trojan** и **Shadowsocks**
+- Возможность активации **нескольких протоколов** для каждого пользователя
+- **Несколько пользователей** на одном inbound
+- **Несколько inbound** на **одном порту** (поддержка fallbacks)
+- Ограничения на основе **количества трафика** и **срока действия**
+- Ограничение трафика по **периодам** (например выдавать трафик на день, неделю и т. д.)
+- Поддержка **ссылок-подписок** совместимых с **V2ray** _(такие как V2RayNG, SingBox, Nekoray, и др.)_, **Clash** и **ClashMeta**
+- Автоматическая генерация **Ссылок** и **QRcode**
+- Мониторинг ресурсов сервера и **использования трафика**
+- Настраиваемые конфигурации xray
+- Поддержка **TLS** и **REALITY**
+- Встроенный **Telegram Bot**
+- Встроенный **Command Line Interface (CLI)**
+- **Несколько языков**
+- Поддержка **Нескольких администраторов** (WIP)
+
+# Руководство по установке
+
+### ⚠️ Следующие команды установят предварительные версии (alpha/beta)
+
+Установка PasarGuard с базой данных SQLite (по умолчанию):
+
+```bash
+sudo bash -c "$(curl -sL https://github.com/PasarGuard/scripts/raw/main/pasarguard.sh)" @ install --pre-release
+```
+
+Установка PasarGuard с базой данных MySQL:
+
+```bash
+sudo bash -c "$(curl -sL https://github.com/PasarGuard/scripts/raw/main/pasarguard.sh)" @ install --database mysql --pre-release
+```
+
+Установка PasarGuard с базой данных MariaDB:
+
+```bash
+sudo bash -c "$(curl -sL https://github.com/PasarGuard/scripts/raw/main/pasarguard.sh)" @ install --database mariadb --pre-release
+```
+
+Установка PasarGuard с базой данных PostgreSQL:
+
+```bash
+sudo bash -c "$(curl -sL https://github.com/PasarGuard/scripts/raw/main/pasarguard.sh)" @ install --database postgresql --pre-release
+```
+
+Когда установка будет завершена:
+
+- Вы увидите логи, которые можно остановить, нажав `Ctrl+C` или закрыв терминал.
+- Файлы PasarGuard будут размещены по адресу `/opt/pasarguard`.
+- Файл конфигурации будет размещен по адресу `/opt/pasarguard/.env` (см. [Конфигурация](#конфигурация)).
+- Файлы с данными будут размещены по адресу `/var/lib/pasarguard`.
+- По соображениям безопасности, панель управления PasarGuard недоступна через IP-адрес. Поэтому вам необходимо [получить SSL-сертификат](https://pasarguard.github.io/PasarGuard/ru/examples/issue-ssl-certificate) и получить доступ к панели управления PasarGuard, открыв веб-браузер и перейдя по адресу `https://YOUR_DOMAIN:8000/dashboard/` (замените YOUR_DOMAIN на ваш фактический домен).
+- Вы также можете использовать перенаправление портов SSH для локального доступа к панели управления PasarGuard без домена. Замените `user@serverip` на ваше фактическое имя пользователя SSH и IP-адрес сервера и выполните следующую команду:
+
+```bash
+ssh -L 8000:localhost:8000 user@serverip
+```
+
+Наконец, введите следующую ссылку в ваш браузер, чтобы получить доступ к панели управления PasarGuard:
+
+http://localhost:8000/dashboard/
+
+Вы потеряете доступ к панели управления, как только закроете терминал SSH. Поэтому этот метод рекомендуется использовать только для тестирования.
+
+Далее, Вам нужно создать главного администратора для входа в панель управления PasarGuard, выполнив следующую команду:
+
+```bash
+pasarguard cli admin create --sudo
+```
+
+Готово! Теперь Вы можете войти, используя данные своей учетной записи.
+
+Для того, чтобы увидеть справочное сообщение от скрипта PasarGuard, выполните команду:
+
+```bash
+pasarguard --help
+```
+
+Если Вы хотите запустить проект, используя его исходный код, обратитесь к разделу ниже
+
+
+
Ручная установка
+
+Установите xray на Ваш сервер.
+
+Вы можете сделать это, используя [Xray-install](https://github.com/XTLS/Xray-install):
+
+```bash
+bash -c "$(curl -L https://github.com/XTLS/Xray-install/raw/main/install-release.sh)" @ install
+```
+
+Клонируйте этот проект и установите зависимости (Вам нужен Python >= 3.12.7):
+
+```bash
+git clone https://github.com/PasarGuard/panel.git
+cd PasarGuard
+curl -LsSf https://astral.sh/uv/install.sh | sh
+uv sync
+```
+
+В качестве альтернативы для создания виртуальной среды можно использовать [Python Virtualenv](https://pypi.org/project/virtualenv/).
+
+Затем выполните следующую команду для запуска скрипта миграции базы данных:
+
+```bash
+uv run alembic upgrade head
+```
+
+Если Вы хотите использовать `PasarGuard-cli`, необходимо связать его с файлом в `$PATH`, сделать его исполняемым и установить:
+
+```bash
+sudo ln -s $(pwd)/PasarGuard-cli.py /usr/bin/pasarguard-cli
+sudo chmod +x /usr/bin/pasarguard-cli
+pasarguard-cli completion install
+```
+
+Теперь настало время настройки.
+
+Создайте копию файла `.env.example`, посмотрите его и отредактируйте с помощью текстового редактора,например `nano`.
+
+Возможно, вам захочется изменить учетные данные администратора.
+
+```bash
+cp .env.example .env
+nano .env
+```
+
+> Проверьте раздел [Конфигурации](#конфигурация) для получения большей информации.
+
+В завершение запустите приложение с помощью следующей команды:
+
+```bash
+uv run main.py
+```
+
+Для запуска с помощью linux systemctl (скопируйте файл PasarGuard.service в `/var/lib/pasarguard/PasarGuard.service`):
+
+```
+systemctl enable /var/lib/pasarguard/PasarGuard.service
+systemctl start PasarGuard
+```
+
+Для использования с nginx:
+
+```
+server {
+ listen 443 ssl http2;
+ listen [::]:443 ssl http2;
+ server_name example.com;
+
+ ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
+
+ location ~* /(dashboard|statics|sub|api|docs|redoc|openapi.json) {
+ proxy_pass http://0.0.0.0:8000;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ }
+
+ # xray-core ws-path: /
+ # client ws-path: /PasarGuard/me/2087
+ #
+ # All traffic is proxed through port 443, and send to the xray port(2087, 2088 etc.).
+ # The '/PasarGuard' in location regex path can changed any characters by yourself.
+ #
+ # /${path}/${username}/${xray-port}
+ location ~* /PasarGuard/.+/(.+)$ {
+ proxy_redirect off;
+ proxy_pass http://127.0.0.1:$1/;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Host $http_host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ }
+}
+```
+
+или:
+
+```
+server {
+ listen 443 ssl http2;
+ listen [::]:443 ssl http2;
+ server_name PasarGuard.example.com;
+
+ ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
+
+ location / {
+ proxy_pass http://0.0.0.0:8000;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ }
+}
+```
+
+По умолчанию приложение будет запускаться на `http://localhost:8000/dashboard`. Вы можете настроить его, изменив переменные окружения `UVICORN_HOST` и `UVICORN_PORT`.
+
+
+
+# Конфигурация
+
+> Ниже приведены настройки, которые можно задать с помощью переменных окружения поместив их в файл `.env`.
+
+| Перменная | Описание |
+| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
+| SUDO_USERNAME | Имя пользователя главного администратора |
+| SUDO_PASSWORD | Пароль главного администратора |
+| SQLALCHEMY_DATABASE_URL | Путь к файлу БД ([SQLAlchemy's docs](https://docs.sqlalchemy.org/en/20/core/engines.html#database-urls)) |
+| UVICORN_HOST | Привязка приложения к хосту (по умолчанию: `0.0.0.0`) |
+| UVICORN_PORT | Привязка приложения к порту (по умолчанию: `8000`) |
+| UVICORN_UDS | Привязка приложения к UNIX domain socket |
+| UVICORN_SSL_CERTFILE | Адрес файла сертификата SSL |
+| UVICORN_SSL_KEYFILE | Адрес файла ключа SSL |
+| UVICORN_SSL_CA_TYPE | Тип центра сертификации ключа SSL. Используйте `private` для тестирования самоподписанных CA (по умолчанию: `public`) |
+| XRAY_JSON | Адрес файла JSON конфигурации Xray. (по умолчанию: `xray_config.json`) |
+| XRAY_EXECUTABLE_PATH | Путь к бинарникам Xray (по умолчанию: `/usr/local/bin/xray`) |
+| XRAY_ASSETS_PATH | Путь к папке с рессурсными файлами для Xray (файлы geoip.dat и geosite.dat) (по умолчанию: `/usr/local/share/xray`) |
+| XRAY_SUBSCRIPTION_URL_PREFIX | Префикс адреса подписки |
+| XRAY_FALLBACKS_INBOUND_TAG | Если вы используете входящее соединение с несколькими резервными вариантами, укажите здесь его тег |
+| XRAY_EXCLUDE_INBOUND_TAGS | Теги входящих соединений, которые не требуют управления и не должны быть включены в список прокси |
+| CUSTOM_TEMPLATES_DIRECTORY | Путь к папке с пользовательскими шаблонами (по умолчанию: `app/templates`) |
+| CLASH_SUBSCRIPTION_TEMPLATE | Шаблон для создания конфигурации Clash (по умолчанию: `clash/default.yml`) |
+| SUBSCRIPTION_PAGE_TEMPLATE | Шаблон для страницы подписки (по умолчанию: `subscription/index.html`) |
+| HOME_PAGE_TEMPLATE | Шаблон главной страницы (по умолчанию: `home/index.html`) |
+| TELEGRAM_API_TOKEN | Токен Telegram-бота (полученный от [@botfather](https://t.me/botfather)) |
+| TELEGRAM_ADMIN_ID | Числовой идентификатор администратора в Telegram (полученный от [@userinfobot](https://t.me/userinfobot)) |
+| TELEGRAM_PROXY_URL | URL прокси для запуска Telegram-бота (если серверы Telegram заблокированы на вашем сервере). |
+| JWT_ACCESS_TOKEN_EXPIRE_MINUTES | Время истечения срока действия доступного токена в минутах, `0` означает "без истечения срока действия" (по умолчанию: `1440`) |
+| DOCS | Активация документации API по адресам `/docs` и `/redoc`. (по умолчанию: `False`) |
+| DEBUG | Активация режима разработки (development) (по умолчанию: `False`) |
+| WEBHOOK_ADDRESS | Адрес Webhook для отправки уведомлений. Уведомления Webhook будут отправляться, если это значение было установлено |
+| WEBHOOK_SECRET | Webhook secret будет передаваться с каждым запросом в виде `x-webhook-secret` в заголовке (по умолчанию: `None`) |
+| NUMBER_OF_RECURRENT_NOTIFICATIONS | Сколько раз повторять попытку отправки уведомления при обнаружении ошибки (по умолчанию: `3`) |
+| RECURRENT_NOTIFICATIONS_TIMEOUT | Тайм-аут между каждым повторным запросом при обнаружении ошибки в секундах (по умолчанию: `180`) |
+| NOTIFY_REACHED_USAGE_PERCENT | При каком проценте использования отправлять предупреждение (по умолчанию: `80`) |
+| NOTIFY_DAYS_LEFT | Когда отправлять предупреждение об истечении срока действия (по умолчанию: `3`) |
+| SQLALCHEMY_POOL_SIZE | (по умолчанию: `10`) |
+| SQLALCHEMY_MAX_OVERFLOW | (по умолчанию: `30`) |
+| XRAY_SUBSCRIPTION_TEMPLATE | Шаблон, который будет использоваться для создания конфигураций xray (по умолчанию: `xray/default.yml`) |
+| SINGBOX_SUBSCRIPTION_TEMPLATE | Шаблон, который будет использоваться для создания конфигураций singbox (по умолчанию: `singbox/default.yml`) |
+| USERS_AUTODELETE_DAYS | Удалять истекших (и опционально ограниченных) пользователей через столько дней (отрицательные значения отключают эту функцию, по умолчанию: `-1`) |
+| USER_AUTODELETE_INCLUDE_LIMITED_ACCOUNTS | Включать ли ограниченные учетные записи в функцию автоматического удаления (по умолчанию: `False`) |
+| XRAY_SUBSCRIPTION_PATH | Вы можете изменить путь api для подписки (по умолчанию: `sub`) |
+| ENABLE_RECORDING_NODES_STATS | Из-за большого объема данных эта задача доступна только для postgresql и timescaledb |
+
+# документация
+
+[Документация PasarGuard](https://pasarguard.github.io/PasarGuard/ru/) предоставляет все необходимые руководства для начала работы и доступна на трех языках: фарси, английском и русском. Для полного охвата всех аспектов проекта требуется значительное количество усилий. Мы приветствуем и ценим ваш вклад в улучшение документации. Вы можете внести свой вклад в этот [репозиторий на GitHub](https://github.com/Gozargah/pasarguard.github.io).
+
+# API
+
+PasarGuard предоставляет REST API, позволяющий разработчикам программно взаимодействовать с сервисами PasarGuard. Для просмотра документации по API в Swagger UI или ReDoc установите переменную `DOCS=True` и перейдите по ссылкам `/docs` и `/redoc`.
+
+# Backup
+
+Всегда полезно регулярно создавать резервные копии файлов PasarGuard, чтобы предотвратить потерю данных в случае системных сбоев или случайного удаления. Ниже приведены шаги для создания резервной копии PasarGuard:
+
+1. По умолчанию все важные файлы PasarGuard сохраняются в папке `/var/lib/pasarguard` (в версиях Docker). Скопируйте весь каталог `/var/lib/pasarguard` в выбранное вами место резервного копирования, например на внешний жесткий диск или в облачное хранилище.
+2. Кроме того, не забудьте сделать резервную копию файла env, содержащего переменные конфигурации, а также файла конфигурации Xray. Если вы устанавливали PasarGuard с помощью PasarGuard-scripts (рекомендуемый подход к установке), то env и другие конфигурации должны находиться в каталоге `/opt/pasarguard/`.
+
+Выполнив эти действия, вы сможете обеспечить резервное копирование всех файлов и данных PasarGuard, а также переменных конфигурации и конфигурации Xray на случай, если в будущем потребуется их восстановить. Не забывайте регулярно обновлять резервные копии, чтобы поддерживать их в актуальном состоянии.
+
+# Telegram Bot
+
+PasarGuard поставляется с встроенным ботом Telegram, который может управлять сервером, создавать и удалять пользователей, а также отправлять уведомления. Этот бот можно легко включить, выполнив несколько простых шагов, и он предоставляет удобный способ взаимодействия с PasarGuard без необходимости каждый раз заходить на сервер.
+
+Чтобы включить Telegram-бота, выполните следующие действия:
+
+1. установите `TELEGRAM_API_TOKEN` в качестве API-токена вашего бота.
+2. установите `TELEGRAM_ADMIN_ID` в качестве цифрового ID вашего Telegram-аккаунта, который вы можете получить от [@userinfobot](https://t.me/userinfobot)
+
+Сервис резервного копирования PasarGuard эффективно архивирует все необходимые файлы и отправляет их вашему указанному Telegram-боту. Он поддерживает базы данных SQLite, MySQL и MariaDB. Одной из ключевых особенностей является автоматизация, позволяющая настроить расписание резервного копирования, например, каждый час. При этом ограничений на размер файлов для загрузки в Telegram через бота нет: если файл превышает лимит, он будет автоматически разделен и отправлен частями. Также можно запустить резервное копирование вручную в любой момент.
+
+Установка последней версии PasarGuard:
+
+```bash
+sudo bash -c "$(curl -sL https://github.com/PasarGuard/scripts/raw/main/pasarguard.sh)" @ install-script
+```
+
+Настройка сервиса резервного копирования:
+
+```bash
+pasarguard backup-service
+```
+
+Мгновенное резервное копирование:
+
+```bash
+pasarguard backup
+```
+
+# PasarGuard CLI
+
+PasarGuard поставляется с встроенным CLI под названием `PasarGuard-cli`, который позволяет администраторам напрямую взаимодействовать с ним.
+
+Если вы установили PasarGuard с помощью скрипта установки, то доступ к командам cli можно получить, выполнив команду:
+
+```bash
+pasarguard cli [OPTIONS] COMMAND [ARGS]...
+```
+
+Для получения дополнительной информации можно ознакомиться с [документацией по PasarGuard CLI](./cli/README.md).
+
+# Пользовательский интерфейс терминала (TUI) PasarGuard
+
+PasarGuard также предоставляет пользовательский интерфейс терминала (TUI) для интерактивного управления непосредственно в вашем терминале.
+
+Если вы установили PasarGuard с помощью простого установочного скрипта, вы можете получить доступ к TUI, запустив:
+
+```bash
+pasarguard tui
+```
+
+Для получения дополнительной информации вы можете ознакомиться с [документацией по TUI PasarGuard](./tui/README.md).
+
+# Node
+
+Проект PasarGuard представляет [node](https://github.com/PasarGuard/node), который помогает Вам в распределении инфраструктуры. С помощью node можно распределить инфраструктуру по нескольким узлам, получив такие преимущества, как высокая доступность, масштабируемость и гибкость. node позволяет пользователям подключаться к различным серверам, предоставляя им гибкость в выборе, а не ограничиваться только одним сервером.
+Более подробная информация и инструкции по установке приведены в [официальной документации PasarGuard-node](https://github.com/PasarGuard/node).
+
+# Webhook уведомления
+
+Вы можете задать адрес webhook, и PasarGuard будет отправлять уведомления на этот адрес.
+
+Запросы будут отправляться в виде POST-запроса на адрес, указанный в `WEBHOOK_ADDRESS`, с `WEBHOOK_SECRET` в качестве `x-webhook-secret` в заголовках.
+
+Пример запроса, отправленного PasarGuard:
+
+```
+Headers:
+Host: 0.0.0.0:9000
+User-Agent: python-requests/2.28.1
+Accept-Encoding: gzip, deflate
+Accept: */*
+Connection: keep-alive
+x-webhook-secret: something-very-very-secret
+Content-Length: 107
+Content-Type: application/json
+
+
+
+Body:
+{"username": "PasarGuard_test_user", "action": "user_updated", "enqueued_at": 1680506457.636369, "tries": 0}
+```
+
+Различные типы действий: `user_created`, `user_updated`, `user_deleted`, `user_limited`, `user_expired`, `user_disabled`, `user_enabled`
+
+# Поддержка
+
+Если вы нашли PasarGuard полезным и хотели бы поддержать его развитие, вы можете сделать пожертвование, [нажмите здесь](https://donate.gozargah.pro)
+
+Спасибо за поддержку!
+
+# Лицензия
+
+Сделано в [Unknown!] и опубликовано под [AGPL-3.0](./LICENSE).
+
+# Участники
+
+Мы ❤️🔥 участников проекта! Если вы хотите внести свой вклад, пожалуйста, ознакомьтесь с нашим [Contributing Guidelines](CONTRIBUTING.md) и не стесняйтесь отправлять запросы на исправление ошибок или сообщить о проблеме. Мы также приглашаем вас присоединиться к нашей группе [Telegram](https://t.me/Pasar_Guard) для получения поддержки.
+
+Проверьте [open issues](https://github.com/PasarGuard/panel/issues), чтобы помочь развитию этого проекта.
+
+
+Спасибо всем участникам, благодаря которым PasarGuard становится лучше:
+
+
+**PasarGuard** ابزاری قدرتمند برای مدیریت پروکسی است که رابط کاربری ساده و آسانی برای مدیریت صدها حساب پروکسی فراهم میکند. این ابزار بر پایه [Xray-core](https://github.com/XTLS/Xray-core) ساخته شده و با استفاده از Python و Reactjs توسعه یافته است.
+
+**PasarGuard** is a powerful proxy management tool that provides a simple and easy-to-use user interface for managing hundreds of proxy accounts. Built on [Xray-core](https://github.com/XTLS/Xray-core) and developed using Python and Reactjs.
+
+
+
+## ❓ Why using PasarGuard?
+
+
+
+PasarGuard کاربرپسند، غنی از ویژگی و قابل اعتماد است. این ابزار به شما امکان ایجاد پروکسیهای مختلف برای کاربرانتان بدون هیچ پیکربندی پیچیدهای را میدهد. با استفاده از رابط وب داخلی آن، میتوانید کاربران را نظارت، تغییر و محدود کنید.
+
+PasarGuard is user-friendly, feature-rich and reliable. It lets you create different proxies for your users without any complicated configuration. Using its built-in web UI, you are able to monitor, modify and limit users.
+
+
+
+#### 🎯 **Key Capabilities:**
+
+- ✅ **Built-in Web UI** - رابط کاربری وب داخلی
+- ✅ **Fully REST API** backend - بکاند API کامل
+- ✅ **[Multiple Nodes](#node)** support - پشتیبانی از چندین نود
+- ✅ **Multi-protocol** for a single user - چندین پروتکل برای یک کاربر
+- ✅ **Multi-user** on a single inbound - چندین کاربر روی یک inbound
+- ✅ **Multi-inbound** on a **single port** - چندین inbound روی یک پورت
+- ✅ **Traffic** and **expiry date** limitations - محدودیت ترافیک و تاریخ انقضا
+- ✅ **Periodic** traffic limit (daily, weekly, etc.) - محدودیت ترافیک دورهای
+- ✅ **Subscription link** compatible with **V2ray**, **Clash** and **ClashMeta**
+- ✅ Automated **Share link** and **QRcode** generator
+- ✅ System monitoring and **traffic statistics**
+- ✅ Customizable xray configuration
+- ✅ **TLS** and **REALITY** support
+- ✅ Integrated **Telegram Bot**
+- ✅ Integrated **Command Line Interface (CLI)**
+- ✅ **Multi-language** support
+- ✅ **Multi-admin** support (WIP)
+
+# 🚀 Installation guide
+
+
+
+## ⚠️ هشدار | Warning
+
+دستورات زیر نسخههای پیشانتشار (آلفا/بتا) را نصب میکنند
+
+The following commands will install the pre-release versions (alpha/beta)
+
+
+
+## 📦 نصب با پایگاه داده SQLite | Install with SQLite Database
+
+```bash
+sudo bash -c "$(curl -sL https://github.com/PasarGuard/scripts/raw/main/pasarguard.sh)" @ install --pre-release
+```
+
+## 🗄️ نصب با پایگاه داده MySQL | Install with MySQL Database
+
+```bash
+sudo bash -c "$(curl -sL https://github.com/PasarGuard/scripts/raw/main/pasarguard.sh)" @ install --database mysql --pre-release
+```
+
+## 🗃️ نصب با پایگاه داده MariaDB | Install with MariaDB Database
+
+```bash
+sudo bash -c "$(curl -sL https://github.com/PasarGuard/scripts/raw/main/pasarguard.sh)" @ install --database mariadb --pre-release
+```
+
+## 🐘 نصب با پایگاه داده PostgreSQL | Install with PostgreSQL Database
+
+```bash
+sudo bash -c "$(curl -sL https://github.com/PasarGuard/scripts/raw/main/pasarguard.sh)" @ install --database postgresql --pre-release
+```
+
+## ✅ پس از تکمیل نصب | After Installation Complete
+
+
+
+### 📋 مراحل بعدی | Next Steps
+
+
+
+- ✅ **Logs**: شما لاگها را خواهید دید که میتوانید با بستن ترمینال یا فشردن `Ctrl+C` آنها را متوقف کنید
+- 📁 **Files Location**: فایلهای PasarGuard در `/opt/pasarguard` قرار خواهند گرفت
+- ⚙️ **Configuration**: فایل پیکربندی در `/opt/pasarguard/.env` یافت میشود
+- 💾 **Data Files**: فایلهای داده در `/var/lib/pasarguard` قرار خواهند گرفت
+- 🔒 **Security**: داشبورد PasarGuard به دلایل امنیتی از طریق آدرس IP قابل دسترسی نیست
+- 🌐 **SSL Required**: باید [گواهی SSL دریافت کنید](https://PasarGuard.github.io/PasarGuard/en/examples/issue-ssl-certificate) و به `https://YOUR_DOMAIN:8000/dashboard/` دسترسی پیدا کنید
+
+### 🔗 دسترسی محلی با SSH | Local Access via SSH
+
+```bash
+ssh -L 8000:localhost:8000 user@serverip
+```
+
+سپس در مرورگر خود به آدرس زیر بروید:
+
+Then navigate to the following link in your browser:
+
+```
+http://localhost:8000/dashboard/
+```
+
+⚠️ **توجه**: دسترسی به داشبورد با بستن ترمینال SSH از بین میرود. این روش فقط برای تست توصیه میشود.
+
+### 👨💼 ایجاد ادمین | Create Admin
+
+```bash
+pasarguard cli admin create --sudo
+```
+
+### ❓ راهنمایی | Help
+
+```bash
+pasarguard --help
+```
+
+## 🔧 نصب دستی (پیشرفته) | Manual Install (Advanced)
+
+
+
+اگر میخواهید پروژه را با کد منبع اجرا کنید، بخش زیر را بررسی کنید
+
+If you are eager to run the project using the source code, check the section below
+
+
+
+
+
🔧 Manual install (advanced) | نصب دستی (پیشرفته)
+
+### 🚀 مرحله 1: نصب Xray | Step 1: Install Xray
+
+```bash
+bash -c "$(curl -L https://github.com/XTLS/Xray-install/raw/main/install-release.sh)" @ install
+```
+
+### 📥 مرحله 2: کلون پروژه | Step 2: Clone Project
+
+```bash
+git clone https://github.com/PasarGuard/panel.git
+cd PasarGuard
+curl -LsSf https://astral.sh/uv/install.sh | sh
+uv sync
+```
+
+> **نکته**: شما به Python >= 3.12.7 نیاز دارید | You need Python >= 3.12.7
+
+### 🗄️ مرحله 3: مهاجرت پایگاه داده | Step 3: Database Migration
+
+```bash
+uv run alembic upgrade head
+```
+
+### 💻 مرحله 4: نصب CLI | Step 4: Install CLI
+
+```bash
+sudo ln -s $(pwd)/PasarGuard-cli.py /usr/bin/pasarguard-cli
+sudo chmod +x /usr/bin/pasarguard-cli
+pasarguard-cli completion install
+```
+
+### ⚙️ مرحله 5: پیکربندی | Step 5: Configuration
+
+```bash
+cp .env.example .env
+nano .env
+```
+
+> 📖 برای اطلاعات بیشتر بخش [پیکربندی](#configuration) را بررسی کنید | Check [configurations](#configuration) section for more information
+
+### 🚀 مرحله 6: اجرای برنامه | Step 6: Run Application
+
+```bash
+uv run main.py
+```
+
+### 🔧 اجرا با systemctl | Run with systemctl
+
+```bash
+systemctl enable /var/lib/pasarguard/PasarGuard.service
+systemctl start PasarGuard
+```
+
+### 🌐 پیکربندی Nginx | Nginx Configuration
+
+```nginx
+server {
+ listen 443 ssl http2;
+ listen [::]:443 ssl http2;
+ server_name example.com;
+
+ ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
+
+ location ~* /(dashboard|statics|sub|api|docs|redoc|openapi.json) {
+ proxy_pass http://0.0.0.0:8000;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ }
+
+ # xray-core ws-path: /
+ # client ws-path: /PasarGuard/me/2087
+ #
+ # All traffic is proxed through port 443, and send to the xray port(2087, 2088 etc.).
+ # The '/PasarGuard' in location regex path can changed any characters by yourself.
+ #
+ # /${path}/${username}/${xray-port}
+ location ~* /PasarGuard/.+/(.+)$ {
+ proxy_redirect off;
+ proxy_pass http://127.0.0.1:$1/;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Host $http_host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ }
+}
+```
+
+### 🌐 پیکربندی ساده Nginx | Simple Nginx Configuration
+
+```nginx
+server {
+ listen 443 ssl http2;
+ listen [::]:443 ssl http2;
+ server_name PasarGuard.example.com;
+
+ ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
+
+ location / {
+ proxy_pass http://0.0.0.0:8000;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ }
+}
+```
+
+### 🌐 دسترسی به داشبورد | Dashboard Access
+
+به طور پیشفرض برنامه روی `http://localhost:8000/dashboard` اجرا میشود. میتوانید با تغییر متغیرهای محیطی `UVICORN_HOST` و `UVICORN_PORT` آن را پیکربندی کنید.
+
+By default the app will be run on `http://localhost:8000/dashboard`. You can configure it using changing the `UVICORN_HOST` and `UVICORN_PORT` environment variables.
+
+
+
+# ⚙️ Configuration
+
+
+
+میتوانید تنظیمات زیر را با استفاده از متغیرهای محیطی یا قرار دادن آنها در فایل `.env` تنظیم کنید.
+
+You can set settings below using environment variables or placing them in `.env` file.
+
+
+
+[مستندات PasarGuard](https://PasarGuard.github.io/PasarGuard) تمام راهنمایهای ضروری برای شروع کار را فراهم میکند و به سه زبان فارسی، انگلیسی و روسی در دسترس است.
+
+The [PasarGuard Documentation](https://PasarGuard.github.io/PasarGuard) provides all the essential guides to get you started, available in three languages: Farsi, English, and Russian.
+
+
+
+## 🤝 مشارکت در مستندات | Contributing to Documentation
+
+این مستندات نیاز به تلاش قابل توجهی برای پوشش جامع تمام جنبههای پروژه دارد. ما از مشارکت شما برای بهبود آن استقبال میکنیم.
+
+This documentation requires significant effort to cover all aspects of the project comprehensively. We welcome and appreciate your contributions to help us improve it.
+
+[مخزن GitHub مستندات](https://github.com/PasarGuard/PasarGuard.github.io) | [Documentation GitHub Repository](https://github.com/PasarGuard/PasarGuard.github.io)
+
+# 🔌 API
+
+
+
+PasarGuard یک REST API فراهم میکند که به توسعهدهندگان امکان تعامل برنامهنویسی با سرویسهای PasarGuard را میدهد.
+
+PasarGuard provides a REST API that enables developers to interact with PasarGuard services programmatically.
+
+
+
+## 📖 مشاهده مستندات API | View API Documentation
+
+برای مشاهده مستندات API در Swagger UI یا ReDoc، متغیر پیکربندی `DOCS=True` را تنظیم کنید و به `/docs` و `/redoc` بروید.
+
+To view the API documentation in Swagger UI or ReDoc, set the configuration variable `DOCS=True` and navigate to the `/docs` and `/redoc`.
+
+# 💾 Backup
+
+
+
+## 🔄 سرویس پشتیبانگیری خودکار | Automated Backup Service
+
+همیشه ایده خوبی است که فایلهای PasarGuard خود را به طور منظم پشتیبانگیری کنید تا از از دست رفتن داده در صورت خرابی سیستم یا حذف تصادفی جلوگیری کنید.
+
+It's always a good idea to backup your PasarGuard files regularly to prevent data loss in case of system failures or accidental deletion.
+
+
+
+## 📋 مراحل پشتیبانگیری | Backup Steps
+
+1. **📁 فایلهای مهم**: به طور پیشفرض، تمام فایلهای مهم PasarGuard در `/var/lib/pasarguard` ذخیره میشوند
+2. **⚙️ فایل پیکربندی**: فایل env و فایل پیکربندی Xray را نیز پشتیبانگیری کنید
+3. **📂 مسیر پیکربندی**: اگر با اسکریپت PasarGuard نصب کردهاید، پیکربندیها در `/opt/pasarguard/` قرار دارند
+
+## 🤖 سرویس پشتیبانگیری تلگرام | Telegram Backup Service
+
+سرویس پشتیبانگیری PasarGuard به طور کارآمد تمام فایلهای ضروری را فشرده میکند و آنها را به ربات تلگرام مشخص شده شما ارسال میکند.
+
+### ✨ ویژگیها | Features
+
+- ✅ پشتیبانی از SQLite، MySQL و MariaDB
+- ✅ خودکارسازی با امکان زمانبندی پشتیبانگیری هر ساعت
+- ✅ بدون محدودیت آپلود تلگرام (فایلهای بزرگ تقسیم میشوند)
+- ✅ امکان پشتیبانگیری فوری در هر زمان
+
+### 🚀 نصب و راهاندازی | Installation & Setup
+
+```bash
+# نصب آخرین نسخه اسکریپت PasarGuard
+sudo bash -c "$(curl -sL https://github.com/PasarGuard/scripts/raw/main/pasarguard.sh)" @ install-script
+
+# راهاندازی سرویس پشتیبانگیری
+pasarguard backup-service
+
+# پشتیبانگیری فوری
+pasarguard backup
+```
+
+## 💡 نکات مهم | Important Notes
+
+- 🔄 پشتیبانگیریها را به طور منظم بهروزرسانی کنید
+- 📁 تمام فایلهای داده و پیکربندی را پشتیبانگیری کنید
+- 🔐 فایلهای env و پیکربندی Xray را فراموش نکنید
+
+# 🤖 Telegram Bot
+
+
+
+PasarGuard با یک ربات تلگرام یکپارچه همراه است که میتواند مدیریت سرور، ایجاد و حذف کاربران و ارسال اعلانها را انجام دهد.
+
+PasarGuard comes with an integrated Telegram bot that can handle server management, user creation and removal, and send notifications.
+
+
+
+## ⚙️ راهاندازی ربات تلگرام | Enable Telegram Bot
+
+این ربات را میتوان با دنبال کردن چند مرحله ساده به راحتی فعال کرد و راهی راحت برای تعامل با PasarGuard بدون نیاز به ورود به سرور در هر بار فراهم میکند.
+
+This bot can be easily enabled by following a few simple steps, and it provides a convenient way to interact with PasarGuard without having to log in to the server every time.
+
+### 🔧 مراحل راهاندازی | Setup Steps
+
+1. **🔑 تنظیم توکن API**: `TELEGRAM_API_TOKEN` را به توکن API ربات خود تنظیم کنید
+2. **👤 تنظیم شناسه ادمین**: `TELEGRAM_ADMIN_ID` را به شناسه عددی حساب تلگرام خود تنظیم کنید
+
+### 📱 دریافت شناسه تلگرام | Get Telegram ID
+
+میتوانید شناسه خود را از [@userinfobot](https://t.me/userinfobot) دریافت کنید.
+
+You can get your ID from [@userinfobot](https://t.me/userinfobot).
+
+# 💻 PasarGuard CLI
+
+
+
+PasarGuard با یک CLI یکپارچه به نام `PasarGuard-cli` همراه است که به مدیران امکان تعامل مستقیم با آن را میدهد.
+
+PasarGuard comes with an integrated CLI named `PasarGuard-cli` which allows administrators to have direct interaction with it.
+
+
+
+## 🚀 استفاده از CLI | Using CLI
+
+اگر PasarGuard را با اسکریپت نصب آسان نصب کردهاید، میتوانید با اجرای دستور زیر به دستورات CLI دسترسی پیدا کنید:
+
+If you've installed PasarGuard using easy install script, you can access the cli commands by running:
+
+```bash
+pasarguard cli [OPTIONS] COMMAND [ARGS]...
+```
+
+## 📖 مستندات CLI | CLI Documentation
+
+برای اطلاعات بیشتر، میتوانید [مستندات PasarGuard CLI](./cli/README.md) را مطالعه کنید.
+
+For more information, You can read [PasarGuard CLI's documentation](./cli/README.md).
+
+# 🖥️ PasarGuard TUI
+
+
+
+PasarGuard همچنین یک رابط کاربری ترمینال (TUI) برای مدیریت تعاملی مستقیماً در ترمینال شما فراهم میکند.
+
+PasarGuard also provides a Terminal User Interface (TUI) for interactive management directly within your terminal.
+
+
+
+## 🚀 استفاده از TUI | Using TUI
+
+اگر PasarGuard را با اسکریپت نصب آسان نصب کردهاید، میتوانید با اجرای دستور زیر به TUI دسترسی پیدا کنید:
+
+If you've installed PasarGuard using the easy install script, you can access the TUI by running:
+
+```bash
+pasarguard tui
+```
+
+## 📖 مستندات TUI | TUI Documentation
+
+برای اطلاعات بیشتر، میتوانید [مستندات PasarGuard TUI](./tui/README.md) را مطالعه کنید.
+
+For more information, you can read [PasarGuard TUI's documentation](./tui/README.md).
+
+# 🌐 Node
+
+
+
+پروژه PasarGuard [node](https://github.com/PasarGuard/node) را معرفی میکند که توزیع زیرساخت را متحول میکند.
+
+The PasarGuard project introduces the [node](https://github.com/PasarGuard/node), which revolutionizes infrastructure distribution.
+
+
+
+## ✨ مزایای Node | Node Benefits
+
+با node، میتوانید زیرساخت خود را در چندین مکان توزیع کنید و از مزایایی مانند:
+
+With node, you can distribute your infrastructure across multiple locations, unlocking benefits such as:
+
+- 🔄 **Redundancy** - افزونگی
+- ⚡ **High Availability** - در دسترس بودن بالا
+- 📈 **Scalability** - مقیاسپذیری
+- 🔧 **Flexibility** - انعطافپذیری
+
+## 🎯 انعطاف کاربران | User Flexibility
+
+node به کاربران امکان اتصال به سرورهای مختلف را میدهد و انعطاف انتخاب و اتصال به چندین سرور به جای محدودیت به یک سرور را فراهم میکند.
+
+node empowers users to connect to different servers, offering them the flexibility to choose and connect to multiple servers instead of being limited to only one server.
+
+## 📖 مستندات Node | Node Documentation
+
+برای اطلاعات تفصیلی و دستورالعملهای نصب، لطفاً به [مستندات رسمی PasarGuard-node](https://github.com/PasarGuard/node) مراجعه کنید.
+
+For more detailed information and installation instructions, please refer to the [PasarGuard-node official documentation](https://github.com/PasarGuard/node)
+
+# 🔔 Webhook notifications
+
+
+
+میتوانید یک آدرس webhook تنظیم کنید و PasarGuard اعلانها را به آن آدرس ارسال کند.
+
+You can set a webhook address and PasarGuard will send the notifications to that address.
+
+
+
+## 📡 نحوه کارکرد | How it Works
+
+درخواستها به عنوان درخواست POST به آدرس ارائه شده توسط `WEBHOOK_ADDRESS` با `WEBHOOK_SECRET` به عنوان `x-webhook-secret` در هدرها ارسال میشوند.
+
+The requests will be sent as a post request to the address provided by `WEBHOOK_ADDRESS` with `WEBHOOK_SECRET` as `x-webhook-secret` in the headers.
+
+## 📋 مثال درخواست | Example Request
+
+```http
+Headers:
+Host: 0.0.0.0:9000
+User-Agent: python-requests/2.28.1
+Accept-Encoding: gzip, deflate
+Accept: */*
+Connection: keep-alive
+x-webhook-secret: something-very-very-secret
+Content-Length: 107
+Content-Type: application/json
+
+Body:
+{"username": "PasarGuard_test_user", "action": "user_updated", "enqueued_at": 1680506457.636369, "tries": 0}
+```
+
+## 🎯 انواع عملیات | Action Types
+
+انواع مختلف عملیات عبارتند از: `user_created`, `user_updated`, `user_deleted`, `user_limited`, `user_expired`, `user_disabled`, `user_enabled`
+
+Different action types are: `user_created`, `user_updated`, `user_deleted`, `user_limited`, `user_expired`, `user_disabled`, `user_enabled`
+
+# 💝 Donation
+
+
+
+اگر PasarGuard را مفید یافتید و میخواهید از توسعه آن پشتیبانی کنید، میتوانید کمک مالی کنید.
+
+If you found PasarGuard useful and would like to support its development, you can make a donation.
+
+
+
+## 🎯 حمایت از پروژه | Support the Project
+
+[کمک مالی کنید](https://donate.gozargah.pro) | [Make a Donation](https://donate.gozargah.pro)
+
+از حمایت شما متشکریم! | Thank you for your support!
+
+# 📄 License
+
+
+
+ساخته شده در [نامشخص!] و منتشر شده تحت [AGPL-3.0](./LICENSE).
+
+Made in [Unknown!] and Published under [AGPL-3.0](./LICENSE).
+
+
+
+# 👥 Contributors
+
+
+
+ما ❤️🔥 مشارکتکنندگان را دوست داریم! اگر میخواهید مشارکت کنید، لطفاً [راهنمای مشارکت](CONTRIBUTING.md) ما را بررسی کنید و آزادانه یک pull request ارسال کنید یا issue باز کنید.
+
+We ❤️🔥 contributors! If you'd like to contribute, please check out our [Contributing Guidelines](CONTRIBUTING.md) and feel free to submit a pull request or open an issue.
+
+
+
+## 🤝 پیوستن به جامعه | Join the Community
+
+همچنین از شما دعوت میکنیم به گروه [تلگرام](https://t.me/Pasar_Guard) ما برای پشتیبانی یا راهنمایی مشارکت بپیوندید.
+
+We also welcome you to join our [Telegram](https://t.me/Pasar_Guard) group for either support or contributing guidance.
+
+## 🐛 کمک به پیشرفت پروژه | Help Project Progress
+
+[مسائل باز](https://github.com/PasarGuard/panel/issues) را بررسی کنید تا به پیشرفت این پروژه کمک کنید.
+
+Check [open issues](https://github.com/PasarGuard/panel/issues) to help the progress of this project.
+
+
+
+## 🙏 تشکر از مشارکتکنندگان | Thanks to Contributors
+
+از تمام مشارکتکنندگانی که به بهبود PasarGuard کمک کردهاند متشکریم:
+
+Thanks to the all contributors who have helped improve PasarGuard:
+
+
diff --git a/alembic.ini b/alembic.ini
new file mode 100644
index 000000000..402281a09
--- /dev/null
+++ b/alembic.ini
@@ -0,0 +1,99 @@
+# A generic, single database configuration.
+
+[alembic]
+# path to migration scripts
+script_location = app/db/migrations
+
+# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
+# Uncomment the line below if you want the files to be prepended with date and time
+# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
+# for all available tokens
+# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
+
+# sys.path path, will be prepended to sys.path if present.
+# defaults to the current working directory.
+prepend_sys_path = .
+
+# timezone to use when rendering the date within the migration file
+# as well as the filename.
+# If specified, requires the python-dateutil library that can be
+# installed by adding `alembic[tz]` to the pip requirements
+# string value is passed to dateutil.tz.gettz()
+# leave blank for localtime
+# timezone =
+
+# max length of characters to apply to the
+# "slug" field
+# truncate_slug_length = 40
+
+# set to 'true' to run the environment during
+# the 'revision' command, regardless of autogenerate
+# revision_environment = false
+
+# set to 'true' to allow .pyc and .pyo files without
+# a source .py file to be detected as revisions in the
+# versions/ directory
+# sourceless = false
+
+# version location specification; This defaults
+# to app/db/migrations/versions. When using multiple version
+# directories, initial revisions must be specified with --version-path.
+# The path separator used here should be the separator specified by "version_path_separator" below.
+# version_locations = %(here)s/bar:%(here)s/bat:app/db/migrations/versions
+
+# version path separator; As mentioned above, this is the character used to split
+# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
+# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
+# Valid values for version_path_separator are:
+#
+# version_path_separator = :
+# version_path_separator = ;
+# version_path_separator = space
+version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
+
+
+[post_write_hooks]
+# post_write_hooks defines scripts or Python functions that are run
+# on newly generated revision scripts. See the documentation for further
+# detail and examples
+
+# format using "black" - use the console_scripts runner, against the "black" entrypoint
+# hooks = black
+# black.type = console_scripts
+# black.entrypoint = black
+# black.options = -l 79 REVISION_SCRIPT_FILENAME
+
+# Logging configuration
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S
diff --git a/app/__init__.py b/app/__init__.py
new file mode 100644
index 000000000..4a1b66598
--- /dev/null
+++ b/app/__init__.py
@@ -0,0 +1,116 @@
+import asyncio
+from contextlib import asynccontextmanager
+
+from apscheduler.schedulers.asyncio import AsyncIOScheduler
+from fastapi import FastAPI, Request, status
+from fastapi.encoders import jsonable_encoder
+from fastapi.exceptions import RequestValidationError
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import JSONResponse
+from fastapi.routing import APIRoute
+
+from app.utils.logger import get_logger
+from config import ALLOWED_ORIGINS, DOCS, SUBSCRIPTION_PATH
+
+__version__ = "1.0.0-rc-2"
+
+startup_functions = []
+shutdown_functions = []
+
+
+def on_startup(func):
+ startup_functions.append(func)
+ return func
+
+
+def on_shutdown(func):
+ shutdown_functions.append(func)
+ return func
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ for func in startup_functions:
+ if callable(func):
+ if asyncio.iscoroutinefunction(func): # Better way to check if it's async
+ if "app" in func.__code__.co_varnames:
+ await func(app)
+ else:
+ await func()
+ else:
+ if "app" in func.__code__.co_varnames:
+ func(app)
+ else:
+ func()
+ yield
+
+ for func in shutdown_functions:
+ if callable(func):
+ if asyncio.iscoroutinefunction(func):
+ if "app" in func.__code__.co_varnames:
+ await func(app)
+ else:
+ await func()
+ else:
+ if "app" in func.__code__.co_varnames:
+ func(app)
+ else:
+ func()
+
+
+app = FastAPI(
+ title="PasarGuardAPI",
+ description="Unified GUI Censorship Resistant Solution",
+ version=__version__,
+ lifespan=lifespan,
+ openapi_url="/openapi.json" if DOCS else None,
+)
+
+scheduler = AsyncIOScheduler(job_defaults={"max_instances": 20}, timezone="UTC")
+logger = get_logger()
+
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=ALLOWED_ORIGINS,
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+from app import routers, telegram, jobs # noqa
+from app.routers import api_router # noqa
+
+app.include_router(api_router)
+
+
+def use_route_names_as_operation_ids(app: FastAPI) -> None:
+ for route in app.routes:
+ if isinstance(route, APIRoute):
+ route.operation_id = route.name
+
+
+use_route_names_as_operation_ids(app)
+
+
+@on_startup
+def validate_paths():
+ paths = [f"{r.path}/" for r in app.routes]
+ paths.append("/api/")
+ if f"/{SUBSCRIPTION_PATH}/" in paths:
+ raise ValueError(f"you can't use /{SUBSCRIPTION_PATH}/ as subscription path it reserved for {app.title}")
+
+
+on_startup(scheduler.start)
+on_shutdown(scheduler.shutdown)
+on_startup(lambda: logger.info(f"PasarGuard v{__version__}"))
+
+
+@app.exception_handler(RequestValidationError)
+def validation_exception_handler(request: Request, exc: RequestValidationError):
+ details = {}
+ for error in exc.errors():
+ details[error["loc"][-1]] = error.get("msg")
+ return JSONResponse(
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ content=jsonable_encoder({"detail": details}),
+ )
diff --git a/app/core/__init__.py b/app/core/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/app/core/abstract_core.py b/app/core/abstract_core.py
new file mode 100644
index 000000000..ed5fba392
--- /dev/null
+++ b/app/core/abstract_core.py
@@ -0,0 +1,21 @@
+from abc import ABC, abstractmethod
+
+
+class AbstractCore(ABC):
+ @abstractmethod
+ def __init__(self, config: dict, exclude_inbound_tags: list[str], fallbacks_inbound_tags: list[str]) -> None:
+ raise NotImplementedError
+
+ @abstractmethod
+ def to_str(self, **json_kwargs) -> str:
+ raise NotImplementedError
+
+ @property
+ @abstractmethod
+ def inbounds_by_tag(self) -> dict:
+ raise NotImplementedError
+
+ @property
+ @abstractmethod
+ def inbounds(self) -> list[str]:
+ raise NotImplementedError
diff --git a/app/core/hosts.py b/app/core/hosts.py
new file mode 100644
index 000000000..508b9eca6
--- /dev/null
+++ b/app/core/hosts.py
@@ -0,0 +1,76 @@
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app import on_startup
+from app.core.manager import core_manager
+from app.db import GetDB
+from app.db.crud.host import get_host_by_id, get_hosts, get_or_create_inbound
+from app.db.models import ProxyHost, ProxyHostSecurity
+from app.models.host import MuxSettings, TransportSettings
+from app.utils.store import DictStorage
+
+
+def _prepare_host_data(host: ProxyHost) -> dict:
+ return {
+ "remark": host.remark,
+ "inbound_tag": host.inbound_tag,
+ "address": [v for v in host.address],
+ "port": host.port,
+ "path": host.path or None,
+ "sni": [v for v in host.sni] if host.sni else [],
+ "host": [v for v in host.host] if host.host else [],
+ "alpn": [alpn.value for alpn in host.alpn] if host.alpn else [],
+ "fingerprint": host.fingerprint.value,
+ "tls": None if host.security == ProxyHostSecurity.inbound_default else host.security.value,
+ "allowinsecure": host.allowinsecure,
+ "fragment_settings": host.fragment_settings,
+ "noise_settings": host.noise_settings,
+ "random_user_agent": host.random_user_agent,
+ "use_sni_as_host": host.use_sni_as_host,
+ "http_headers": host.http_headers,
+ "mux_settings": MuxSettings.model_validate(host.mux_settings).model_dump(by_alias=True, exclude_none=True)
+ if host.mux_settings
+ else {},
+ "transport_settings": TransportSettings.model_validate(host.transport_settings).model_dump(
+ by_alias=True, exclude_none=True
+ )
+ if host.transport_settings
+ else {},
+ "status": host.status,
+ "ech_config_list": host.ech_config_list,
+ }
+
+
+@DictStorage
+async def hosts(storage: dict, db: AsyncSession):
+ inbounds_list = await core_manager.get_inbounds()
+ for tag in inbounds_list:
+ await get_or_create_inbound(db, tag)
+
+ storage.clear()
+ db_hosts = await get_hosts(db)
+
+ for host in db_hosts:
+ if host.is_disabled or (host.inbound_tag not in inbounds_list):
+ continue
+ downstream = None
+ if (
+ host.transport_settings
+ and host.transport_settings.get("xhttp_settings")
+ and (ds_host := host.transport_settings.get("xhttp_settings", {}).get("download_settings"))
+ ):
+ downstream = await get_host_by_id(db, ds_host)
+
+ host_data = _prepare_host_data(host)
+
+ if downstream:
+ host_data["downloadSettings"] = _prepare_host_data(downstream)
+ else:
+ host_data["downloadSettings"] = None
+
+ storage[host.id] = host_data
+
+
+@on_startup
+async def initialize_hosts():
+ async with GetDB() as db:
+ await hosts.update(db)
diff --git a/app/core/manager.py b/app/core/manager.py
new file mode 100644
index 000000000..43781f875
--- /dev/null
+++ b/app/core/manager.py
@@ -0,0 +1,97 @@
+from copy import deepcopy
+
+from aiorwlock import RWLock
+from aiocache import cached
+
+from app import on_startup
+from app.core.abstract_core import AbstractCore
+from app.core.xray import XRayConfig
+from app.db import GetDB
+from app.db.crud.core import get_core_configs
+from app.db.models import CoreConfig
+
+
+class CoreManager:
+ def __init__(self):
+ self._cores: dict[int, AbstractCore] = {}
+ self._lock = RWLock(fast=True)
+ self._inbounds: list[str] = []
+ self._inbounds_by_tag = {}
+
+ @staticmethod
+ def validate_core(
+ config: dict, exclude_inbounds: set[str] | None = None, fallbacks_inbounds: set[str] | None = None
+ ):
+ exclude_inbounds = exclude_inbounds or set()
+ fallbacks_inbounds = fallbacks_inbounds or set()
+ return XRayConfig(config, exclude_inbounds.copy(), fallbacks_inbounds.copy())
+
+ async def update_inbounds(self):
+ async with self._lock.writer_lock:
+ new_inbounds = {}
+ for core in self._cores.values():
+ new_inbounds.update(core.inbounds_by_tag)
+
+ self._inbounds_by_tag = new_inbounds
+ self._inbounds = list(self._inbounds_by_tag.keys())
+
+ await self.get_inbounds.cache.clear()
+ await self.get_inbounds_by_tag.cache.clear()
+
+ async def update_core(self, db_core_config: CoreConfig):
+ backend_config = self.validate_core(
+ db_core_config.config, db_core_config.exclude_inbound_tags, db_core_config.fallbacks_inbound_tags
+ )
+
+ async with self._lock.writer_lock:
+ self._cores.update({db_core_config.id: backend_config})
+
+ await self.update_inbounds()
+
+ async def remove_core(self, core_id: int):
+ async with self._lock.writer_lock:
+ core = self._cores.get(core_id, None)
+ if core:
+ del self._cores[core_id]
+ else:
+ return
+
+ await self.update_inbounds()
+
+ async def get_core(self, core_id: int) -> AbstractCore | None:
+ async with self._lock.reader_lock:
+ core = self._cores.get(core_id, None)
+
+ if not core:
+ core = self._cores.get(1)
+
+ return core
+
+ @cached()
+ async def get_inbounds(self) -> list[str]:
+ async with self._lock.reader_lock:
+ return deepcopy(self._inbounds)
+
+ @cached()
+ async def get_inbounds_by_tag(self) -> dict:
+ async with self._lock.reader_lock:
+ return deepcopy(self._inbounds_by_tag)
+
+ async def get_inbound_by_tag(self, tag) -> dict:
+ async with self._lock.reader_lock:
+ inbound = self._inbounds_by_tag.get(tag, None)
+ if not inbound:
+ return None
+ return deepcopy(inbound)
+
+
+core_manager = CoreManager()
+
+
+@on_startup
+async def init_core_manager():
+ async with GetDB() as db:
+ core_configs, _ = await get_core_configs(db)
+
+ for config in core_configs:
+ await core_manager.update_core(config)
diff --git a/app/core/xray.py b/app/core/xray.py
new file mode 100644
index 000000000..b626420bf
--- /dev/null
+++ b/app/core/xray.py
@@ -0,0 +1,413 @@
+from __future__ import annotations
+
+import base64
+import json
+from copy import deepcopy
+from pathlib import PosixPath
+from typing import Union
+
+import commentjson
+
+from app.utils.crypto import get_cert_SANs, get_x25519_public_key
+
+
+class XRayConfig(dict):
+ def __init__(
+ self,
+ config: Union[dict, str, PosixPath] = {},
+ exclude_inbound_tags: set[str] | None = set(),
+ fallbacks_inbound_tags: set[str] | None = set(),
+ ):
+ """Initialize the XRay config."""
+ if isinstance(config, str):
+ # considering string as json
+ config = commentjson.loads(config)
+
+ if isinstance(config, dict):
+ config = deepcopy(config)
+
+ super().__init__(config)
+ self._validate()
+
+ if exclude_inbound_tags is None:
+ exclude_inbound_tags = set()
+ if fallbacks_inbound_tags is None:
+ fallbacks_inbound_tags = set()
+ exclude_inbound_tags.update(fallbacks_inbound_tags)
+ self.exclude_inbound_tags = exclude_inbound_tags
+
+ self._inbounds = []
+ self._inbounds_by_tag = {}
+ self._fallbacks_inbound = []
+ for tag in fallbacks_inbound_tags:
+ inbound = self.get_inbound(tag)
+ if inbound:
+ self._fallbacks_inbound.append(inbound)
+ self._resolve_inbounds()
+
+ def _validate(self):
+ """Validate the config."""
+ if not self.get("inbounds"):
+ raise ValueError("config doesn't have inbounds")
+
+ if not self.get("outbounds"):
+ raise ValueError("config doesn't have outbounds")
+
+ for inbound in self["inbounds"]:
+ if not inbound.get("tag"):
+ raise ValueError("all inbounds must have a unique tag")
+ if "," in inbound.get("tag"):
+ raise ValueError("character «,» is not allowed in inbound tag")
+ if "<=>" in inbound.get("tag"):
+ raise ValueError("character «<=>» is not allowed in inbound tag")
+ for outbound in self["outbounds"]:
+ if not outbound.get("tag"):
+ raise ValueError("all outbounds must have a unique tag")
+
+ def _find_fallback_inbound(self, inbound: dict) -> list:
+ """Find fallback inbounds for an inbound."""
+ fallback_inbounds = []
+ for fallback in self._fallbacks_inbound:
+ for fallback_settings in fallback.get("settings", {}).get("fallbacks", []):
+ if fallback_settings.get("dest", "") == inbound.get("listen") or fallback_settings.get(
+ "dest", ""
+ ) == inbound.get("port", 0):
+ fallback_inbounds.append(fallback)
+ return fallback_inbounds
+
+ def _create_base_settings(self, inbound: dict) -> dict:
+ """Create base settings for an inbound."""
+ settings = {
+ "tag": inbound["tag"],
+ "protocol": inbound["protocol"],
+ "port": None,
+ "network": "tcp",
+ "tls": "none",
+ "sni": [],
+ "host": [],
+ "path": "",
+ "header_type": "",
+ "is_fallback": False,
+ "fallbacks": [],
+ }
+ return settings
+
+ def _handle_port_settings(self, inbound: dict, settings: dict):
+ """Handle port settings for an inbound."""
+ port_found = True
+ try:
+ settings["port"] = inbound["port"]
+ except KeyError:
+ port_found = False
+
+ if self._fallbacks_inbound and "<=>" not in inbound["tag"]:
+ if inbound.get("settings", {}).get("fallbacks", []):
+ if not port_found:
+ raise ValueError(f"{settings['tag']} inbound doesn't have port")
+ else:
+ return
+ fallbacks = self._find_fallback_inbound(inbound)
+ if fallbacks:
+ settings["is_fallback"] = True
+ settings["fallbacks"] = fallbacks
+ elif not port_found:
+ raise ValueError(f"{settings['tag']} inbound doesn't have port")
+
+ def _handle_tls_settings(self, tls_settings: dict, settings: dict, inbound_tag: str):
+ """Handle TLS security settings."""
+ settings["tls"] = "tls"
+ for certificate in tls_settings.get("certificates", []):
+ if certificate.get("certificateFile", None):
+ with open(certificate["certificateFile"], "rb") as file:
+ cert = file.read()
+ settings["sni"].extend(get_cert_SANs(cert))
+
+ if certificate.get("keyFile", None):
+ with open(certificate["keyFile"], "rb") as file:
+ key = file.read()
+ else:
+ raise ValueError(f"{inbound_tag} inbound doesn't keyFile in tlsSettings")
+
+ certificate["certificate"] = [line.decode() for line in cert.splitlines()]
+ certificate["key"] = [line.decode() for line in key.splitlines()]
+
+ del certificate["certificateFile"]
+ del certificate["keyFile"]
+
+ elif certificate.get("certificate", None):
+ cert = certificate["certificate"]
+ if isinstance(cert, list):
+ cert = "\n".join(cert)
+ if isinstance(cert, str):
+ cert = cert.encode()
+ settings["sni"].extend(get_cert_SANs(cert))
+
+ return tls_settings
+
+ def _handle_reality_settings(self, tls_settings: dict, settings: dict, inbound_tag: str):
+ """Handle Reality security settings."""
+ settings["fp"] = "chrome"
+ settings["tls"] = "reality"
+ settings["sni"] = tls_settings.get("serverNames", [])
+
+ pvk = tls_settings.get("privateKey")
+ if not pvk:
+ raise ValueError(f"You need to provide privateKey in realitySettings of {inbound_tag}")
+
+ settings["pbk"] = get_x25519_public_key(pvk)
+ if not settings.get("pbk"):
+ raise ValueError(f"You need to provide publicKey in realitySettings of {inbound_tag}")
+
+ try:
+ settings["sids"] = tls_settings.get("shortIds")
+ settings["sids"][0] # check if there is any shortIds
+ except (IndexError, TypeError):
+ raise ValueError(f"You need to define at least one shortID in realitySettings of {inbound_tag}")
+ try:
+ settings["spx"] = tls_settings.get("SpiderX")
+ except Exception:
+ settings["spx"] = ""
+
+ settings["mldsa65Verify"] = tls_settings.get("mldsa65Verify")
+
+ def _handle_network_settings(self, net: str, net_settings: dict, settings: dict, inbound_tag: str):
+ """Handle network-specific settings."""
+ if net in ("tcp", "raw"):
+ self._handle_tcp_raw_settings(net_settings, settings, inbound_tag)
+ elif net == "ws":
+ self._handle_ws_settings(net_settings, settings)
+ elif net in ("grpc", "gun"):
+ self._handle_grpc_settings(net_settings, settings)
+ elif net == "quic":
+ self._handle_quic_settings(net_settings, settings)
+ elif net == "httpupgrade":
+ self._handle_httpupgrade_settings(net_settings, settings)
+ elif net in ("splithttp", "xhttp"):
+ self._handle_xhttp_settings(net_settings, settings)
+ elif net == "kcp":
+ self._handle_kcp_settings(net_settings, settings)
+ elif net in ("http", "h2", "h3"):
+ self._handle_http_settings(net_settings, settings)
+ else:
+ self._handle_default_network_settings(net_settings, settings)
+
+ def _handle_tcp_raw_settings(self, net_settings: dict, settings: dict, inbound_tag: str):
+ """Handle TCP and RAW network settings."""
+ header = net_settings.get("header", {})
+ request = header.get("request", {})
+ path = request.get("path")
+ host = request.get("headers", {}).get("Host")
+
+ settings["header_type"] = header.get("type", "none")
+
+ if isinstance(path, str) or isinstance(host, str):
+ raise ValueError(
+ f"Settings of {inbound_tag} for path and host must be list, not str\n"
+ "https://xtls.github.io/config/transports/tcp.html#httpheaderobject"
+ )
+
+ if path and isinstance(path, list):
+ settings["path"] = path[0]
+
+ if host and isinstance(host, list):
+ settings["host"] = host
+
+ def _handle_ws_settings(self, net_settings: dict, settings: dict):
+ """Handle WebSocket network settings."""
+ path = net_settings.get("path", "")
+ host = net_settings.get("host", "") or net_settings.get("headers", {}).get("Host")
+
+ settings["header_type"] = ""
+
+ if isinstance(path, list) or isinstance(host, list):
+ raise ValueError(
+ "Settings for path and host must be str, not list\n"
+ "https://xtls.github.io/config/transports/websocket.html#websocketobject"
+ )
+
+ if isinstance(path, str):
+ settings["path"] = path
+
+ if isinstance(host, str):
+ settings["host"] = [host]
+
+ def _handle_grpc_settings(self, net_settings: dict, settings: dict):
+ """Handle gRPC network settings."""
+ settings["header_type"] = ""
+ settings["path"] = net_settings.get("serviceName", "")
+ host = net_settings.get("authority", "")
+ settings["host"] = [host]
+
+ def _handle_quic_settings(self, net_settings: dict, settings: dict):
+ """Handle QUIC network settings."""
+ settings["header_type"] = net_settings.get("header", {}).get("type", "")
+ settings["path"] = net_settings.get("key", "")
+ settings["host"] = [net_settings.get("security", "")]
+
+ def _handle_httpupgrade_settings(self, net_settings: dict, settings: dict):
+ """Handle HTTP Upgrade network settings."""
+ settings["path"] = net_settings.get("path", "")
+ host = net_settings.get("host", "")
+ settings["host"] = [host]
+
+ def _handle_xhttp_settings(self, net_settings: dict, settings: dict):
+ """Handle XHTTP network settings."""
+ settings["path"] = net_settings.get("path", "")
+ host = net_settings.get("host", "")
+ settings["host"] = [host]
+ settings["mode"] = net_settings.get("mode", "auto")
+
+ def _handle_kcp_settings(self, net_settings: dict, settings: dict):
+ """Handle KCP network settings."""
+ header = net_settings.get("header", {})
+ settings["header_type"] = header.get("type", "")
+ settings["host"] = header.get("domain", "")
+ settings["path"] = net_settings.get("seed", "")
+
+ def _handle_http_settings(self, net_settings: dict, settings: dict):
+ """Handle HTTP network settings."""
+ settings["host"] = net_settings.get("host") or net_settings.get("Host", "")
+ settings["path"] = net_settings.get("path", "")
+
+ def _handle_default_network_settings(self, net_settings: dict, settings: dict):
+ """Handle default network settings."""
+ settings["path"] = net_settings.get("path", "")
+ host = net_settings.get("host", {}) or net_settings.get("Host", {})
+ if host and isinstance(host, str):
+ settings["host"] = host
+ elif host and isinstance(host, list):
+ settings["host"] = host[0]
+
+ def _handle_shadowsocks_settings(self, inbound_settings: dict, settings: dict):
+ """Handle shadowsocks special settings."""
+ settings["method"] = inbound_settings.get("method", "")
+ if settings["method"] == "2022-blake3-chacha20-poly1305":
+ raise ValueError("only 2022-blake3-aes-*-gcm methods are supported")
+ if settings["method"].startswith("2022-blake3"):
+ settings["is_2022"] = True
+ password = inbound_settings.get("password", "")
+
+ # Validate if password is a valid base64 string
+ try:
+ base64.b64decode(password, validate=True)
+ settings["password"] = password
+ except Exception:
+ raise ValueError("Shadowsocks password must be a valid base64 string")
+ else:
+ settings["is_2022"] = False
+ settings["header_type"] = "none"
+
+ def _resolve_inbounds(self):
+ """Resolve all inbounds and their settings."""
+ for inbound in self["inbounds"]:
+ self._read_inbound(inbound)
+
+ def _read_inbound(self, inbound: dict):
+ """Read an inbound and its settings."""
+ if inbound["protocol"] not in ("vmess", "vless", "trojan", "shadowsocks"):
+ return
+
+ if inbound["tag"] in self.exclude_inbound_tags:
+ return
+
+ if not inbound.get("settings"):
+ inbound["settings"] = {}
+ if not inbound["settings"].get("clients"):
+ inbound["settings"]["clients"] = []
+
+ settings = self._create_base_settings(inbound)
+ self._handle_port_settings(inbound, settings)
+
+ if inbound["protocol"] == "vless":
+ settings["flow"] = inbound.get("settings").get("flow", "")
+ vless_decryption = inbound.get("settings").get("decryption", "none")
+ vless_encryption = inbound.get("settings").get("encryption", "none")
+ if vless_decryption != "none" and vless_encryption == "none":
+ raise ValueError(f"'encryption' key must be provided in {inbound['tag']} inbound")
+ settings["encryption"] = vless_encryption
+
+ if inbound["protocol"] == "shadowsocks":
+ self._handle_shadowsocks_settings(inbound["settings"], settings)
+
+ if stream := inbound.get("streamSettings"):
+ net = stream.get("network", "tcp")
+ net_settings = stream.get(f"{net}Settings", {})
+ security = stream.get("security")
+ tls_settings = stream.get(f"{security}Settings")
+
+ if settings["is_fallback"] is True:
+ for fallback in settings["fallbacks"]:
+ fallback_tag = f"{inbound['tag']}<=>{fallback['tag']}" # Fake inbound tag
+ if fallback_tag in self._inbounds_by_tag:
+ continue
+ try:
+ fallback_port = fallback["port"]
+ except KeyError:
+ raise ValueError("fallbacks inbound doesn't have port")
+ fallback_security = fallback.get("streamSettings", {}).get("security")
+ fallback_tls_settings = fallback.get("streamSettings", {}).get(f"{fallback_security}Settings", {})
+ fallback_inbound = self._make_fallback_inbound(
+ deepcopy(inbound), fallback_security, fallback_tls_settings, fallback_tag, fallback_port
+ )
+ self._read_inbound(fallback_inbound)
+
+ settings["network"] = net
+
+ if security == "tls":
+ stream["tlsSettings"] = self._handle_tls_settings(tls_settings, settings, inbound["tag"])
+ elif security == "reality":
+ self._handle_reality_settings(tls_settings, settings, inbound["tag"])
+
+ self._handle_network_settings(net, net_settings, settings, inbound["tag"])
+
+ if inbound["tag"] not in self._inbounds:
+ self._inbounds.append(inbound["tag"])
+ self._inbounds_by_tag[inbound["tag"]] = settings
+
+ def _make_fallback_inbound(
+ self,
+ inbound: dict,
+ security: str,
+ tls_settings: dict,
+ inbound_tag: str,
+ port: int | str,
+ ):
+ """Make a fallback inbound."""
+ fallback_inbound = {
+ **inbound,
+ "port": port,
+ "tag": inbound_tag,
+ }
+ fallback_inbound["streamSettings"]["security"] = security
+ fallback_inbound["streamSettings"][f"{security}Settings"] = tls_settings
+ return fallback_inbound
+
+ def get_inbound(self, tag) -> dict:
+ """Get an inbound by tag."""
+ for inbound in self["inbounds"]:
+ if inbound["tag"] == tag:
+ return inbound
+
+ def get_outbound(self, tag) -> dict:
+ """Get an outbound by tag."""
+ for outbound in self["outbounds"]:
+ if outbound["tag"] == tag:
+ return outbound
+
+ def to_str(self, **json_kwargs) -> str:
+ """Convert the config to a JSON string."""
+ return json.dumps(self, **json_kwargs)
+
+ @property
+ def inbounds_by_tag(self) -> dict:
+ """Get inbounds by tag."""
+ return self._inbounds_by_tag
+
+ @property
+ def inbounds(self) -> list[str]:
+ """Get inbounds by tag."""
+ return self._inbounds
+
+ def copy(self):
+ """Copy the config."""
+ return deepcopy(self)
diff --git a/app/db/__init__.py b/app/db/__init__.py
new file mode 100644
index 000000000..5cc091f93
--- /dev/null
+++ b/app/db/__init__.py
@@ -0,0 +1,16 @@
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from .base import Base, GetDB, get_db # noqa
+
+
+from .models import JWT, System, User # noqa
+
+__all__ = [
+ "GetDB",
+ "get_db",
+ "User",
+ "System",
+ "JWT",
+ "Base",
+ "AsyncSession",
+]
diff --git a/app/db/base.py b/app/db/base.py
new file mode 100644
index 000000000..16ed862f0
--- /dev/null
+++ b/app/db/base.py
@@ -0,0 +1,62 @@
+from sqlalchemy.exc import SQLAlchemyError
+from sqlalchemy.ext.asyncio import AsyncAttrs, async_sessionmaker, create_async_engine
+from sqlalchemy.orm import DeclarativeBase, MappedAsDataclass
+
+from config import (
+ ECHO_SQL_QUERIES,
+ SQLALCHEMY_DATABASE_URL,
+ SQLALCHEMY_MAX_OVERFLOW,
+ SQLALCHEMY_POOL_SIZE,
+)
+
+IS_SQLITE = SQLALCHEMY_DATABASE_URL.startswith("sqlite")
+
+if IS_SQLITE:
+ engine = create_async_engine(
+ SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}, echo=ECHO_SQL_QUERIES
+ )
+else:
+ engine = create_async_engine(
+ SQLALCHEMY_DATABASE_URL,
+ pool_size=SQLALCHEMY_POOL_SIZE,
+ max_overflow=SQLALCHEMY_MAX_OVERFLOW,
+ pool_recycle=300,
+ pool_timeout=5,
+ pool_pre_ping=True,
+ echo=ECHO_SQL_QUERIES,
+ )
+
+SessionLocal = async_sessionmaker(autocommit=False, autoflush=False, bind=engine)
+
+# Determine dialect once at startup based on connection URL
+if SQLALCHEMY_DATABASE_URL.startswith("sqlite"):
+ DATABASE_DIALECT = "sqlite"
+elif SQLALCHEMY_DATABASE_URL.startswith("postgresql"):
+ DATABASE_DIALECT = "postgresql"
+elif SQLALCHEMY_DATABASE_URL.startswith("mysql"):
+ DATABASE_DIALECT = "mysql"
+else:
+ raise ValueError("Unsupported database URL")
+
+
+class Base(DeclarativeBase, MappedAsDataclass, AsyncAttrs):
+ pass
+
+
+class GetDB: # Context Manager
+ def __init__(self):
+ self.db = SessionLocal()
+
+ async def __aenter__(self):
+ return self.db
+
+ async def __aexit__(self, exc_type, exc_value, traceback):
+ if isinstance(exc_value, SQLAlchemyError):
+ await self.db.rollback() # rollback on exception
+
+ await self.db.close()
+
+
+async def get_db(): # Dependency
+ async with GetDB() as db:
+ yield db
diff --git a/app/db/compiles_types.py b/app/db/compiles_types.py
new file mode 100644
index 000000000..e6aa4a7e7
--- /dev/null
+++ b/app/db/compiles_types.py
@@ -0,0 +1,118 @@
+from sqlalchemy import String, Numeric, TypeDecorator
+from sqlalchemy.sql.expression import FunctionElement
+from sqlalchemy.ext.compiler import compiles
+
+
+class CaseSensitiveString(String):
+ def __init__(self, length=None):
+ super(CaseSensitiveString, self).__init__(length)
+
+
+# Modify how this type is handled for each dialect
+@compiles(CaseSensitiveString, "sqlite")
+def compile_cs_sqlite(element, compiler, **kw):
+ return f"VARCHAR({element.length}) COLLATE BINARY" # BINARY is case-sensitive in SQLite
+
+
+@compiles(CaseSensitiveString, "postgresql")
+def compile_cs_postgresql(element, compiler, **kw):
+ return f'VARCHAR({element.length}) COLLATE "C"' # "C" collation is case-sensitive
+
+
+@compiles(CaseSensitiveString, "mysql")
+def compile_cs_mysql(element, compiler, **kw):
+ return f"VARCHAR({element.length}) COLLATE utf8mb4_bin" # utf8mb4_bin is case-sensitive
+
+
+class EnumArray(TypeDecorator):
+ """Custom SQLAlchemy type to handle Enum lists as a comma-separated string."""
+
+ impl = String
+
+ def __init__(self, enum_cls, length=255):
+ super(EnumArray, self).__init__(length=length)
+ self.enum_cls = enum_cls
+
+ def process_bind_param(self, value, dialect):
+ """Convert Enum list to a comma-separated string for storage."""
+ if value is None:
+ return None
+ return ",".join([v.value for v in value])
+
+ def process_result_value(self, value, dialect):
+ """Convert stored comma-separated string back to an Enum list."""
+ if value is None:
+ return None
+ if isinstance(value, str):
+ return [self.enum_cls(v) for v in value.split(",") if v]
+ return [self.enum_cls(v) for v in value]
+
+
+class StringArray(TypeDecorator):
+ """Custom SQLAlchemy type to handle String lists as a comma-separated string."""
+
+ impl = String
+
+ def __init__(self, length=255, **kwargs):
+ super(StringArray, self).__init__(length=length, **kwargs)
+
+ def process_bind_param(self, value, dialect):
+ """Convert list to a comma-separated string for storage."""
+ if not value:
+ return ""
+ return ",".join([str(v) for v in value])
+
+ def process_result_value(self, value, dialect):
+ """Convert stored comma-separated string back to a StringArraySet."""
+ if value is None:
+ return set()
+ if isinstance(value, str):
+ return set(v for v in value.split(",") if v)
+ return set(value)
+
+
+class DaysDiff(FunctionElement):
+ type = Numeric()
+ name = "days_diff"
+ inherit_cache = True
+
+
+@compiles(DaysDiff, "postgresql")
+def compile_days_diff_postgresql(element, compiler, **kw):
+ return "EXTRACT(EPOCH FROM (expire - CURRENT_TIMESTAMP)) / 86400"
+
+
+@compiles(DaysDiff, "mysql")
+def compile_days_diff_mysql(element, compiler, **kw):
+ return "DATEDIFF(expire, UTC_TIMESTAMP())"
+
+
+@compiles(DaysDiff, "sqlite")
+def compile_days_diff_sqlite(element, compiler, **kw):
+ return "(julianday(expire) - julianday('now'))"
+
+
+class DateDiff(FunctionElement):
+ type = Numeric()
+ name = "date_diff"
+ inherit_cache = True
+
+ def __init__(self, date1, date2, **kwargs):
+ super().__init__(**kwargs)
+ self.date1 = date1
+ self.date2 = date2
+
+
+@compiles(DateDiff, "postgresql")
+def compile_date_diff_postgresql(element, compiler, **kw):
+ return f"EXTRACT(EPOCH FROM ({compiler.process(element.date1)} - {compiler.process(element.date2)})) / 86400"
+
+
+@compiles(DateDiff, "mysql")
+def compile_date_diff_mysql(element, compiler, **kw):
+ return f"DATEDIFF({compiler.process(element.date1)}, {compiler.process(element.date2)})"
+
+
+@compiles(DateDiff, "sqlite")
+def compile_date_diff_sqlite(element, compiler, **kw):
+ return f"julianday({compiler.process(element.date1)}) - julianday({compiler.process(element.date2)})"
diff --git a/app/db/crud/__init__.py b/app/db/crud/__init__.py
new file mode 100644
index 000000000..9197113af
--- /dev/null
+++ b/app/db/crud/__init__.py
@@ -0,0 +1,18 @@
+from .admin import get_admin
+from .core import get_core_config_by_id
+from .group import get_group_by_id
+from .host import get_host_by_id
+from .node import get_node_by_id
+from .user import get_user
+from .user_template import get_user_template
+
+
+__all__ = [
+ "get_admin",
+ "get_core_config_by_id",
+ "get_group_by_id",
+ "get_host_by_id",
+ "get_node_by_id",
+ "get_user",
+ "get_user_template",
+]
diff --git a/app/db/crud/admin.py b/app/db/crud/admin.py
new file mode 100644
index 000000000..22d0f7b3b
--- /dev/null
+++ b/app/db/crud/admin.py
@@ -0,0 +1,217 @@
+from datetime import datetime, timezone
+
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.db.models import Admin, AdminUsageLogs
+from app.models.admin import AdminCreate, AdminModify
+
+
+async def load_admin_attrs(admin: Admin):
+ await admin.awaitable_attrs.users
+ await admin.awaitable_attrs.usage_logs
+
+
+async def get_admin(db: AsyncSession, username: str) -> Admin:
+ """
+ Retrieves an admin by username.
+
+ Args:
+ db (AsyncSession): Database session.
+ username (str): The username of the admin.
+
+ Returns:
+ Admin: The admin object.
+ """
+ admin = (await db.execute(select(Admin).where(Admin.username == username))).unique().scalar_one_or_none()
+ if admin:
+ await load_admin_attrs(admin)
+ return admin
+
+
+async def create_admin(db: AsyncSession, admin: AdminCreate) -> Admin:
+ """
+ Creates a new admin in the database.
+
+ Args:
+ db (AsyncSession): Database session.
+ admin (AdminCreate): The admin creation data.
+
+ Returns:
+ Admin: The created admin object.
+ """
+ db_admin = Admin(**admin.model_dump(exclude={"password"}), hashed_password=admin.hashed_password)
+ db.add(db_admin)
+ await db.commit()
+ await db.refresh(db_admin)
+ await load_admin_attrs(db_admin)
+ return db_admin
+
+
+async def update_admin(db: AsyncSession, db_admin: Admin, modified_admin: AdminModify) -> Admin:
+ """
+ Updates an admin's details.
+
+ Args:
+ db (AsyncSession): Database session.
+ dbadmin (Admin): The admin object to be updated.
+ modified_admin (AdminModify): The modified admin data.
+
+ Returns:
+ Admin: The updated admin object.
+ """
+ if modified_admin.is_sudo is not None:
+ db_admin.is_sudo = modified_admin.is_sudo
+ if modified_admin.is_disabled is not None:
+ db_admin.is_disabled = modified_admin.is_disabled
+ if modified_admin.hashed_password is not None and db_admin.hashed_password != modified_admin.hashed_password:
+ db_admin.hashed_password = modified_admin.hashed_password
+ db_admin.password_reset_at = datetime.now(timezone.utc)
+ if modified_admin.telegram_id is not None:
+ db_admin.telegram_id = modified_admin.telegram_id
+ if modified_admin.discord_webhook is not None:
+ db_admin.discord_webhook = modified_admin.discord_webhook
+ if modified_admin.discord_id is not None:
+ db_admin.discord_id = modified_admin.discord_id
+ if modified_admin.sub_template is not None:
+ db_admin.sub_template = modified_admin.sub_template
+ if modified_admin.sub_domain is not None:
+ db_admin.sub_domain = modified_admin.sub_domain
+ if modified_admin.support_url is not None:
+ db_admin.support_url = modified_admin.support_url
+ if modified_admin.profile_title is not None:
+ db_admin.profile_title = modified_admin.profile_title
+
+ await db.commit()
+ await load_admin_attrs(db_admin)
+ return db_admin
+
+
+async def remove_admin(db: AsyncSession, dbadmin: Admin) -> None:
+ """
+ Removes an admin from the database.
+
+ Args:
+ db (AsyncSession): Database session.
+ dbadmin (Admin): The admin object to be removed.
+ """
+ await db.delete(dbadmin)
+ await db.commit()
+
+
+async def get_admin_by_id(db: AsyncSession, id: int) -> Admin:
+ """
+ Retrieves an admin by their ID.
+
+ Args:
+ db (AsyncSession): Database session.
+ id (int): The ID of the admin.
+
+ Returns:
+ Admin: The admin object.
+ """
+ admin = (await db.execute(select(Admin).where(Admin.id == id))).first()
+ if admin:
+ await load_admin_attrs(admin)
+ return admin
+
+
+async def get_admin_by_telegram_id(db: AsyncSession, telegram_id: int) -> Admin:
+ """
+ Retrieves an admin by their Telegram ID.
+
+ Args:
+ db (AsyncSession): Database session.
+ telegram_id (int): The Telegram ID of the admin.
+
+ Returns:
+ Admin: The admin object.
+ """
+ admin = (await db.execute(select(Admin).where(Admin.telegram_id == telegram_id))).scalar_one_or_none()
+ if admin:
+ await load_admin_attrs(admin)
+ return admin
+
+
+async def get_admin_by_discord_id(db: AsyncSession, discord_id: int) -> Admin:
+ """
+ Retrieves an admin by their Discord ID.
+
+ Args:
+ db (AsyncSession): Database session.
+ discord_id (int): The Discord ID of the admin.
+
+ Returns:
+ Admin: The admin object.
+ """
+ admin = (await db.execute(select(Admin).where(Admin.discord_id == discord_id))).first()
+ if admin:
+ await load_admin_attrs(admin)
+ return admin
+
+
+async def get_admins(
+ db: AsyncSession, offset: int | None = None, limit: int | None = None, username: str | None = None
+) -> list[Admin]:
+ """
+ Retrieves a list of admins with optional filters and pagination.
+
+ Args:
+ db (AsyncSession): Database session.
+ offset (Optional[int]): The number of records to skip (for pagination).
+ limit (Optional[int]): The maximum number of records to return.
+ username (Optional[str]): The username to filter by.
+
+ Returns:
+ List[Admin]: A list of admin objects.
+ """
+ query = select(Admin)
+ if username:
+ query = query.where(Admin.username.ilike(f"%{username}%"))
+ if offset:
+ query = query.offset(offset)
+ if limit:
+ query = query.limit(limit)
+
+ admins = (await db.execute(query)).scalars().all()
+
+ for admin in admins:
+ await load_admin_attrs(admin)
+
+ return admins
+
+
+async def reset_admin_usage(db: AsyncSession, db_admin: Admin) -> Admin:
+ """
+ Retrieves an admin's usage by their username.
+ Args:
+ db (AsyncSession): Database session.
+ db_admin (Admin): The admin object to be updated.
+ Returns:
+ Admin: The updated admin.
+ """
+ if db_admin.used_traffic == 0:
+ return db_admin
+
+ usage_log = AdminUsageLogs(admin_id=db_admin.id, used_traffic_at_reset=db_admin.used_traffic)
+ db.add(usage_log)
+ db_admin.used_traffic = 0
+
+ await db.commit()
+ await db.refresh(db_admin)
+ await load_admin_attrs(db_admin)
+ return db_admin
+
+
+async def get_admins_count(db: AsyncSession) -> int:
+ """
+ Retrieves the total count of admins.
+
+ Args:
+ db (AsyncSession): Database session.
+
+ Returns:
+ int: The total number of admins.
+ """
+ count = (await db.execute(select(func.count(Admin.id)))).scalar_one()
+ return count
diff --git a/app/db/crud/bulk.py b/app/db/crud/bulk.py
new file mode 100644
index 000000000..3af6057c3
--- /dev/null
+++ b/app/db/crud/bulk.py
@@ -0,0 +1,416 @@
+from datetime import datetime as dt, timezone as tz
+from typing import Optional
+
+from sqlalchemy import and_, case, cast, delete, func, or_, select, text, update
+from sqlalchemy.dialects.postgresql import JSONB
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.db.base import DATABASE_DIALECT
+from app.db.models import (
+ Admin,
+ Group,
+ NextPlan,
+ NodeUserUsage,
+ User,
+ UserStatus,
+ UserUsageResetLogs,
+ users_groups_association,
+)
+from app.models.group import BulkGroup
+from app.models.user import BulkUser, BulkUsersProxy
+
+from .general import get_datetime_add_expression
+from .user import load_user_attrs
+
+
+async def reset_all_users_data_usage(db: AsyncSession, admin: Optional[Admin] = None):
+ """
+ Efficiently resets data usage for all users, or users under a specific admin if provided.
+
+ This function performs a high-performance reset by executing bulk database operations
+ rather than iterating over ORM-mapped objects, improving speed and reducing memory usage.
+
+ Operations performed:
+ - Sets `used_traffic` to 0 for all target users.
+ - Sets `status` to `active` for all users, unless filtered by admin.
+ - Deletes all related `UserUsageResetLogs`, `NodeUserUsage`, and `NextPlan` entries.
+
+ Args:
+ db (AsyncSession): The SQLAlchemy async session used for database operations.
+ admin (Optional[Admin]): If provided, only resets data usage for users belonging to this admin.
+ If None, resets usage for all users in the system.
+
+ Notes:
+ - All operations are executed in bulk for performance.
+ - This function assumes proper foreign key constraints and cascading rules are in place.
+ - The function commits changes at the end of the operation.
+ """
+ user_ids_query = select(User.id).where(User.admin_id == admin.id) if admin else select(User.id)
+ user_ids = (await db.execute(user_ids_query)).scalars().all()
+
+ if not user_ids:
+ return
+
+ await db.execute(update(User).where(User.id.in_(user_ids)).values(used_traffic=0, status=UserStatus.active))
+
+ await db.execute(delete(UserUsageResetLogs).where(UserUsageResetLogs.user_id.in_(user_ids)))
+ await db.execute(delete(NodeUserUsage).where(NodeUserUsage.user_id.in_(user_ids)))
+ await db.execute(delete(NextPlan).where(NextPlan.user_id.in_(user_ids)))
+
+ await db.commit()
+
+
+async def disable_all_active_users(db: AsyncSession, admin: Admin | None = None):
+ """
+ Disable all active users or users under a specific admin.
+
+ Args:
+ db (AsyncSession): Database session.
+ admin (Optional[Admin]): Admin to filter users by, if any.
+ """
+ query = update(User).where(User.status.in_((UserStatus.active, UserStatus.on_hold)))
+ if admin:
+ query = query.filter(User.admin_id == admin.id)
+
+ await db.execute(
+ query.values(
+ {User.status: UserStatus.disabled, User.last_status_change: dt.now(tz.utc)},
+ )
+ )
+
+ await db.commit()
+ await db.refresh(admin)
+
+
+async def activate_all_disabled_users(db: AsyncSession, admin: Admin | None = None):
+ """
+ Activate all disabled users or users under a specific admin.
+
+ Args:
+ db (AsyncSession): Database session.
+ admin (Optional[Admin]): Admin to filter users by, if any.
+ """
+ query_for_active_users = update(User).where(User.status == UserStatus.disabled)
+ query_for_on_hold_users = update(User).where(
+ and_(
+ User.status == UserStatus.disabled,
+ User.expire.is_(None),
+ User.on_hold_expire_duration.isnot(None),
+ )
+ )
+ if admin:
+ query_for_active_users = query_for_active_users.where(User.admin_id == admin.id)
+ query_for_on_hold_users = query_for_on_hold_users.where(User.admin_id == admin.id)
+
+ await db.execute(
+ query_for_on_hold_users.values(
+ {User.status: UserStatus.on_hold, User.last_status_change: dt.now(tz.utc)},
+ )
+ )
+ await db.execute(
+ query_for_active_users.values(
+ {User.status: UserStatus.active, User.last_status_change: dt.now(tz.utc)},
+ )
+ )
+
+ await db.commit()
+ await db.refresh(admin)
+
+
+def _create_group_filter(bulk_model: BulkGroup):
+ """Create a comprehensive SQLAlchemy filter condition from a BulkGroup model."""
+ other_conditions = []
+ if bulk_model.admins:
+ other_conditions.append(User.admin_id.in_(bulk_model.admins))
+ if bulk_model.has_group_ids:
+ other_conditions.append(User.groups.any(Group.id.in_(bulk_model.has_group_ids)))
+
+ user_ids = bulk_model.users or []
+
+ filter_conditions = []
+ if user_ids:
+ filter_conditions.append(User.id.in_(user_ids))
+ if other_conditions:
+ filter_conditions.append(and_(*other_conditions))
+
+ if len(filter_conditions) > 1:
+ return or_(*filter_conditions)
+ elif filter_conditions:
+ return filter_conditions[0]
+ else:
+ return True
+
+
+async def add_groups_to_users(db: AsyncSession, bulk_model: BulkGroup) -> tuple[list, int] | tuple[list[User], int]:
+ """
+ Bulk add groups to users and return list of affected User objects.
+ """
+ final_filter = _create_group_filter(bulk_model)
+
+ # Get target user IDs
+ result = await db.execute(select(User.id).where(final_filter))
+ user_ids = {row[0] for row in result.all()}
+
+ count_effctive_users = len(user_ids)
+
+ if not user_ids:
+ return [], count_effctive_users
+
+ # Fetch existing associations for target users
+ existing = await db.execute(
+ select(users_groups_association).where(users_groups_association.c.user_id.in_(user_ids))
+ )
+ existing_pairs = {(r.user_id, r.groups_id) for r in existing.all()}
+
+ # Prepare new associations
+ new_rows = [
+ {"user_id": uid, "groups_id": gid}
+ for uid in user_ids
+ for gid in bulk_model.group_ids
+ if (uid, gid) not in existing_pairs
+ ]
+
+ if not new_rows:
+ return [], count_effctive_users
+
+ await db.execute(users_groups_association.insert(), new_rows)
+ await db.commit()
+
+ # Return users that actually had groups added
+ result = await db.execute(select(User).where(User.id.in_({r["user_id"] for r in new_rows})))
+ users = result.scalars().all()
+ for user in users:
+ await load_user_attrs(user)
+ return users, count_effctive_users
+
+
+async def remove_groups_from_users(
+ db: AsyncSession, bulk_model: BulkGroup
+) -> tuple[list, int] | tuple[list[User], int]:
+ """
+ Bulk remove groups from users and return list of affected User objects.
+ """
+ final_filter = _create_group_filter(bulk_model)
+
+ # Get target user IDs
+ result = await db.execute(select(User.id).where(final_filter))
+ user_ids = {row[0] for row in result.all()}
+
+ count_effctive_users = len(user_ids)
+
+ if not user_ids:
+ return [], count_effctive_users
+
+ # Identify affected users (those who actually have the groups to be removed)
+ subquery = (
+ select(users_groups_association.c.user_id)
+ .where(
+ and_(
+ users_groups_association.c.user_id.in_(user_ids),
+ users_groups_association.c.groups_id.in_(bulk_model.group_ids),
+ )
+ )
+ .distinct()
+ )
+ result = await db.execute(select(User).where(User.id.in_(subquery)))
+ users = result.scalars().all()
+
+ if not users:
+ return [], count_effctive_users
+
+ await db.execute(
+ delete(users_groups_association).where(
+ users_groups_association.c.user_id.in_([u.id for u in users]),
+ users_groups_association.c.groups_id.in_(bulk_model.group_ids),
+ )
+ )
+ await db.commit()
+ for user in users:
+ await load_user_attrs(user)
+ return users, count_effctive_users
+
+
+def _create_final_filter(bulk_model: BulkUser | BulkUsersProxy):
+ """Create a comprehensive SQLAlchemy filter condition from a bulk model."""
+ other_conditions = []
+ if hasattr(bulk_model, "status") and bulk_model.status:
+ other_conditions.append(User.status.in_([i.value for i in bulk_model.status]))
+ if bulk_model.admins:
+ other_conditions.append(User.admin_id.in_([i for i in bulk_model.admins]))
+ if bulk_model.group_ids:
+ other_conditions.append(User.groups.any(Group.id.in_(bulk_model.group_ids)))
+
+ user_ids = bulk_model.users or []
+
+ filter_conditions = []
+ if user_ids:
+ filter_conditions.append(User.id.in_(user_ids))
+ if other_conditions:
+ filter_conditions.append(and_(*other_conditions))
+
+ if len(filter_conditions) > 1:
+ return or_(*filter_conditions)
+ elif filter_conditions:
+ return filter_conditions[0]
+ else:
+ return True
+
+
+async def update_users_expire(db: AsyncSession, bulk_model: BulkUser) -> tuple[list[User], int] | tuple[list, int]:
+ """
+ Bulk update user expiration dates and return list of User objects where status changed.
+ """
+ final_filter = _create_final_filter(bulk_model)
+
+ count_effctive_users = (
+ await db.execute(select(func.count(User.id)).where(and_(final_filter, User.expire.isnot(None))))
+ ).scalar_one_or_none() or 0
+ # Get database-specific datetime addition expression
+ new_expire = get_datetime_add_expression(User.expire, bulk_model.amount)
+ current_time = dt.now(tz.utc)
+
+ # First, get the users that will have status changes BEFORE updating
+ status_change_conditions = or_(
+ and_(new_expire <= current_time, User.status == UserStatus.active),
+ and_(new_expire > current_time, User.status == UserStatus.expired),
+ )
+
+ # Get IDs of users whose status will change
+ result = await db.execute(
+ select(User.id).where(and_(final_filter, User.expire.isnot(None), status_change_conditions))
+ )
+ status_changed_user_ids = [row[0] for row in result.fetchall()]
+
+ # Perform the update
+ status_cases = [
+ (and_(new_expire <= current_time, User.status == UserStatus.active), UserStatus.expired),
+ (and_(new_expire > current_time, User.status == UserStatus.expired), UserStatus.active),
+ ]
+
+ await db.execute(
+ update(User)
+ .where(and_(final_filter, User.expire.isnot(None)))
+ .values(expire=new_expire, status=case(*status_cases, else_=User.status))
+ )
+ await db.commit()
+
+ # Return the users whose status changed
+ if status_changed_user_ids:
+ result = await db.execute(select(User).where(User.id.in_(status_changed_user_ids)))
+ users = result.scalars().all()
+ for user in users:
+ await load_user_attrs(user)
+ return users, count_effctive_users
+ return [], count_effctive_users
+
+
+async def update_users_datalimit(db: AsyncSession, bulk_model: BulkUser) -> tuple[list[User], int] | tuple[list, int]:
+ """
+ Bulk update user data limits and return list of User objects where status changed.
+ """
+ final_filter = _create_final_filter(bulk_model)
+
+ count_effctive_users = (
+ await db.execute(
+ select(func.count(User.id)).where(and_(final_filter, User.data_limit.isnot(None), User.data_limit != 0))
+ )
+ ).scalar_one_or_none() or 0
+
+ # First, get the users that will have status changes BEFORE updating
+ status_change_conditions = or_(
+ and_(User.data_limit + bulk_model.amount <= User.used_traffic, User.status == UserStatus.active),
+ and_(User.data_limit + bulk_model.amount > User.used_traffic, User.status == UserStatus.limited),
+ )
+
+ # Get IDs of users whose status will change
+ result = await db.execute(
+ select(User.id).where(
+ and_(final_filter, User.data_limit.isnot(None), User.data_limit != 0, status_change_conditions)
+ )
+ )
+ status_changed_user_ids = [row[0] for row in result.fetchall()]
+
+ # Perform the update
+ status_cases = [
+ (
+ and_(User.data_limit + bulk_model.amount <= User.used_traffic, User.status == UserStatus.active),
+ UserStatus.limited,
+ ),
+ (
+ and_(User.data_limit + bulk_model.amount > User.used_traffic, User.status == UserStatus.limited),
+ UserStatus.active,
+ ),
+ ]
+
+ await db.execute(
+ update(User)
+ .where(and_(final_filter, User.data_limit.isnot(None), User.data_limit != 0))
+ .values(data_limit=User.data_limit + bulk_model.amount, status=case(*status_cases, else_=User.status))
+ )
+
+ await db.commit()
+
+ # Return the users whose status changed
+ if status_changed_user_ids:
+ result = await db.execute(select(User).where(User.id.in_(status_changed_user_ids)))
+ users = result.scalars().all()
+ for user in users:
+ await load_user_attrs(user)
+ return users, count_effctive_users
+ return [], count_effctive_users
+
+
+async def update_users_proxy_settings(
+ db: AsyncSession, bulk_model: BulkUsersProxy
+) -> tuple[list, int] | tuple[list[User], int]:
+ """
+ Bulk update the `proxy_settings` JSON field for users and return updated rows.
+ """
+ final_filter = _create_final_filter(bulk_model)
+
+ # First select the users that will be updated
+ select_stmt = select(User).where(final_filter)
+ result = await db.execute(select_stmt)
+ users_to_update = result.scalars().all()
+ count_effctive_users = len(users_to_update)
+
+ if not users_to_update:
+ return [], count_effctive_users
+
+ # Prepare the update statement
+ if DATABASE_DIALECT == "postgresql":
+ proxy_settings_expr = cast(User.proxy_settings, JSONB)
+ if bulk_model.flow is not None:
+ proxy_settings_expr = func.jsonb_set(
+ proxy_settings_expr,
+ text("'{vless,flow}'"),
+ cast(f"{bulk_model.flow.value}", JSONB),
+ True,
+ )
+ if bulk_model.method is not None:
+ proxy_settings_expr = func.jsonb_set(
+ proxy_settings_expr,
+ text("'{shadowsocks,method}'"),
+ cast(f"{bulk_model.method.value}", JSONB),
+ True,
+ )
+ else:
+ proxy_settings_expr = User.proxy_settings
+ if bulk_model.flow is not None:
+ proxy_settings_expr = func.json_set(proxy_settings_expr, "$.vless.flow", f"{bulk_model.flow.value}")
+ if bulk_model.method is not None:
+ proxy_settings_expr = func.json_set(
+ proxy_settings_expr, "$.shadowsocks.method", f"{bulk_model.method.value}"
+ )
+
+ # Perform the update
+ update_stmt = update(User).where(final_filter).values(proxy_settings=proxy_settings_expr)
+ await db.execute(update_stmt)
+ await db.commit()
+
+ # Refresh the user objects to get updated values
+ for user in users_to_update:
+ await db.refresh(user)
+ await load_user_attrs(user)
+
+ return users_to_update, count_effctive_users
diff --git a/app/db/crud/core.py b/app/db/crud/core.py
new file mode 100644
index 000000000..02cc85101
--- /dev/null
+++ b/app/db/crud/core.py
@@ -0,0 +1,102 @@
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.db.models import CoreConfig
+from app.models.core import CoreCreate
+
+
+async def get_core_config_by_id(db: AsyncSession, core_id: int) -> CoreConfig | None:
+ """
+ Retrieves a core configuration by its ID.
+
+ Args:
+ db (AsyncSession): The database session.
+ core_id (int): The ID of the core configuration to retrieve.
+
+ Returns:
+ Optional[CoreConfig]: The CoreConfig object if found, None otherwise.
+ """
+ return (await db.execute(select(CoreConfig).where(CoreConfig.id == core_id))).unique().scalar_one_or_none()
+
+
+async def create_core_config(db: AsyncSession, core_config: CoreCreate) -> CoreConfig:
+ """
+ Creates a new core configuration in the database.
+
+ Args:
+ db (AsyncSession): The database session.
+ core_config (CoreCreate): The core configuration creation model containing core details.
+
+ Returns:
+ CoreConfig: The newly created CoreConfig object.
+ """
+ db_core_config = CoreConfig(
+ name=core_config.name,
+ config=core_config.config,
+ exclude_inbound_tags=core_config.exclude_inbound_tags or set(),
+ fallbacks_inbound_tags=core_config.fallbacks_inbound_tags or set(),
+ )
+ db.add(db_core_config)
+ await db.commit()
+ await db.refresh(db_core_config)
+ return db_core_config
+
+
+async def modify_core_config(
+ db: AsyncSession, db_core_config: CoreConfig, modified_core_config: CoreCreate
+) -> CoreConfig:
+ """
+ Modifies an existing core configuration with new information.
+
+ Args:
+ db (AsyncSession): The database session.
+ db_core_config (CoreConfig): The CoreConfig object to be updated.
+ modified_core_config (CoreCreate): The modification model containing updated core details.
+
+ Returns:
+ CoreConfig: The updated CoreConfig object.
+ """
+ core_data = modified_core_config.model_dump(exclude_none=True)
+
+ for key, value in core_data.items():
+ setattr(db_core_config, key, value)
+
+ await db.commit()
+ await db.refresh(db_core_config)
+ return db_core_config
+
+
+async def remove_core_config(db: AsyncSession, db_core_config: CoreConfig) -> None:
+ """
+ Removes a core configuration from the database.
+
+ Args:
+ db (AsyncSession): The database session.
+ db_core_config (CoreConfig): The CoreConfig object to be removed.
+ """
+ await db.delete(db_core_config)
+ await db.commit()
+
+
+async def get_core_configs(db: AsyncSession, offset: int = None, limit: int = None) -> tuple[int, list[CoreConfig]]:
+ """
+ Retrieves a list of core configurations with optional pagination.
+
+ Args:
+ db (AsyncSession): The database session.
+ offset (int, optional): The number of records to skip (for pagination).
+ limit (int, optional): The maximum number of records to return.
+
+ Returns:
+ tuple: A tuple containing:
+ - list[CoreConfig]: A list of CoreConfig objects
+ - int: The total count of core configurations
+ """
+ query = select(CoreConfig)
+ if offset:
+ query = query.offset(offset)
+ if limit:
+ query = query.limit(limit)
+
+ all_core_configs = (await db.execute(query)).scalars().all()
+ return all_core_configs, len(all_core_configs)
diff --git a/app/db/crud/general.py b/app/db/crud/general.py
new file mode 100644
index 000000000..732aa60f3
--- /dev/null
+++ b/app/db/crud/general.py
@@ -0,0 +1,103 @@
+from sqlalchemy import String, func, or_, select, text
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.db.base import DATABASE_DIALECT
+from app.db.models import JWT, System
+from app.models.stats import Period
+
+MYSQL_FORMATS = {
+ Period.minute: "%Y-%m-%d %H:%i:00",
+ Period.hour: "%Y-%m-%d %H:00:00",
+ Period.day: "%Y-%m-%d",
+ Period.month: "%Y-%m-01",
+}
+SQLITE_FORMATS = {
+ Period.minute: "%Y-%m-%d %H:%M:00",
+ Period.hour: "%Y-%m-%d %H:00:00",
+ Period.day: "%Y-%m-%d",
+ Period.month: "%Y-%m-01",
+}
+
+
+def _build_trunc_expression(period: Period, column):
+ """Builds the appropriate truncation SQL expression based on DATABASE_DIALECT and period."""
+ if DATABASE_DIALECT == "postgresql":
+ return func.date_trunc(period.value, column)
+ elif DATABASE_DIALECT == "mysql":
+ return func.date_format(column, MYSQL_FORMATS[period.value])
+ elif DATABASE_DIALECT == "sqlite":
+ return func.strftime(SQLITE_FORMATS[period.value], column)
+
+ raise ValueError(f"Unsupported dialect: {DATABASE_DIALECT}")
+
+
+def get_datetime_add_expression(datetime_column, seconds: int):
+ """
+ Get database-specific datetime addition expression
+ """
+ if DATABASE_DIALECT == "mysql":
+ return func.date_add(datetime_column, text("INTERVAL :seconds SECOND").bindparams(seconds=seconds))
+ elif DATABASE_DIALECT == "postgresql":
+ return datetime_column + func.make_interval(0, 0, 0, 0, 0, 0, seconds)
+ elif DATABASE_DIALECT == "sqlite":
+ return func.datetime(func.strftime("%s", datetime_column) + seconds, "unixepoch")
+
+ raise ValueError(f"Unsupported dialect: {DATABASE_DIALECT}")
+
+
+def json_extract(column, path: str):
+ """
+ Args:
+ column: The JSON column in your model
+ path: JSON path (e.g., '$.theme')
+ """
+ match DATABASE_DIALECT:
+ case "postgresql":
+ keys = path.replace("$.", "").split(".")
+ expr = column
+ for key in keys:
+ expr = expr.op("->>")(key) if key == keys[-1] else expr.op("->")(key)
+ return expr.cast(String)
+ case "mysql":
+ return func.json_unquote(func.json_extract(column, path)).cast(String)
+ case "sqlite":
+ return func.json_extract(column, path).cast(String)
+
+
+def build_json_proxy_settings_search_condition(column, value: str):
+ """
+ Builds a condition to search JSON column for UUIDs or passwords.
+ Supports PostgresSQL, MySQL, SQLite.
+ """
+ return or_(
+ *[
+ json_extract(column, field) == value
+ for field in ("$.vmess.id", "$.vless.id", "$.trojan.password", "$.shadowsocks.password")
+ ],
+ )
+
+
+async def get_system_usage(db: AsyncSession) -> System:
+ """
+ Retrieves system usage information.
+
+ Args:
+ db (AsyncSession): Database session.
+
+ Returns:
+ System: System usage information.
+ """
+ return (await db.execute(select(System))).scalar_one_or_none()
+
+
+async def get_jwt_secret_key(db: AsyncSession) -> str:
+ """
+ Retrieves the JWT secret key.
+
+ Args:
+ db (AsyncSession): Database session.
+
+ Returns:
+ str: JWT secret key.
+ """
+ return (await db.execute(select(JWT))).scalar_one_or_none().secret_key
diff --git a/app/db/crud/group.py b/app/db/crud/group.py
new file mode 100644
index 000000000..ac3589011
--- /dev/null
+++ b/app/db/crud/group.py
@@ -0,0 +1,149 @@
+from sqlalchemy import select, func
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.db.models import ProxyInbound, Group
+from app.models.group import GroupCreate, GroupModify
+
+from .host import get_or_create_inbound
+
+
+async def get_inbounds_by_tags(db: AsyncSession, tags: list[str]) -> list[ProxyInbound]:
+ """
+ Retrieves inbounds by their tags.
+ """
+ return [(await get_or_create_inbound(db, tag)) for tag in tags]
+
+
+async def load_group_attrs(group: Group):
+ await group.awaitable_attrs.users
+ await group.awaitable_attrs.inbounds
+
+
+async def get_group_by_id(db: AsyncSession, group_id: int) -> Group | None:
+ """
+ Retrieves a group by its ID.
+
+ Args:
+ db (AsyncSession): The database session.
+ group_id (int): The ID of the group to retrieve.
+
+ Returns:
+ Optional[Group]: The Group object if found, None otherwise.
+ """
+ group = (await db.execute(select(Group).where(Group.id == group_id))).unique().scalar_one_or_none()
+ if group:
+ await load_group_attrs(group)
+ return group
+
+
+async def create_group(db: AsyncSession, group: GroupCreate) -> Group:
+ """
+ Creates a new group in the database.
+
+ Args:
+ db (AsyncSession): The database session.
+ group (GroupCreate): The group creation model containing group details.
+
+ Returns:
+ Group: The newly created Group object.
+ """
+ db_group = Group(
+ name=group.name,
+ inbounds=await get_inbounds_by_tags(db, group.inbound_tags),
+ is_disabled=group.is_disabled,
+ )
+ db.add(db_group)
+ await db.commit()
+ await db.refresh(db_group)
+ await load_group_attrs(db_group)
+ return db_group
+
+
+async def get_group(db: AsyncSession, offset: int = None, limit: int = None) -> tuple[list[Group], int]:
+ """
+ Retrieves a list of groups with optional pagination.
+
+ Args:
+ db (AsyncSession): The database session.
+ offset (int, optional): The number of records to skip (for pagination).
+ limit (int, optional): The maximum number of records to return.
+
+ Returns:
+ tuple: A tuple containing:
+ - list[Group]: A list of Group objects
+ - int: The total count of groups
+ """
+ groups = select(Group)
+
+ count_query = select(func.count()).select_from(groups.subquery())
+
+ if offset:
+ groups = groups.offset(offset)
+ if limit:
+ groups = groups.limit(limit)
+
+ count = (await db.execute(count_query)).scalar_one()
+
+ all_groups = (await db.execute(groups)).scalars().all()
+
+ for group in all_groups:
+ await load_group_attrs(group)
+
+ return all_groups, count
+
+
+async def get_groups_by_ids(db: AsyncSession, group_ids: list[int]) -> list[Group]:
+ """
+ Retrieves a list of groups by their IDs.
+
+ Args:
+ db (AsyncSession): The database session.
+ group_ids (list[int]): The IDs of the groups to retrieve.
+
+ Returns:
+ list[Group]: A list of Group objects.
+ """
+ groups = (await db.execute(select(Group).where(Group.id.in_(group_ids)))).scalars().all()
+
+ for group in groups:
+ await load_group_attrs(group)
+
+ return groups
+
+
+async def modify_group(db: AsyncSession, db_group: Group, modified_group: GroupModify) -> Group:
+ """
+ Modify an existing group with new information.
+
+ Args:
+ db (AsyncSession): The database session.
+ dbgroup (Group): The Group object to be updated.
+ modified_group (GroupModify): The modification model containing updated group details.
+
+ Returns:
+ Group: The updated Group object.
+ """
+
+ if db_group.name != modified_group.name:
+ db_group.name = modified_group.name
+ if modified_group.is_disabled is not None:
+ db_group.is_disabled = modified_group.is_disabled
+ if modified_group.inbound_tags:
+ inbounds = await get_inbounds_by_tags(db, modified_group.inbound_tags)
+ db_group.inbounds = inbounds
+ await db.commit()
+ await db.refresh(db_group)
+ await load_group_attrs(db_group)
+ return db_group
+
+
+async def remove_group(db: AsyncSession, dbgroup: Group):
+ """
+ Removes a group from the database.
+
+ Args:
+ db (AsyncSession): The database session.
+ dbgroup (Group): The Group object to be removed.
+ """
+ await db.delete(dbgroup)
+ await db.commit()
diff --git a/app/db/crud/host.py b/app/db/crud/host.py
new file mode 100644
index 000000000..f55c7095d
--- /dev/null
+++ b/app/db/crud/host.py
@@ -0,0 +1,166 @@
+import asyncio
+from enum import Enum
+from typing import List, Optional
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.db.models import ProxyInbound, ProxyHost
+from app.models.host import CreateHost
+
+
+async def get_or_create_inbound(db: AsyncSession, inbound_tag: str) -> ProxyInbound:
+ """
+ Retrieves or creates a proxy inbound based on the given tag.
+
+ Args:
+ db (AsyncSession): Database session.
+ inbound_tag (str): The tag of the inbound.
+
+ Returns:
+ ProxyInbound: The retrieved or newly created proxy inbound.
+ """
+ stmt = select(ProxyInbound).where(ProxyInbound.tag == inbound_tag)
+ result = await db.execute(stmt)
+ inbound = result.scalar_one_or_none()
+
+ if not inbound:
+ inbound = ProxyInbound(tag=inbound_tag)
+ db.add(inbound)
+ await db.commit()
+ await db.refresh(inbound)
+
+ return inbound
+
+
+async def get_inbounds_not_in_tags(db: AsyncSession, excluded_tags: List[str]) -> List[ProxyInbound]:
+ """
+ Get all inbounds where the tag is not in the provided list of tags.
+
+ Args:
+ db: Database session
+ excluded_tags: List of tags to exclude
+
+ Returns:
+ List of ProxyInbound objects not matching any tag in the list
+ """
+ stmt = select(ProxyInbound).where(ProxyInbound.tag.not_in(excluded_tags))
+ result = await db.execute(stmt)
+ return result.scalars().all()
+
+
+async def remove_inbounds(db: AsyncSession, inbounds: List[ProxyInbound]) -> None:
+ """
+ Remove a list of inbounds from the database.
+
+ Args:
+ db: Database session
+ inbounds: List of ProxyInbound objects to remove
+ """
+ if not inbounds:
+ return
+
+ await asyncio.gather(*[db.delete(inbound) for inbound in inbounds])
+ await db.commit()
+
+
+ProxyHostSortingOptions = Enum(
+ "ProxyHostSortingOptions",
+ {
+ "priority": ProxyHost.priority.asc(),
+ "id": ProxyHost.id.asc(),
+ "-priority": ProxyHost.priority.desc(),
+ "-id": ProxyHost.id.desc(),
+ },
+)
+
+
+async def get_hosts(
+ db: AsyncSession,
+ offset: Optional[int] = 0,
+ limit: Optional[int] = 0,
+ sort: ProxyHostSortingOptions = "priority",
+) -> list[ProxyHost]:
+ """
+ Retrieves hosts.
+
+ Args:
+ db (AsyncSession): Database session.
+ offset (Optional[int]): Number of records to skip.
+ limit (Optional[int]): Number of records to retrieve.
+
+ Returns:
+ List[ProxyHost]: List of hosts for the inbound.
+ """
+ stmt = select(ProxyHost).order_by(sort)
+
+ if offset:
+ stmt = stmt.offset(offset)
+ if limit:
+ stmt = stmt.limit(limit)
+
+ result = await db.execute(stmt)
+ return list(result.scalars().all())
+
+
+async def get_host_by_id(db: AsyncSession, id: int) -> ProxyHost:
+ """
+ Retrieves host by id.
+
+ Args:
+ db (AsyncSession): Database session.
+ id (int): The ID of the host.
+
+ Returns:
+ ProxyHost: The host if found.
+ """
+ stmt = select(ProxyHost).where(ProxyHost.id == id)
+ result = await db.execute(stmt)
+ return result.scalar_one_or_none()
+
+
+async def create_host(db: AsyncSession, new_host: CreateHost) -> ProxyHost:
+ """
+ Creates a proxy Host based on the host.
+
+ Args:
+ db (AsyncSession): Database session.
+ host (CreateHost): The new host to add.
+
+ Returns:
+ ProxyHost: The retrieved or newly created proxy host.
+ """
+ db_host = ProxyHost(**new_host.model_dump(exclude={"inbound_tag", "id"}))
+ db_host.inbound = await get_or_create_inbound(db, new_host.inbound_tag)
+
+ db.add(db_host)
+ await db.commit()
+ await db.refresh(db_host)
+ return db_host
+
+
+async def modify_host(db: AsyncSession, db_host: ProxyHost, modified_host: CreateHost) -> ProxyHost:
+ host_data = modified_host.model_dump(exclude={"id"})
+
+ for key, value in host_data.items():
+ setattr(db_host, key, value)
+
+ await db.commit()
+ await db.refresh(db_host)
+ return db_host
+
+
+async def remove_host(db: AsyncSession, db_host: ProxyHost) -> ProxyHost:
+ """
+ Removes a proxy Host from the database.
+
+ Args:
+ db (AsyncSession): Database session.
+ db_host (ProxyHost): The host to remove.
+
+ Returns:
+ ProxyHost: The removed proxy host.
+ """
+ await db.delete(db_host)
+ await db.commit()
+ return db_host
diff --git a/app/db/crud/node.py b/app/db/crud/node.py
new file mode 100644
index 000000000..acfbde135
--- /dev/null
+++ b/app/db/crud/node.py
@@ -0,0 +1,295 @@
+from datetime import datetime, timezone
+from typing import Optional, Union
+
+from sqlalchemy import and_, delete, func, select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.db.models import Node, NodeStat, NodeStatus, NodeUsage, NodeUserUsage
+from app.models.node import NodeCreate, NodeModify, UsageTable
+from app.models.stats import NodeStats, NodeStatsList, NodeUsageStat, NodeUsageStatsList, Period
+
+from .general import _build_trunc_expression
+
+
+async def get_node(db: AsyncSession, name: str) -> Optional[Node]:
+ """
+ Retrieves a node by its name.
+
+ Args:
+ db (AsyncSession): The database session.
+ name (str): The name of the node to retrieve.
+
+ Returns:
+ Optional[Node]: The Node object if found, None otherwise.
+ """
+ return (await db.execute(select(Node).where(Node.name == name))).unique().scalar_one_or_none()
+
+
+async def get_node_by_id(db: AsyncSession, node_id: int) -> Optional[Node]:
+ """
+ Retrieves a node by its ID.
+
+ Args:
+ db (AsyncSession): The database session.
+ node_id (int): The ID of the node to retrieve.
+
+ Returns:
+ Optional[Node]: The Node object if found, None otherwise.
+ """
+ return (await db.execute(select(Node).where(Node.id == node_id))).unique().scalar_one_or_none()
+
+
+async def get_nodes(
+ db: AsyncSession,
+ status: Optional[Union[NodeStatus, list]] = None,
+ enabled: bool | None = None,
+ core_id: int | None = None,
+ offset: int | None = None,
+ limit: int | None = None,
+) -> list[Node]:
+ """
+ Retrieves nodes based on optional status and enabled filters.
+
+ Args:
+ db (AsyncSession): The database session.
+ status (Optional[Union[app.db.models.NodeStatus, list]]): The status or list of statuses to filter by.
+ enabled (bool): If True, excludes disabled nodes.
+
+ Returns:
+ List[Node]: A list of Node objects matching the criteria.
+ """
+ query = select(Node)
+
+ if status:
+ if isinstance(status, list):
+ query = query.where(Node.status.in_(status))
+ else:
+ query = query.where(Node.status == status)
+
+ if enabled:
+ query = query.where(Node.status != NodeStatus.disabled)
+
+ if core_id:
+ query = query.where(Node.core_config_id == core_id)
+
+ if offset:
+ query = query.offset(offset)
+ if limit:
+ query = query.limit(limit)
+
+ return (await db.execute(query)).scalars().all()
+
+
+async def get_nodes_usage(
+ db: AsyncSession,
+ start: datetime,
+ end: datetime,
+ period: Period,
+ node_id: int | None = None,
+ group_by_node: bool = False,
+) -> NodeUsageStatsList:
+ """
+ Retrieves usage data for all nodes within a specified time range.
+
+ Args:
+ db (AsyncSession): The database session.
+ start (datetime): The start time of the usage period.
+ end (datetime): The end time of the usage period.
+
+ Returns:
+ NodeUsageStatsList: A NodeUsageStatsList contain list of NodeUsageResponse objects containing usage data.
+ """
+ trunc_expr = _build_trunc_expression(period, NodeUsage.created_at)
+
+ conditions = [NodeUsage.created_at >= start, NodeUsage.created_at <= end]
+
+ if node_id is not None:
+ conditions.append(NodeUsage.node_id == node_id)
+ else:
+ node_id = -1 # Default value for node_id when not specified
+
+ if group_by_node:
+ stmt = (
+ select(
+ trunc_expr.label("period_start"),
+ func.coalesce(NodeUsage.node_id, 0).label("node_id"),
+ func.sum(NodeUsage.downlink).label("downlink"),
+ func.sum(NodeUsage.uplink).label("uplink"),
+ )
+ .where(and_(*conditions))
+ .group_by(trunc_expr, NodeUsage.node_id)
+ .order_by(trunc_expr)
+ )
+ else:
+ stmt = (
+ select(
+ trunc_expr.label("period_start"),
+ func.sum(NodeUsage.downlink).label("downlink"),
+ func.sum(NodeUsage.uplink).label("uplink"),
+ )
+ .where(and_(*conditions))
+ .group_by(trunc_expr)
+ .order_by(trunc_expr)
+ )
+
+ result = await db.execute(stmt)
+ stats = {}
+ for row in result.mappings():
+ row_dict = dict(row)
+ node_id_val = row_dict.pop("node_id", node_id)
+ if node_id_val not in stats:
+ stats[node_id_val] = []
+ stats[node_id_val].append(NodeUsageStat(**row_dict))
+
+ return NodeUsageStatsList(period=period, start=start, end=end, stats=stats)
+
+
+async def get_node_stats(
+ db: AsyncSession, node_id: int, start: datetime, end: datetime, period: Period
+) -> NodeStatsList:
+ trunc_expr = _build_trunc_expression(period, NodeStat.created_at)
+ conditions = [NodeStat.created_at >= start, NodeStat.created_at <= end, NodeStat.node_id == node_id]
+
+ stmt = (
+ select(
+ trunc_expr.label("period_start"),
+ func.avg(NodeStat.mem_used / NodeStat.mem_total * 100).label("mem_usage_percentage"),
+ func.avg(NodeStat.cpu_usage).label("cpu_usage_percentage"), # CPU usage is already in percentage
+ func.avg(NodeStat.incoming_bandwidth_speed).label("incoming_bandwidth_speed"),
+ func.avg(NodeStat.outgoing_bandwidth_speed).label("outgoing_bandwidth_speed"),
+ )
+ .where(and_(*conditions))
+ .group_by(trunc_expr)
+ .order_by(trunc_expr)
+ )
+
+ result = await db.execute(stmt)
+
+ return NodeStatsList(period=period, start=start, end=end, stats=[NodeStats(**row) for row in result.mappings()])
+
+
+async def create_node(db: AsyncSession, node: NodeCreate) -> Node:
+ """
+ Creates a new node in the database.
+
+ Args:
+ db (AsyncSession): The database session.
+ node (NodeCreate): The node creation model containing node details.
+
+ Returns:
+ Node: The newly created Node object.
+ """
+ db_node = Node(**node.model_dump())
+
+ db.add(db_node)
+ await db.commit()
+ await db.refresh(db_node)
+ return db_node
+
+
+async def remove_node(db: AsyncSession, db_node: Node) -> Node:
+ """
+ Removes a node from the database.
+
+ Args:
+ db (AsyncSession): The database session.
+ dbnode (Node): The Node object to be removed.
+
+ Returns:
+ Node: The removed Node object.
+ """
+ await db.delete(db_node)
+ await db.commit()
+
+
+async def modify_node(db: AsyncSession, db_node: Node, modify: NodeModify) -> Node:
+ """
+ modify an existing node with new information.
+
+ Args:
+ db (AsyncSession): The database session.
+ dbnode (Node): The Node object to be updated.
+ modify (NodeModify): The modification model containing updated node details.
+
+ Returns:
+ Node: The modified Node object.
+ """
+
+ node_data = modify.model_dump(exclude_none=True)
+
+ for key, value in node_data.items():
+ setattr(db_node, key, value)
+
+ db_node.xray_version = None
+ db_node.message = None
+ db_node.node_version = None
+
+ if db_node.status != NodeStatus.disabled:
+ db_node.status = NodeStatus.connecting
+
+ await db.commit()
+ await db.refresh(db_node)
+ return db_node
+
+
+async def update_node_status(
+ db: AsyncSession,
+ db_node: Node,
+ status: NodeStatus,
+ message: str = "",
+ xray_version: str = "",
+ node_version: str = "",
+) -> Node:
+ """
+ Updates the status of a node.
+
+ Args:
+ db (AsyncSession): The database session.
+ dbnode (Node): The Node object to be updated.
+ status (app.db.models.NodeStatus): The new status of the node.
+ message (str, optional): A message associated with the status update.
+ version (str, optional): The version of the node software.
+
+ Returns:
+ Node: The updated Node object.
+ """
+ stmt = (
+ update(Node)
+ .where(Node.id == db_node.id)
+ .values(
+ status=status,
+ message=message,
+ xray_version=xray_version,
+ node_version=node_version,
+ last_status_change=datetime.now(timezone.utc),
+ )
+ )
+ await db.execute(stmt)
+ await db.commit()
+ await db.refresh(db_node)
+ return db_node
+
+
+def _table_model(table: UsageTable):
+ if table == UsageTable.node_user_usages:
+ return NodeUserUsage
+ if table == UsageTable.node_usages:
+ return NodeUsage
+ raise ValueError("Invalid table enum")
+
+
+async def clear_usage_data(
+ db: AsyncSession, table: UsageTable, start: datetime | None = None, end: datetime | None = None
+):
+ filters = []
+ if start:
+ filters.append(getattr(_table_model(table), "created_at") >= start.replace(tzinfo=timezone.utc))
+ if end:
+ filters.append(getattr(_table_model(table), "created_at") < end.replace(tzinfo=timezone.utc))
+
+ stmt = delete(_table_model(table))
+ if filters:
+ stmt = stmt.where(and_(*filters))
+
+ await db.execute(stmt)
+ await db.commit()
diff --git a/app/db/crud/settings.py b/app/db/crud/settings.py
new file mode 100644
index 000000000..06af597a4
--- /dev/null
+++ b/app/db/crud/settings.py
@@ -0,0 +1,29 @@
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.db.models import Settings
+from app.models.settings import SettingsSchema
+
+
+async def get_settings(db: AsyncSession) -> Settings:
+ """
+ Retrieves the Settings.
+
+ Args:
+ db (AsyncSession): Database session.
+
+ Returns:
+ Settings: Settings information.
+ """
+ return (await db.execute(select(Settings))).scalar_one_or_none()
+
+
+async def modify_settings(db: AsyncSession, db_setting: Settings, modify: SettingsSchema) -> Settings:
+ settings_data = modify.model_dump(exclude_none=True)
+
+ for key, value in settings_data.items():
+ setattr(db_setting, key, value)
+
+ await db.commit()
+ await db.refresh(db_setting)
+ return db_setting
diff --git a/app/db/crud/user.py b/app/db/crud/user.py
new file mode 100644
index 000000000..6a53dc479
--- /dev/null
+++ b/app/db/crud/user.py
@@ -0,0 +1,1064 @@
+import asyncio
+from copy import deepcopy
+from datetime import UTC, datetime, timedelta, timezone
+from enum import Enum
+from typing import List, Optional, Sequence
+
+from sqlalchemy import and_, case, delete, desc, func, not_, or_, select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import joinedload
+from sqlalchemy.sql.functions import coalesce
+
+from app.db.compiles_types import DateDiff
+from app.db.models import (
+ Admin,
+ Group,
+ NextPlan,
+ NodeUserUsage,
+ NotificationReminder,
+ ReminderType,
+ User,
+ UserDataLimitResetStrategy,
+ UserStatus,
+ UserSubscriptionUpdate,
+ UserUsageResetLogs,
+)
+from app.models.proxy import ProxyTable
+from app.models.stats import Period, UserUsageStat, UserUsageStatsList
+from app.models.user import UserCreate, UserModify, UserNotificationResponse
+from config import USERS_AUTODELETE_DAYS
+
+from .general import _build_trunc_expression, build_json_proxy_settings_search_condition
+from .group import get_groups_by_ids
+
+
+async def load_user_attrs(user: User):
+ await user.awaitable_attrs.admin
+ await user.awaitable_attrs.next_plan
+ await user.awaitable_attrs.usage_logs
+ await user.awaitable_attrs.groups
+
+
+async def get_user(db: AsyncSession, username: str) -> Optional[User]:
+ """
+ Retrieves a user by username.
+
+ Args:
+ db (AsyncSession): Database session.
+ username (str): The username of the user.
+
+ Returns:
+ Optional[User]: The user object if found, else None.
+ """
+ stmt = select(User).where(User.username == username)
+
+ user = (await db.execute(stmt)).unique().scalar_one_or_none()
+ if user:
+ await load_user_attrs(user)
+ return user
+
+
+async def get_user_by_id(db: AsyncSession, user_id: int) -> User | None:
+ """
+ Retrieves a user by user ID.
+
+ Args:
+ db (AsyncSession): Database session.
+ user_id (int): The ID of the user.
+
+ Returns:
+ Optional[User]: The user object if found, else None.
+ """
+ stmt = select(User).where(User.id == user_id)
+
+ user = (await db.execute(stmt)).unique().scalar_one_or_none()
+ if user:
+ await load_user_attrs(user)
+ return user
+
+
+UsersSortingOptions = Enum(
+ "UsersSortingOptions",
+ {
+ "username": User.username.asc(),
+ "used_traffic": User.used_traffic.asc(),
+ "data_limit": User.data_limit.asc(),
+ "expire": User.expire.asc(),
+ "created_at": User.created_at.asc(),
+ "edit_at": User.edit_at.asc(),
+ "-username": User.username.desc(),
+ "-used_traffic": User.used_traffic.desc(),
+ "-data_limit": User.data_limit.desc(),
+ "-expire": User.expire.desc(),
+ "-created_at": User.created_at.desc(),
+ "-edit_at": User.edit_at.desc(),
+ },
+)
+
+
+async def get_users(
+ db: AsyncSession,
+ offset: int | None = None,
+ limit: int | None = None,
+ usernames: list[str] | None = None,
+ search: str | None = None,
+ proxy_id: str | None = None,
+ status: UserStatus | list[UserStatus] | None = None,
+ sort: list[UsersSortingOptions] | None = None,
+ admin: Admin | None = None,
+ admins: list[str] | None = None,
+ reset_strategy: UserDataLimitResetStrategy | list[UserDataLimitResetStrategy] | None = None,
+ return_with_count: bool = False,
+ group_ids: list[int] | None = None,
+) -> list[User] | tuple[list[User], int]:
+ """
+ Retrieves users based on various filters.
+
+ Args:
+ db: Database session.
+ offset: Number of records to skip.
+ limit: Number of records to retrieve.
+ usernames: List of usernames to filter by.
+ search: Search term for username.
+ status: User status filter (single status or list).
+ sort: Sort options.
+ admin: Admin filter.
+ admins: List of admin usernames to filter by.
+ reset_strategy: Reset strategy filter (single strategy or list).
+ return_with_count: Whether to return total count.
+ group_ids: Filter users by their group IDs.
+
+ Returns:
+ List of users or tuple with (users, count) if return_with_count is True.
+ """
+ stmt = select(User)
+
+ filters = []
+ if usernames:
+ filters.append(User.username.in_(usernames))
+ if search:
+ filters.append(or_(User.username.ilike(f"%{search}%"), User.note.ilike(f"%{search}%")))
+
+ if status:
+ if isinstance(status, list):
+ filters.append(User.status.in_(status))
+ else:
+ filters.append(User.status == status)
+ if admin:
+ filters.append(User.admin_id == admin.id)
+ if admins:
+ stmt = stmt.join(User.admin).filter(Admin.username.in_(admins))
+ if reset_strategy:
+ if isinstance(reset_strategy, list):
+ filters.append(User.data_limit_reset_strategy.in_(reset_strategy))
+ else:
+ filters.append(User.data_limit_reset_strategy == reset_strategy)
+
+ if group_ids:
+ filters.append(User.groups.any(Group.id.in_(group_ids)))
+ if proxy_id:
+ filters.append(build_json_proxy_settings_search_condition(User.proxy_settings, proxy_id))
+
+ if filters:
+ stmt = stmt.where(and_(*filters))
+
+ if sort:
+ stmt = stmt.order_by(*sort)
+
+ total = None
+ if return_with_count:
+ count_stmt = select(func.count()).select_from(stmt.subquery())
+ result = await db.execute(count_stmt)
+ total = result.scalar()
+
+ if offset:
+ stmt = stmt.offset(offset)
+ if limit:
+ stmt = stmt.limit(limit)
+
+ result = await db.execute(stmt)
+ users = list(result.unique().scalars().all())
+
+ for user in users:
+ await load_user_attrs(user)
+
+ if return_with_count:
+ return users, total
+ return users
+
+
+async def get_expired_users(
+ db: AsyncSession,
+ expired_after: datetime | None = None,
+ expired_before: datetime | None = None,
+ admin_id: int | None = None,
+):
+ query = select(User).where(User.status.in_([UserStatus.limited, UserStatus.expired])).where(User.is_expired)
+ if expired_after:
+ query = query.where(User.expire >= expired_after)
+ if expired_before:
+ query = query.where(User.expire <= expired_before)
+ if admin_id:
+ query = query.where(User.admin_id == admin_id)
+
+ return (await db.execute(query)).unique().scalars().all()
+
+
+async def get_active_to_expire_users(db: AsyncSession) -> list[User]:
+ stmt = select(User).where(User.status == UserStatus.active).where(User.is_expired)
+
+ return list((await db.execute(stmt)).unique().scalars().all())
+
+
+async def get_active_to_limited_users(db: AsyncSession) -> list[User]:
+ stmt = select(User).where(User.status == UserStatus.active).where(User.is_limited)
+
+ return list((await db.execute(stmt)).unique().scalars().all())
+
+
+async def get_on_hold_to_active_users(db: AsyncSession) -> list[User]:
+ stmt = select(User).where(User.status == UserStatus.on_hold).where(User.become_online)
+
+ return list((await db.execute(stmt)).unique().scalars().all())
+
+
+async def get_users_to_reset_data_usage(db: AsyncSession) -> list[User]:
+ """
+ Retrieves users whose data usage needs to be reset based on their reset strategy.
+ """
+ last_reset_subq = (
+ select(
+ UserUsageResetLogs.user_id,
+ func.max(UserUsageResetLogs.reset_at).label("last_reset_at"),
+ )
+ .group_by(UserUsageResetLogs.user_id)
+ .subquery()
+ )
+
+ last_reset_time = coalesce(last_reset_subq.c.last_reset_at, User.created_at)
+
+ reset_strategy_to_days = {
+ UserDataLimitResetStrategy.day: 1,
+ UserDataLimitResetStrategy.week: 7,
+ UserDataLimitResetStrategy.month: 30,
+ UserDataLimitResetStrategy.year: 365,
+ }
+
+ num_days_to_reset_case = case(
+ *((User.data_limit_reset_strategy == strategy, days) for strategy, days in reset_strategy_to_days.items()),
+ else_=None,
+ )
+
+ stmt = (
+ select(User)
+ .outerjoin(last_reset_subq, User.id == last_reset_subq.c.user_id)
+ .where(
+ User.status.in_([UserStatus.active, UserStatus.limited]),
+ User.data_limit_reset_strategy != UserDataLimitResetStrategy.no_reset,
+ DateDiff(func.now(), last_reset_time) >= num_days_to_reset_case,
+ )
+ )
+
+ return list((await db.execute(stmt)).unique().scalars().all())
+
+
+async def get_usage_percentage_reached_users(db: AsyncSession, percentage: int) -> list[User]:
+ """
+ Get active users who have reached or exceeded the specified usage percentage threshold
+ and don't have an existing notification reminder for this threshold.
+ """
+ # Subquery to check for existing notification reminders
+ existing_reminder_subq = (
+ select(NotificationReminder.user_id)
+ .where(
+ NotificationReminder.user_id == User.id,
+ NotificationReminder.type == ReminderType.data_usage,
+ NotificationReminder.threshold == percentage,
+ )
+ .exists()
+ )
+
+ stmt = (
+ select(User)
+ .options(joinedload(User.notification_reminders))
+ .where(User.status == UserStatus.active)
+ .where(User.usage_percentage >= percentage)
+ .where(not_(existing_reminder_subq)) # Only users without existing reminders
+ )
+
+ users = list((await db.execute(stmt)).unique().scalars().all())
+ for user in users:
+ await load_user_attrs(user)
+ return users
+
+
+async def get_days_left_reached_users(db: AsyncSession, days: int) -> list[User]:
+ """
+ Get active users who have reached or exceeded the specified days left threshold
+ and don't have an existing notification reminder for this threshold.
+ """
+ # Subquery to check for existing notification reminders
+ existing_reminder_subq = (
+ select(NotificationReminder.user_id)
+ .where(
+ NotificationReminder.user_id == User.id,
+ NotificationReminder.type == ReminderType.expiration_date,
+ NotificationReminder.threshold == days,
+ )
+ .exists()
+ )
+
+ stmt = (
+ select(User)
+ .options(joinedload(User.notification_reminders))
+ .where(User.status == UserStatus.active)
+ .where(User.expire.isnot(None))
+ .where(User.days_left == days)
+ .where(not_(existing_reminder_subq)) # Only users without existing reminders
+ )
+
+ users = list((await db.execute(stmt)).unique().scalars().all())
+ for user in users:
+ await load_user_attrs(user)
+ return users
+
+
+async def get_user_usages(
+ db: AsyncSession,
+ user_id: int,
+ start: datetime,
+ end: datetime,
+ period: Period,
+ node_id: int | None = None,
+ group_by_node: bool = False,
+) -> UserUsageStatsList:
+ """
+ Retrieves user usages within a specified date range.
+ """
+
+ # Build the appropriate truncation expression
+ trunc_expr = _build_trunc_expression(period, NodeUserUsage.created_at)
+
+ conditions = [
+ NodeUserUsage.created_at >= start,
+ NodeUserUsage.created_at <= end,
+ NodeUserUsage.user_id == user_id,
+ ]
+
+ if node_id is not None:
+ conditions.append(NodeUserUsage.node_id == node_id)
+ else:
+ node_id = -1
+
+ if group_by_node:
+ stmt = (
+ select(
+ trunc_expr.label("period_start"),
+ func.coalesce(NodeUserUsage.node_id, 0).label("node_id"),
+ func.sum(NodeUserUsage.used_traffic).label("total_traffic"),
+ )
+ .where(and_(*conditions))
+ .group_by(trunc_expr, NodeUserUsage.node_id)
+ .order_by(trunc_expr)
+ )
+
+ else:
+ stmt = (
+ select(trunc_expr.label("period_start"), func.sum(NodeUserUsage.used_traffic).label("total_traffic"))
+ .where(and_(*conditions))
+ .group_by(trunc_expr)
+ .order_by(trunc_expr)
+ )
+
+ result = await db.execute(stmt)
+ stats = {}
+ for row in result.mappings():
+ row_dict = dict(row)
+ node_id_val = row_dict.pop("node_id", node_id)
+ if node_id_val not in stats:
+ stats[node_id_val] = []
+ stats[node_id_val].append(UserUsageStat(**row_dict))
+
+ return UserUsageStatsList(period=period, start=start, end=end, stats=stats)
+
+
+async def get_users_count(db: AsyncSession, status: UserStatus = None, admin_id: int = None) -> int:
+ """
+ Gets the total count of users with optional filters.
+
+ Args:
+ db (AsyncSession): Database session.
+ status (UserStatus, optional): Filter by user status.
+ admin_id (int, optional): Filter by admin.
+ Returns:
+ int: Total count of users.
+ """
+ stmt = select(func.count(User.id))
+
+ filters = []
+ if status:
+ filters.append(User.status == status)
+ if admin_id:
+ filters.append(User.admin_id == admin_id)
+
+ if filters:
+ stmt = stmt.where(and_(*filters))
+
+ result = await db.execute(stmt)
+ return result.scalar()
+
+
+async def get_users_count_by_status(
+ db: AsyncSession, statuses: list[UserStatus], admin_id: int = None
+) -> dict[str, int]:
+ """
+ Gets count of users grouped by status in a single query.
+
+ Args:
+ db (AsyncSession): Database session.
+ statuses (list[UserStatus]): List of statuses to count.
+ admin_id (int, optional): Filter by admin.
+ Returns:
+ dict[str, int]: Dictionary with status counts and total.
+ """
+ stmt = select(User.status, func.count(User.id).label("count"))
+
+ filters = [User.status.in_(statuses)]
+ if admin_id:
+ filters.append(User.admin_id == admin_id)
+
+ stmt = stmt.where(and_(*filters)).group_by(User.status)
+
+ result = await db.execute(stmt)
+ status_counts = {row.status.value: row.count for row in result}
+
+ # Ensure all requested statuses are present with 0 count if missing
+ all_statuses = {status.value: status_counts.get(status.value, 0) for status in statuses}
+
+ # Add total count
+ all_statuses["total"] = sum(all_statuses.values())
+
+ return all_statuses
+
+
+async def create_user(db: AsyncSession, new_user: UserCreate, groups: list[Group], admin: Admin) -> User:
+ """
+ Creates a new user.
+
+ Args:
+ db (AsyncSession): Database session.
+ new_user (UserCreate): User creation data.
+ groups (list[Group]): Groups to assign to user.
+ admin (Admin): Admin creating the user.
+
+ Returns:
+ User: Created user object.
+ """
+ db_user = User(**new_user.model_dump(exclude={"group_ids", "expire", "proxy_settings", "next_plan"}))
+ db_user.admin = admin
+ db_user.groups = groups
+ db_user.expire = new_user.expire or None
+ db_user.proxy_settings = new_user.proxy_settings.dict()
+
+ db.add(db_user)
+ await db.commit()
+ await db.refresh(db_user)
+
+ if new_user.next_plan:
+ db_user.next_plan = NextPlan(user_id=db_user.id, **new_user.next_plan.model_dump())
+ db.add(db_user.next_plan)
+ await db.commit()
+ await db.refresh(db_user)
+
+ await load_user_attrs(db_user)
+ return db_user
+
+
+async def remove_user(db: AsyncSession, db_user: User) -> User:
+ """
+ Removes a user from the database.
+
+ Args:
+ db (AsyncSession): Database session.
+ db_user (User): User to remove.
+
+ Returns:
+ User: Removed user object.
+ """
+ await db.delete(db_user)
+ await db.commit()
+ return db_user
+
+
+async def remove_users(db: AsyncSession, db_users: list[User]):
+ """
+ Removes multiple users from the database.
+
+ Args:
+ db (AsyncSession): Database session.
+ dbusers (list[User]): List of user objects to be removed.
+ """
+
+ await asyncio.gather(*[db.delete(user) for user in db_users])
+ await db.commit()
+
+
+async def modify_user(db: AsyncSession, db_user: User, modify: UserModify) -> User:
+ """
+ Modify a user's information.
+
+ Args:
+ db (AsyncSession): Database session.
+ dbuser (User): User to update.
+ modify (UserModify): Modified user data.
+
+ Returns:
+ User: Updated user object.
+ """
+ remove_usage_reminder = False
+ remove_expiration_reminder = False
+
+ if modify.proxy_settings is not None:
+ db_user.proxy_settings = modify.proxy_settings.dict()
+ if modify.group_ids:
+ db_user.groups = await get_groups_by_ids(db, modify.group_ids)
+
+ if modify.status is not None:
+ db_user.status = modify.status
+
+ if modify.status is UserStatus.on_hold:
+ db_user.expire = None
+ remove_expiration_reminder = True
+
+ elif modify.expire == 0:
+ db_user.expire = None
+ remove_expiration_reminder = True
+ if db_user.status is UserStatus.expired:
+ db_user.status = UserStatus.active
+
+ elif modify.expire is not None:
+ db_user.expire = modify.expire
+ if db_user.status in [UserStatus.active, UserStatus.expired]:
+ if not db_user.expire or db_user.expire.replace(tzinfo=timezone.utc) > datetime.now(timezone.utc):
+ db_user.status = UserStatus.active
+
+ remove_expiration_reminder = True
+ else:
+ db_user.status = UserStatus.expired
+
+ if modify.data_limit is not None:
+ db_user.data_limit = modify.data_limit or None
+ if db_user.status not in [UserStatus.expired, UserStatus.disabled]:
+ if not db_user.data_limit or db_user.used_traffic < db_user.data_limit:
+ if db_user.status != UserStatus.on_hold:
+ db_user.status = UserStatus.active
+
+ remove_usage_reminder = True
+ else:
+ db_user.status = UserStatus.limited
+
+ if modify.note is not None:
+ db_user.note = modify.note or None
+
+ if modify.data_limit_reset_strategy is not None:
+ db_user.data_limit_reset_strategy = modify.data_limit_reset_strategy.value
+
+ if modify.on_hold_timeout == 0:
+ db_user.on_hold_timeout = None
+ elif modify.on_hold_timeout is not None:
+ db_user.on_hold_timeout = modify.on_hold_timeout
+
+ if modify.on_hold_expire_duration is not None:
+ db_user.on_hold_expire_duration = modify.on_hold_expire_duration
+
+ if modify.next_plan is not None:
+ db_user.next_plan = NextPlan(
+ user_id=db_user.id,
+ user_template_id=modify.next_plan.user_template_id,
+ data_limit=modify.next_plan.data_limit,
+ expire=modify.next_plan.expire,
+ add_remaining_traffic=modify.next_plan.add_remaining_traffic,
+ )
+ elif db_user.next_plan is not None:
+ await db.delete(db_user.next_plan)
+
+ db_user.edit_at = datetime.now(timezone.utc)
+
+ if remove_usage_reminder or remove_expiration_reminder:
+ id = db_user.id
+ usage_percentage = db_user.usage_percentage
+ days_left = db_user.days_left
+
+ if remove_usage_reminder:
+ await delete_user_passed_notification_reminders(db, id, ReminderType.data_usage, usage_percentage)
+ if remove_expiration_reminder:
+ await delete_user_passed_notification_reminders(db, id, ReminderType.expiration_date, days_left)
+
+ await db.commit()
+ await db.refresh(db_user)
+ await load_user_attrs(db_user)
+ return db_user
+
+
+async def _reset_user_traffic_and_log(db: AsyncSession, db_user: User):
+ """Helper to reset user traffic and log the action."""
+ await db_user.awaitable_attrs.node_usages
+ await db_user.awaitable_attrs.next_plan
+ usage_log = UserUsageResetLogs(
+ user_id=db_user.id,
+ used_traffic_at_reset=db_user.used_traffic,
+ )
+ db.add(usage_log)
+
+ db_user.used_traffic = 0
+ db_user.node_usages.clear()
+
+ if db_user.next_plan:
+ await db.delete(db_user.next_plan)
+ db_user.next_plan = None
+
+
+async def reset_user_data_usage(db: AsyncSession, db_user: User) -> User:
+ """
+ Resets the data usage of a user and logs the reset.
+
+ Args:
+ db (AsyncSession): Database session.
+ dbuser (User): The user object whose data usage is to be reset.
+
+ Returns:
+ User: The updated user object.
+ """
+ await _reset_user_traffic_and_log(db, db_user)
+
+ if db_user.status not in [UserStatus.expired, UserStatus.disabled]:
+ db_user.status = UserStatus.active.value
+
+ await db.commit()
+ await db.refresh(db_user)
+ await load_user_attrs(db_user)
+ return db_user
+
+
+async def bulk_reset_user_data_usage(db: AsyncSession, users: list[User]) -> list[User]:
+ """
+ Resets the data usage for a list of users and logs the reset.
+
+ Args:
+ db (AsyncSession): Database session.
+ users (list[User]): The list of user objects whose data usage is to be reset.
+
+ Returns:
+ list[User]: The updated list of user objects.
+ """
+ for db_user in users:
+ await _reset_user_traffic_and_log(db, db_user)
+ if db_user.status not in [UserStatus.expired, UserStatus.disabled]:
+ db_user.status = UserStatus.active.value
+ await db.commit()
+ for user in users:
+ await db.refresh(user)
+ await load_user_attrs(user)
+ return users
+
+
+async def reset_user_by_next(db: AsyncSession, db_user: User) -> User:
+ """
+ Resets the data usage of a user based on next user.
+
+ Args:
+ db (AsyncSession): Database session.
+ dbuser (User): The user object whose data usage is to be reset.
+
+ Returns:
+ User: The updated user object.
+ """
+ remaining_traffic = (db_user.data_limit or 0) - db_user.used_traffic
+ if db_user.next_plan.user_template_id is None:
+ db_user.data_limit = db_user.next_plan.data_limit + (
+ 0 if not db_user.next_plan.add_remaining_traffic else remaining_traffic
+ )
+ db_user.expire = (
+ timedelta(seconds=db_user.next_plan.expire) + datetime.now(UTC) if db_user.next_plan.expire else None
+ )
+ else:
+ await db_user.next_plan.awaitable_attrs.user_template
+ await db_user.next_plan.user_template.awaitable_attrs.groups
+ db_user.groups = db_user.next_plan.user_template.groups
+ db_user.data_limit = db_user.next_plan.user_template.data_limit + (
+ 0 if not db_user.next_plan.add_remaining_traffic else remaining_traffic
+ )
+ if db_user.next_plan.user_template.status is UserStatus.on_hold:
+ db_user.status = UserStatus.on_hold
+ db_user.on_hold_expire_duration = db_user.next_plan.user_template.expire_duration
+ db_user.on_hold_timeout = db_user.next_plan.user_template.on_hold_timeout
+ db_user.expire = None
+ else:
+ db_user.expire = (
+ timedelta(seconds=db_user.next_plan.user_template.expire_duration) + datetime.now(UTC)
+ if db_user.next_plan.user_template.expire_duration
+ else None
+ )
+
+ if db_user.next_plan.user_template.extra_settings:
+ proxy_settings = deepcopy(db_user.proxy_settings)
+ proxy_settings["vless"]["flow"] = (
+ db_user.next_plan.user_template.extra_settings["flow"]
+ if db_user.next_plan.user_template.extra_settings["flow"]
+ else ""
+ )
+ proxy_settings["shadowsocks"]["method"] = (
+ db_user.next_plan.user_template.extra_settings["method"]
+ if db_user.next_plan.user_template.extra_settings["method"]
+ else "chacha20-ietf-poly1305"
+ )
+ db_user.proxy_settings = proxy_settings
+ db_user.data_limit_reset_strategy = db_user.next_plan.user_template.data_limit_reset_strategy
+
+ await _reset_user_traffic_and_log(db, db_user)
+ db_user.status = UserStatus.active
+
+ await db.commit()
+ await db.refresh(db_user)
+ await load_user_attrs(db_user)
+ return db_user
+
+
+async def revoke_user_sub(db: AsyncSession, db_user: User) -> User:
+ """
+ Revokes the subscription of a user and updates proxies settings.
+
+ Args:
+ db (AsyncSession): Database session.
+ db_user (User): The user object whose subscription is to be revoked.
+
+ Returns:
+ User: The updated user object.
+ """
+ stmt = (
+ update(User)
+ .where(User.id == db_user.id)
+ .values(sub_revoked_at=datetime.now(timezone.utc), proxy_settings=ProxyTable().dict())
+ )
+ await db.execute(stmt)
+ await db.commit()
+ await db.refresh(db_user)
+ await load_user_attrs(db_user)
+ return db_user
+
+
+async def user_sub_update(db: AsyncSession, user_id: User, user_agent: str) -> User:
+ """
+ Updates the user's subscription details.
+
+ Args:
+ db (AsyncSession): Database session.
+ user_id (User): The user id whose subscription is to be updated.
+ user_agent (str): The user agent string.
+
+ """
+ agent = UserSubscriptionUpdate(user_id=user_id, user_agent=user_agent)
+ db.add(agent)
+ await db.commit()
+
+
+async def get_user_sub_update_list(
+ db: AsyncSession, user_id: int, offset: int = 0, limit: int = 10
+) -> tuple[Sequence[UserSubscriptionUpdate], int]:
+ stmt = (
+ select(UserSubscriptionUpdate)
+ .where(UserSubscriptionUpdate.user_id == user_id)
+ .order_by(desc(UserSubscriptionUpdate.created_at))
+ )
+
+ result = await db.execute(select(func.count()).select_from(stmt.subquery()))
+ count = result.scalar() or 0
+
+ if offset:
+ stmt = stmt.offset(offset)
+ if limit:
+ stmt = stmt.limit(limit)
+
+ result = (await db.execute(stmt)).unique().scalars().all()
+
+ return result, count
+
+
+async def autodelete_expired_users(
+ db: AsyncSession, include_limited_users: bool = False
+) -> list[UserNotificationResponse]:
+ """
+ Deletes expired (optionally also limited) users whose auto-delete time has passed.
+
+ Args:
+ db (AsyncSession): Database session
+ include_limited_users (bool, optional): Whether to delete limited users as well.
+ Defaults to False.
+
+ Returns:
+ list[UserNotificationResponse]: List of deleted users.
+ """
+ target_status = [UserStatus.expired] if not include_limited_users else [UserStatus.expired, UserStatus.limited]
+
+ auto_delete = coalesce(User.auto_delete_in_days, USERS_AUTODELETE_DAYS)
+
+ query = (
+ select(
+ User,
+ auto_delete, # Use global auto-delete days as fallback
+ )
+ .where(
+ auto_delete >= 0, # Negative values prevent auto-deletion
+ User.status.in_(target_status),
+ )
+ .options(joinedload(User.admin))
+ )
+
+ expired_users = [
+ user
+ for (user, auto_delete) in (await db.execute(query)).unique()
+ if user.last_status_change.replace(tzinfo=timezone.utc) + timedelta(days=auto_delete)
+ <= datetime.now(timezone.utc)
+ ]
+
+ result: list[UserNotificationResponse] = []
+ if expired_users:
+ for user in expired_users:
+ await load_user_attrs(user)
+ result.append(UserNotificationResponse.model_validate(user))
+
+ await remove_users(db, expired_users)
+
+ return result
+
+
+async def get_all_users_usages(
+ db: AsyncSession,
+ admin: str,
+ start: datetime,
+ end: datetime,
+ period: Period = Period.hour,
+ node_id: int | None = None,
+ group_by_node: bool = False,
+) -> UserUsageStatsList:
+ """
+ Retrieves aggregated usage data for all users of an admin within a specified time range,
+ grouped by the specified time period.
+
+ Args:
+ db (AsyncSession): Database session for querying.
+ admin (Admin): The admin user for which to retrieve user usage data.
+ start (datetime): Start of the period.
+ end (datetime): End of the period.
+ period (Period): Time period to group by ('minute', 'hour', 'day', 'month').
+ node_id (Optional[int]): Filter results by specific node ID if provided
+
+ Returns:
+ UserUsageStatsList: Aggregated usage data for each period.
+ """
+ admin_users = {user.id for user in await get_users(db=db, admins=admin)}
+
+ # Build the appropriate truncation expression
+ trunc_expr = _build_trunc_expression(period, NodeUserUsage.created_at)
+
+ conditions = [
+ NodeUserUsage.created_at >= start,
+ NodeUserUsage.created_at <= end,
+ NodeUserUsage.user_id.in_(admin_users),
+ ]
+
+ if node_id is not None:
+ conditions.append(NodeUserUsage.node_id == node_id)
+ else:
+ node_id = -1
+
+ if group_by_node:
+ stmt = (
+ select(
+ trunc_expr.label("period_start"),
+ func.coalesce(NodeUserUsage.node_id, 0).label("node_id"),
+ func.sum(NodeUserUsage.used_traffic).label("total_traffic"),
+ )
+ .where(and_(*conditions))
+ .group_by(trunc_expr, NodeUserUsage.node_id)
+ .order_by(trunc_expr)
+ )
+ else:
+ stmt = (
+ select(trunc_expr.label("period_start"), func.sum(NodeUserUsage.used_traffic).label("total_traffic"))
+ .where(and_(*conditions))
+ .group_by(trunc_expr)
+ .order_by(trunc_expr)
+ )
+
+ result = await db.execute(stmt)
+ stats = {}
+ for row in result.mappings():
+ row_dict = dict(row)
+ node_id_val = row_dict.pop("node_id", node_id)
+ if node_id_val not in stats:
+ stats[node_id_val] = []
+ stats[node_id_val].append(UserUsageStat(**row_dict))
+
+ return UserUsageStatsList(period=period, start=start, end=end, stats=stats)
+
+
+async def update_users_status(db: AsyncSession, users: list[User], status: UserStatus) -> list[User]:
+ """
+ Updates a users status and records the time of change.
+
+ Args:
+ db (AsyncSession): Database session.
+ users list[User]: The users list to update.
+ status (UserStatus): The new status.
+
+ Returns:
+ User: The updated user object.
+ """
+ user_ids = [user.id for user in users]
+ stmt = (
+ update(User).where(User.id.in_(user_ids)).values(status=status, last_status_change=datetime.now(timezone.utc))
+ )
+ await db.execute(stmt)
+ await db.commit()
+ for user in users:
+ await db.refresh(user)
+ await load_user_attrs(user)
+ return users
+
+
+async def set_owner(db: AsyncSession, db_user: User, admin: Admin) -> User:
+ """
+ Sets the owner (admin) of a user.
+
+ Args:
+ db (AsyncSession): Database session.
+ db_user (User): The user object whose owner is to be set.
+ admin (Admin): The admin to set as owner.
+
+ Returns:
+ User: The updated user object.
+ """
+ stmt = update(User).where(User.id == db_user.id).values(admin_id=admin.id)
+ await db.execute(stmt)
+ await db.commit()
+ await db.refresh(db_user)
+ await load_user_attrs(db_user)
+ return db_user
+
+
+async def start_users_expire(db: AsyncSession, users: list[User]) -> list[User]:
+ """
+ Starts the expiration timer for a user.
+
+ Args:
+ db (AsyncSession): Database session.
+ users list[User]: The users list whose expiration timer is to be started.
+
+ Returns:
+ list[User]: The updated users list.
+ """
+ now = datetime.now(timezone.utc)
+ for user in users:
+ expire_time = now + timedelta(seconds=user.on_hold_expire_duration)
+ stmt = (
+ update(User)
+ .where(User.id == user.id)
+ .values(expire=expire_time, on_hold_expire_duration=None, on_hold_timeout=None, status=UserStatus.active)
+ )
+ await db.execute(stmt)
+
+ await db.commit()
+ for user in users:
+ await db.refresh(user)
+ await load_user_attrs(user)
+ return users
+
+
+async def create_notification_reminder(
+ db: AsyncSession, reminder_type: ReminderType, expires_at: datetime, user_id: int, threshold: int | None = None
+) -> NotificationReminder:
+ """
+ Creates a new notification reminder.
+
+ Args:
+ db (AsyncSession): The database session.
+ reminder_type (ReminderType): The type of reminder.
+ expires_at (datetime): The expiration time of the reminder.
+ user_id (int): The ID of the user associated with the reminder.
+ threshold (Optional[int]): The threshold value to check for (e.g., days left or usage percent).
+
+ Returns:
+ NotificationReminder: The newly created NotificationReminder object.
+ """
+ reminder = NotificationReminder(type=reminder_type, expires_at=expires_at, user_id=user_id)
+ if threshold is not None:
+ reminder.threshold = threshold
+ db.add(reminder)
+ await db.commit()
+ await db.refresh(reminder)
+ return reminder
+
+
+async def bulk_create_notification_reminders(db: AsyncSession, reminder_data: List[dict]) -> None:
+ """
+ Bulk creates notification reminders.
+
+ Args:
+ db (AsyncSession): The database session.
+ reminder_data (List[dict]): List of reminder data dicts with keys: user_id, type, threshold, expires_at
+ """
+ if not reminder_data:
+ return
+
+ reminders = []
+ for data in reminder_data:
+ reminder = NotificationReminder(
+ type=data["type"], expires_at=data["expires_at"], user_id=data["user_id"], threshold=data.get("threshold")
+ )
+ reminders.append(reminder)
+
+ db.add_all(reminders)
+ await db.commit()
+
+
+async def delete_user_passed_notification_reminders(
+ db: AsyncSession, user_id: int, type: ReminderType, threshold: int
+) -> None:
+ """
+ Deletes user reminders passed.
+
+ Args:
+ db (AsyncSession): The database session.
+ user_id (int): The ID of the user.
+ reminder_type (ReminderType): The type of reminder to delete.
+ threshold (int): The threshold to delete (e.g., days left or usage percent).
+ """
+ conditions = [NotificationReminder.user_id == user_id, NotificationReminder.type == type]
+
+ if type == ReminderType.data_usage:
+ conditions.append(NotificationReminder.threshold > threshold)
+ if type == ReminderType.expiration_date:
+ conditions.append(NotificationReminder.threshold < threshold)
+
+ stmt = delete(NotificationReminder).where(and_(*conditions))
+ await db.execute(stmt)
+
+
+async def count_online_users(db: AsyncSession, time_delta: timedelta, admin_id: int | None = None):
+ """
+ Counts the number of users who have been online within the specified time delta.
+
+ Args:
+ db (AsyncSession): The database session.
+ time_delta (timedelta): The time period to check for online users.
+ admin_id (int, optional): Filter by admin.
+
+ Returns:
+ int: The number of users who have been online within the specified time period.
+ """
+ twenty_four_hours_ago = datetime.now(timezone.utc) - time_delta
+ query = select(func.count(User.id)).where(User.online_at.isnot(None), User.online_at >= twenty_four_hours_ago)
+ if admin_id:
+ query = query.where(User.admin_id == admin_id)
+ return (await db.execute(query)).scalar_one_or_none()
diff --git a/app/db/crud/user_template.py b/app/db/crud/user_template.py
new file mode 100644
index 000000000..6f26f5c34
--- /dev/null
+++ b/app/db/crud/user_template.py
@@ -0,0 +1,152 @@
+from typing import Union, List
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.db.models import UserTemplate
+from app.models.user_template import UserTemplateCreate, UserTemplateModify
+
+from .group import get_groups_by_ids
+
+
+async def load_user_template_attrs(template: UserTemplate):
+ await template.awaitable_attrs.groups
+
+
+async def create_user_template(db: AsyncSession, user_template: UserTemplateCreate) -> UserTemplate:
+ """
+ Creates a new user template in the database.
+
+ Args:
+ db (AsyncSession): Database session.
+ user_template (UserTemplateCreate): The user template creation data.
+
+ Returns:
+ UserTemplate: The created user template object.
+ """
+
+ db_user_template = UserTemplate(
+ name=user_template.name,
+ data_limit=user_template.data_limit,
+ expire_duration=user_template.expire_duration,
+ username_prefix=user_template.username_prefix,
+ username_suffix=user_template.username_suffix,
+ groups=await get_groups_by_ids(db, user_template.group_ids) if user_template.group_ids else None,
+ extra_settings=user_template.extra_settings.dict() if user_template.extra_settings else None,
+ status=user_template.status,
+ reset_usages=user_template.reset_usages,
+ on_hold_timeout=user_template.on_hold_timeout,
+ is_disabled=user_template.is_disabled,
+ data_limit_reset_strategy=user_template.data_limit_reset_strategy,
+ )
+
+ db.add(db_user_template)
+ await db.commit()
+ await db.refresh(db_user_template)
+ await load_user_template_attrs(db_user_template)
+ return db_user_template
+
+
+async def modify_user_template(
+ db: AsyncSession, db_user_template: UserTemplate, modified_user_template: UserTemplateModify
+) -> UserTemplate:
+ """
+ Updates a user template's details.
+
+ Args:
+ db (AsyncSession): Database session.
+ db_user_template (UserTemplate): The user template object to be updated.
+ modified_user_template (UserTemplateModify): The modified user template data.
+
+ Returns:
+ UserTemplate: The updated user template object.
+ """
+ if modified_user_template.name is not None:
+ db_user_template.name = modified_user_template.name
+ if modified_user_template.data_limit is not None:
+ db_user_template.data_limit = modified_user_template.data_limit
+ if modified_user_template.expire_duration is not None:
+ db_user_template.expire_duration = modified_user_template.expire_duration
+ if modified_user_template.username_prefix is not None:
+ db_user_template.username_prefix = modified_user_template.username_prefix
+ if modified_user_template.username_suffix is not None:
+ db_user_template.username_suffix = modified_user_template.username_suffix
+ if modified_user_template.group_ids:
+ db_user_template.groups = await get_groups_by_ids(db, modified_user_template.group_ids)
+ if modified_user_template.extra_settings is not None:
+ db_user_template.extra_settings = modified_user_template.extra_settings.dict()
+ if modified_user_template.status is not None:
+ db_user_template.status = modified_user_template.status
+ if modified_user_template.reset_usages is not None:
+ db_user_template.reset_usages = modified_user_template.reset_usages
+ if modified_user_template.on_hold_timeout is not None:
+ db_user_template.on_hold_timeout = modified_user_template.on_hold_timeout
+ if modified_user_template.is_disabled is not None:
+ db_user_template.is_disabled = modified_user_template.is_disabled
+ if modified_user_template.data_limit_reset_strategy is not None:
+ db_user_template.data_limit_reset_strategy = modified_user_template.data_limit_reset_strategy
+
+ await db.commit()
+ await db.refresh(db_user_template)
+ await load_user_template_attrs(db_user_template)
+ return db_user_template
+
+
+async def remove_user_template(db: AsyncSession, db_user_template: UserTemplate):
+ """
+ Removes a user template from the database.
+
+ Args:
+ db (AsyncSession): Database session.
+ db_user_template (UserTemplate): The user template object to be removed.
+ """
+ await db.delete(db_user_template)
+ await db.commit()
+
+
+async def get_user_template(db: AsyncSession, user_template_id: int) -> UserTemplate:
+ """
+ Retrieves a user template by its ID.
+
+ Args:
+ db (AsyncSession): Database session.
+ user_template_id (int): The ID of the user template.
+
+ Returns:
+ UserTemplate: The user template object.
+ """
+ user_template = (
+ (await db.execute(select(UserTemplate).where(UserTemplate.id == user_template_id)))
+ .unique()
+ .scalar_one_or_none()
+ )
+ if user_template:
+ await load_user_template_attrs(user_template)
+ return user_template
+
+
+async def get_user_templates(
+ db: AsyncSession, offset: Union[int, None] = None, limit: Union[int, None] = None
+) -> List[UserTemplate]:
+ """
+ Retrieves a list of user templates with optional pagination.
+
+ Args:
+ db (AsyncSession): Database session.
+ offset (Union[int, None]): The number of records to skip (for pagination).
+ limit (Union[int, None]): The maximum number of records to return.
+
+ Returns:
+ List[UserTemplate]: A list of user template objects.
+ """
+ query = select(UserTemplate)
+ if offset:
+ query = query.offset(offset)
+ if limit:
+ query = query.limit(limit)
+
+ user_templates = (await db.execute(query)).scalars().all()
+ for template in user_templates:
+ await load_user_template_attrs(template)
+
+ return user_templates
diff --git a/app/db/migrations/README b/app/db/migrations/README
new file mode 100644
index 000000000..98e4f9c44
--- /dev/null
+++ b/app/db/migrations/README
@@ -0,0 +1 @@
+Generic single-database configuration.
\ No newline at end of file
diff --git a/app/db/migrations/env.py b/app/db/migrations/env.py
new file mode 100644
index 000000000..7eff2f372
--- /dev/null
+++ b/app/db/migrations/env.py
@@ -0,0 +1,89 @@
+import asyncio
+from logging.config import fileConfig
+from sqlalchemy import pool
+from sqlalchemy.engine import Connection
+from sqlalchemy.ext.asyncio import async_engine_from_config
+from alembic import context
+
+from app.db.base import Base
+from config import SQLALCHEMY_DATABASE_URL
+
+# this is the Alembic Config object, which provides
+# access to the values within the .ini file in use.
+config = context.config
+config.set_main_option('sqlalchemy.url', SQLALCHEMY_DATABASE_URL)
+
+# Interpret the config file for Python logging.
+# This line sets up loggers basically.
+if config.config_file_name is not None:
+ fileConfig(config.config_file_name)
+
+# add your model's MetaData object here
+# for 'autogenerate' support
+# from myapp import mymodel
+# target_metadata = mymodel.Base.metadata
+target_metadata = Base.metadata
+
+# other values from the config, defined by the needs of env.py,
+# can be acquired:
+# my_important_option = config.get_main_option("my_important_option")
+# ... etc.
+
+
+def run_migrations_offline() -> None:
+ """Run migrations in 'offline' mode.
+
+ This configures the context with just a URL
+ and not an Engine, though an Engine is acceptable
+ here as well. By skipping the Engine creation
+ we don't even need a DBAPI to be available.
+
+ Calls to context.execute() here emit the given string to the
+ script output.
+
+ """
+ url = config.get_main_option("sqlalchemy.url")
+ context.configure(
+ url=url,
+ target_metadata=target_metadata,
+ literal_binds=True,
+ dialect_opts={"paramstyle": "named"},
+ )
+
+ with context.begin_transaction():
+ context.run_migrations()
+def do_run_migrations(connection: Connection) -> None:
+ context.configure(connection=connection, target_metadata=target_metadata)
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+async def run_async_migrations() -> None:
+ """In this scenario we need to create an Engine
+ and associate a connection with the context.
+
+ """
+
+ connectable = async_engine_from_config(
+ config.get_section(config.config_ini_section, {}),
+ prefix="sqlalchemy.",
+ poolclass=pool.NullPool,
+ )
+
+ async with connectable.connect() as connection:
+ await connection.run_sync(do_run_migrations)
+
+ await connectable.dispose()
+
+
+def run_migrations_online() -> None:
+ """Run migrations in 'online' mode."""
+
+ asyncio.run(run_async_migrations())
+
+
+if context.is_offline_mode():
+ run_migrations_offline()
+else:
+ run_migrations_online()
\ No newline at end of file
diff --git a/app/db/migrations/script.py.mako b/app/db/migrations/script.py.mako
new file mode 100644
index 000000000..55df2863d
--- /dev/null
+++ b/app/db/migrations/script.py.mako
@@ -0,0 +1,24 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+# revision identifiers, used by Alembic.
+revision = ${repr(up_revision)}
+down_revision = ${repr(down_revision)}
+branch_labels = ${repr(branch_labels)}
+depends_on = ${repr(depends_on)}
+
+
+def upgrade() -> None:
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade() -> None:
+ ${downgrades if downgrades else "pass"}
diff --git a/app/db/migrations/versions/015cf1dc6eca_sub_updated_at_and_sub_last_user_agent.py b/app/db/migrations/versions/015cf1dc6eca_sub_updated_at_and_sub_last_user_agent.py
new file mode 100644
index 000000000..ba8f6ef75
--- /dev/null
+++ b/app/db/migrations/versions/015cf1dc6eca_sub_updated_at_and_sub_last_user_agent.py
@@ -0,0 +1,30 @@
+"""sub_updated_at and sub_last_user_agent
+
+Revision ID: 015cf1dc6eca
+Revises: c47250b790eb
+Create Date: 2023-08-08 18:42:07.829202
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '015cf1dc6eca'
+down_revision = 'c47250b790eb'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('users', sa.Column('sub_updated_at', sa.DateTime(), nullable=True))
+ op.add_column('users', sa.Column('sub_last_user_agent', sa.String(length=64), nullable=True))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('users', 'sub_last_user_agent')
+ op.drop_column('users', 'sub_updated_at')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/025d427831dd_sub_last_user_agent_length.py b/app/db/migrations/versions/025d427831dd_sub_last_user_agent_length.py
new file mode 100644
index 000000000..d463ff3d7
--- /dev/null
+++ b/app/db/migrations/versions/025d427831dd_sub_last_user_agent_length.py
@@ -0,0 +1,34 @@
+"""sub_last_user_agent length
+
+Revision ID: 025d427831dd
+Revises: a6e3fff39291
+Create Date: 2023-08-22 00:50:25.575609
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '025d427831dd'
+down_revision = 'a6e3fff39291'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+
+ if bind.engine.name == 'mysql':
+ op.alter_column('users', 'sub_last_user_agent',
+ existing_type=sa.String(length=512),
+ nullable=True)
+
+
+def downgrade() -> None:
+ bind = op.get_bind()
+
+ if bind.engine.name == 'mysql':
+ op.alter_column('users', 'sub_last_user_agent',
+ existing_type=sa.String(length=64),
+ nullable=True)
diff --git a/app/db/migrations/versions/04a5ec93e9a5_add_randomizednoalpn_and_unsafe_to_.py b/app/db/migrations/versions/04a5ec93e9a5_add_randomizednoalpn_and_unsafe_to_.py
new file mode 100644
index 000000000..33c37c334
--- /dev/null
+++ b/app/db/migrations/versions/04a5ec93e9a5_add_randomizednoalpn_and_unsafe_to_.py
@@ -0,0 +1,83 @@
+"""add randomizednoalpn and unsafe to fingerprints
+
+Revision ID: 04a5ec93e9a5
+Revises: 8fe407cf56c9
+Create Date: 2025-07-10 14:55:05.980537
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '04a5ec93e9a5'
+down_revision = '8fe407cf56c9'
+branch_labels = None
+depends_on = None
+
+# Previous enum (without the new values)
+old_fingerprint = sa.Enum(
+ "none", "chrome", "firefox", "safari", "ios", "android", "edge",
+ "360", "qq", "random", "randomized",
+ name="proxyhostfingerprint"
+)
+
+# New enum (with added values)
+new_fingerprint = sa.Enum(
+ "none", "chrome", "firefox", "safari", "ios", "android", "edge",
+ "360", "qq", "random", "randomized", "randomizednoalpn", "unsafe",
+ name="proxyhostfingerprint"
+)
+
+def upgrade():
+ bind = op.get_bind()
+ dialect = bind.dialect.name
+
+ if dialect == "postgresql":
+ op.execute("ALTER TYPE proxyhostfingerprint ADD VALUE IF NOT EXISTS 'randomizednoalpn'")
+ op.execute("ALTER TYPE proxyhostfingerprint ADD VALUE IF NOT EXISTS 'unsafe'")
+ else:
+ with op.batch_alter_table("hosts", schema=None) as batch_op:
+ batch_op.alter_column(
+ "fingerprint",
+ type_=new_fingerprint,
+ existing_type=old_fingerprint,
+ existing_nullable=True
+ )
+
+
+def downgrade():
+ bind = op.get_bind()
+ dialect = bind.dialect.name
+
+ if dialect == "postgresql":
+ # Rename old type
+ op.execute("ALTER TYPE proxyhostfingerprint RENAME TO proxyhostfingerprint_old")
+
+ # Create new (downgraded) enum type
+ op.execute("""
+ CREATE TYPE proxyhostfingerprint AS ENUM (
+ 'none', 'chrome', 'firefox', 'safari', 'ios',
+ 'android', 'edge', '360', 'qq', 'random', 'randomized'
+ )
+ """)
+
+ # Alter the column to use the new enum
+ op.execute("""
+ ALTER TABLE hosts
+ ALTER COLUMN fingerprint TYPE proxyhostfingerprint
+ USING fingerprint::text::proxyhostfingerprint
+ """)
+
+ # Drop the old enum type
+ op.execute("DROP TYPE proxyhostfingerprint_old")
+
+ else:
+ # SQLite / MySQL: Just switch enum type
+ with op.batch_alter_table("hosts", schema=None) as batch_op:
+ batch_op.alter_column(
+ "fingerprint",
+ type_=old_fingerprint,
+ existing_type=new_fingerprint,
+ existing_nullable=True
+ )
\ No newline at end of file
diff --git a/app/db/migrations/versions/07f9bbb3db4e_user_last_status_change.py b/app/db/migrations/versions/07f9bbb3db4e_user_last_status_change.py
new file mode 100644
index 000000000..7e62f0551
--- /dev/null
+++ b/app/db/migrations/versions/07f9bbb3db4e_user_last_status_change.py
@@ -0,0 +1,49 @@
+"""user_last_status_change
+
+Revision ID: 07f9bbb3db4e
+Revises: ccbf9d322ae3
+Create Date: 2024-04-23 09:57:24.697613
+
+"""
+
+from alembic import op
+import sqlalchemy as sa
+from datetime import datetime
+
+# revision identifiers, used by Alembic.
+revision = '07f9bbb3db4e'
+down_revision = 'ccbf9d322ae3'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ # Add the column 'last_status_change'
+ op.add_column('users', sa.Column('last_status_change', sa.DateTime(), nullable=True))
+
+ # Define the 'users' table for SQLAlchemy Core operations
+ users_table = sa.Table(
+ 'users',
+ sa.MetaData(),
+ sa.Column('id', sa.Integer, primary_key=True),
+ sa.Column('last_status_change', sa.DateTime())
+ )
+
+ # Set last_status_change to the current UTC time for rows where it's NULL
+ connection = op.get_bind()
+ update_stmt = (
+ sa.update(users_table)
+ .where(users_table.c.last_status_change.is_(None))
+ .values(last_status_change=datetime.utcnow())
+ )
+ connection.execute(update_stmt)
+
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ # Drop the 'last_status_change' column
+ op.drop_column('users', 'last_status_change')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/084b8004104c_cast_alpn_as_enumarray.py b/app/db/migrations/versions/084b8004104c_cast_alpn_as_enumarray.py
new file mode 100644
index 000000000..d33e8f957
--- /dev/null
+++ b/app/db/migrations/versions/084b8004104c_cast_alpn_as_enumarray.py
@@ -0,0 +1,91 @@
+"""cast alpn as EnumArray
+
+Revision ID: 084b8004104c
+Revises: 9fa4831e991a
+Create Date: 2025-09-07 14:32:13.968789
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import mysql, postgresql
+
+
+# revision identifiers, used by Alembic.
+revision = '084b8004104c'
+down_revision = '9fa4831e991a'
+branch_labels = None
+depends_on = None
+
+
+alpn_enum = ('none', 'h3', 'h2', 'http/1.1', 'h3,h2,http/1.1', 'h3,h2', 'h2,http/1.1')
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ connection = op.get_bind()
+ dialect = connection.dialect.name
+
+ if dialect == 'postgresql':
+ op.alter_column(
+ 'hosts',
+ 'alpn',
+ existing_type=postgresql.ENUM(*alpn_enum, name="proxyhostalpn"),
+ type_=sa.String(14),
+ nullable=True,
+ )
+ elif dialect == 'mysql':
+ op.alter_column(
+ 'hosts',
+ 'alpn',
+ existing_type=mysql.ENUM(*alpn_enum),
+ type_=sa.String(14),
+ nullable=True,
+ )
+ else: # sqlite
+ with op.batch_alter_table('hosts') as batch_op:
+ batch_op.alter_column(
+ 'alpn',
+ existing_type=sa.VARCHAR(length=14),
+ type_=sa.String(14),
+ nullable=True,
+ existing_server_default=sa.text("'none'"),
+ )
+
+ op.execute("UPDATE hosts SET alpn = NULL WHERE alpn = 'none'")
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.execute(f"UPDATE hosts SET alpn = 'none' WHERE alpn IS NULL OR alpn NOT IN {alpn_enum}")
+
+ connection = op.get_bind()
+ dialect = connection.dialect.name
+
+ if dialect == 'postgresql':
+ op.alter_column(
+ 'hosts',
+ 'alpn',
+ existing_type=sa.String(14),
+ type_=postgresql.ENUM(*alpn_enum, name="proxyhostalpn"),
+ nullable=False,
+ postgresql_using='alpn::proxyhostalpn'
+ )
+ elif dialect == 'mysql':
+ op.alter_column(
+ 'hosts',
+ 'alpn',
+ existing_type=sa.String(14),
+ type_=mysql.ENUM(*alpn_enum),
+ nullable=False,
+ )
+ else:
+ with op.batch_alter_table('hosts') as batch_op:
+ batch_op.alter_column(
+ 'alpn',
+ existing_type=sa.String(14),
+ type_=sa.VARCHAR(length=14),
+ nullable=False,
+ existing_server_default=sa.text("'none'"),
+ )
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/08b381fc1bc7_update_shadowsocks_method.py b/app/db/migrations/versions/08b381fc1bc7_update_shadowsocks_method.py
new file mode 100644
index 000000000..f2c246648
--- /dev/null
+++ b/app/db/migrations/versions/08b381fc1bc7_update_shadowsocks_method.py
@@ -0,0 +1,75 @@
+"""update_shadowsocks_method
+
+Revision ID: 08b381fc1bc7
+Revises: 0f720f5c54dd
+Create Date: 2023-11-03 13:41:57.120379
+
+"""
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy import cast, String
+import json
+
+# revision identifiers, used by Alembic.
+revision = '08b381fc1bc7'
+down_revision = '0f720f5c54dd'
+branch_labels = None
+depends_on = None
+
+# Define the table using SQLAlchemy Core
+proxies_table = sa.Table(
+ 'proxies',
+ sa.MetaData(),
+ sa.Column('id', sa.Integer, primary_key=True),
+ sa.Column('type', sa.String),
+ sa.Column('settings', sa.Text),
+)
+
+
+def upgrade() -> None:
+ connection = op.get_bind()
+
+ # Select proxies where type = 'Shadowsocks'
+ stmt = sa.select(proxies_table.c.id, proxies_table.c.settings).where(
+ cast(proxies_table.c.type, String) == 'Shadowsocks'
+ )
+ result = connection.execute(stmt)
+
+ for pid, settings in result:
+ settings = json.loads(settings)
+ if settings.get('method') == 'chacha20-poly1305':
+ new_settings = settings.copy()
+ new_settings['method'] = 'chacha20-ietf-poly1305'
+
+ # Update query using SQLAlchemy's update()
+ update_stmt = (
+ sa.update(proxies_table)
+ .where(proxies_table.c.id == pid)
+ .values(settings=json.dumps(new_settings))
+ )
+ connection.execute(update_stmt)
+
+
+def downgrade() -> None:
+ connection = op.get_bind()
+
+ # Select proxies where type = 'Shadowsocks'
+ stmt = sa.select(proxies_table.c.id, proxies_table.c.settings).where(
+ cast(proxies_table.c.type, String) == 'Shadowsocks'
+ )
+ result = connection.execute(stmt)
+
+ for pid, settings in result:
+ settings = json.loads(settings)
+ if settings.get('method') == 'chacha20-ietf-poly1305':
+ new_settings = settings.copy()
+ new_settings['method'] = 'chacha20-poly1305'
+
+ # Update query using SQLAlchemy's update()
+ update_stmt = (
+ sa.update(proxies_table)
+ .where(proxies_table.c.id == pid)
+ .values(settings=json.dumps(new_settings))
+ )
+ connection.execute(update_stmt)
diff --git a/app/db/migrations/versions/0b62f893092b_add_sub_template_sub_domain_profile_.py b/app/db/migrations/versions/0b62f893092b_add_sub_template_sub_domain_profile_.py
new file mode 100644
index 000000000..6964d9b64
--- /dev/null
+++ b/app/db/migrations/versions/0b62f893092b_add_sub_template_sub_domain_profile_.py
@@ -0,0 +1,34 @@
+"""add sub_template, sub_domain, profile_title and support_url to admin table
+
+Revision ID: 0b62f893092b
+Revises: 9aa6559916be
+Create Date: 2025-03-08 17:12:10.205130
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '0b62f893092b'
+down_revision = '9aa6559916be'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('admins', sa.Column('sub_template', sa.String(length=1024), nullable=True))
+ op.add_column('admins', sa.Column('sub_domain', sa.String(length=256), nullable=True))
+ op.add_column('admins', sa.Column('profile_title', sa.String(length=512), nullable=True))
+ op.add_column('admins', sa.Column('support_url', sa.String(length=1024), nullable=True))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('admins', 'support_url')
+ op.drop_column('admins', 'profile_title')
+ op.drop_column('admins', 'sub_domain')
+ op.drop_column('admins', 'sub_template')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/0d22271ee06e_make_data_limit_and_expire_duration_.py b/app/db/migrations/versions/0d22271ee06e_make_data_limit_and_expire_duration_.py
new file mode 100644
index 000000000..2f910a8a1
--- /dev/null
+++ b/app/db/migrations/versions/0d22271ee06e_make_data_limit_and_expire_duration_.py
@@ -0,0 +1,56 @@
+"""make data_limit and expire_duration bigint and sub_last_user_agent string
+
+Revision ID: 0d22271ee06e
+Revises: 1dc5f89b706a
+Create Date: 2025-03-19 22:04:10.485911
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '0d22271ee06e'
+down_revision = '1dc5f89b706a'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table('user_templates') as batch_op:
+ batch_op.alter_column('data_limit',
+ existing_type=sa.INTEGER(),
+ type_=sa.BigInteger(),
+ existing_nullable=True)
+ batch_op.alter_column('expire_duration',
+ existing_type=sa.INTEGER(),
+ type_=sa.BigInteger(),
+ existing_nullable=True)
+
+ with op.batch_alter_table('users') as batch_op:
+ batch_op.alter_column('sub_last_user_agent',
+ existing_type=sa.VARCHAR(length=64),
+ type_=sa.String(length=512),
+ existing_nullable=True)
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table('users') as batch_op:
+ batch_op.alter_column('sub_last_user_agent',
+ existing_type=sa.String(length=512),
+ type_=sa.VARCHAR(length=64),
+ existing_nullable=True)
+
+ with op.batch_alter_table('user_templates') as batch_op:
+ batch_op.alter_column('expire_duration',
+ existing_type=sa.BigInteger(),
+ type_=sa.INTEGER(),
+ existing_nullable=True)
+ batch_op.alter_column('data_limit',
+ existing_type=sa.BigInteger(),
+ type_=sa.INTEGER(),
+ existing_nullable=True)
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/0f720f5c54dd_.py b/app/db/migrations/versions/0f720f5c54dd_.py
new file mode 100644
index 000000000..88f37237c
--- /dev/null
+++ b/app/db/migrations/versions/0f720f5c54dd_.py
@@ -0,0 +1,24 @@
+"""empty message
+
+Revision ID: 0f720f5c54dd
+Revises: 5a4446e7b165, fe7796f840a4
+Create Date: 2023-10-27 16:19:33.690427
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '0f720f5c54dd'
+down_revision = ('5a4446e7b165', 'fe7796f840a4')
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ pass
+
+
+def downgrade() -> None:
+ pass
diff --git a/app/db/migrations/versions/16e19723febc_migrate_to_groups.py b/app/db/migrations/versions/16e19723febc_migrate_to_groups.py
new file mode 100644
index 000000000..8a7cbe523
--- /dev/null
+++ b/app/db/migrations/versions/16e19723febc_migrate_to_groups.py
@@ -0,0 +1,302 @@
+"""migrate to groups
+
+Revision ID: 16e19723febc
+Revises: 3b59f3680c90
+Create Date: 2025-03-17 08:45:33.514529
+
+"""
+import json
+from enum import Enum
+from collections import defaultdict, Counter
+from decouple import config as decouple_config
+
+import commentjson
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy import Column, ForeignKey, MetaData, Table
+from sqlalchemy.orm import Session
+from app.db.models import (
+ User,
+ ProxyInbound,
+)
+
+
+# revision identifiers, used by Alembic.
+revision = '16e19723febc'
+down_revision = '3b59f3680c90'
+branch_labels = None
+depends_on = None
+
+
+class ProxyTypes(str, Enum):
+ VMess = "vmess"
+ VLESS = "vless"
+ Trojan = "trojan"
+ Shadowsocks = "shadowsocks"
+
+
+group_table = Table(
+ "groups",
+ MetaData(),
+ Column('id', sa.Integer, primary_key=True),
+ Column("name",sa.String(64),unique=True),
+ Column("is_disabled",sa.Boolean(),default=False)
+)
+proxy_table = Table(
+ 'proxies',
+ MetaData(),
+ Column('id', sa.Integer, primary_key=True),
+ Column('user_id', ForeignKey("users.id")),
+ Column('type', sa.Enum(ProxyTypes)),
+ Column('settings', sa.JSON)
+ )
+user_template_table = Table(
+ "user_templates",
+ MetaData(),
+ Column('id', sa.Integer, primary_key=True),
+ Column("name",sa.String(64),unique=True),
+ Column("data_limit", sa.BigInteger, default=0),
+ Column("expire_duration", sa.BigInteger, default=0),
+ Column("username_prefix", sa.String(20)),
+ Column("username_suffix", sa.String(20)),
+ )
+template_group_association = Table(
+ "template_group_association",
+ MetaData(),
+ Column("user_template_id", ForeignKey("user_templates.id")),
+ Column("group_id", ForeignKey("groups.id")),
+ )
+template_inbounds_association = Table(
+ "template_inbounds_association",
+ MetaData(),
+ Column("user_template_id", ForeignKey("user_templates.id")),
+ Column("inbound_tag", ForeignKey("inbounds.tag")),
+ )
+excluded_inbounds_association = Table(
+ "exclude_inbounds_association",
+ MetaData(),
+ Column("proxy_id", ForeignKey("proxies.id")),
+ Column("inbound_tag", ForeignKey("inbounds.tag")),
+ )
+inbounds_groups_association = Table(
+ "inbounds_groups_association",
+ MetaData(),
+ Column("inbound_id", ForeignKey("inbounds.id") ),
+ Column("group_id", ForeignKey("groups.id") ),
+)
+users_groups_association = Table(
+ "users_groups_association",
+ MetaData(),
+ Column("user_id", ForeignKey("users.id")),
+ Column("groups_id", ForeignKey("groups.id")),
+)
+
+base_xray = {
+ "log": {"loglevel": "warning"},
+ "inbounds": [
+ {
+ "tag": "Shadowsocks TCP",
+ "listen": "0.0.0.0",
+ "port": 1080,
+ "protocol": "shadowsocks",
+ "settings": {"clients": [], "network": "tcp,udp"},
+ }
+ ],
+ "outbounds": [{"protocol": "freedom", "tag": "DIRECT"}, {"protocol": "blackhole", "tag": "BLOCK"}],
+ "routing": {"rules": [{"ip": ["geoip:private"], "outboundTag": "BLOCK", "type": "field"}]},
+}
+
+def get_config(key, default=None, cast=None):
+ if cast is not None:
+ return decouple_config(key, default=default, cast=cast)
+ else:
+ return decouple_config(key, default=default)
+
+XRAY_JSON = get_config("XRAY_JSON", default="./xray_config.json")
+
+
+def upgrade() -> None:
+
+ try:
+ connection = op.get_bind()
+ session = Session(bind=connection)
+ if session.query(User.id).count() <= 0:
+ return
+ result = (
+ session.query(
+ User.id.label("user_id"),
+ proxy_table.c.type.label("proxy_type"),
+ ProxyInbound.tag.label("excluded_inbound_tag"),
+ )
+ .join(proxy_table, proxy_table.c.user_id == User.id)
+ .outerjoin(excluded_inbounds_association, proxy_table.c.id == excluded_inbounds_association.c.proxy_id)
+ .outerjoin(ProxyInbound, excluded_inbounds_association.c.inbound_tag == ProxyInbound.tag)
+ .group_by(User.id, proxy_table.c.type, ProxyInbound.tag)
+ .order_by(User.id, proxy_table.c.type)
+ .all()
+ )
+ template_count = session.query(user_template_table).count()
+ if template_count > 0:
+ templates = (
+ session.query(
+ user_template_table.c.id.label("tid"),
+ template_inbounds_association.c.inbound_tag.label("inbounds"),
+ )
+ .outerjoin(ProxyInbound, template_inbounds_association.c.inbound_tag == ProxyInbound.tag)
+ .group_by(user_template_table.c.id, template_inbounds_association.c.inbound_tag)
+ .order_by(user_template_table.c.id)
+ .all()
+ )
+ template_dict = defaultdict(lambda: {"id": 0, "inbounds": []})
+ for row in templates:
+ tid = row.tid
+ t_inbounds = row.inbounds
+ template_dict[tid]["id"] = tid
+ template_dict[tid]["inbounds"].append(t_inbounds)
+
+ users_dict = defaultdict(lambda: {"proxy_type": set(), "excluded_inbounds": []})
+
+ for row in result:
+ user_id = row.user_id
+ tag = row.excluded_inbound_tag or "No Excluded Inbounds"
+ proxy_type = row.proxy_type
+ users_dict[user_id]["proxy_type"].add(proxy_type)
+ users_dict[user_id]["excluded_inbounds"].append(tag)
+ if len(users_dict[user_id]["excluded_inbounds"]) > 1 and "No Excluded Inbounds" in users_dict[user_id]["excluded_inbounds"]:
+ users_dict[user_id]["excluded_inbounds"].remove("No Excluded Inbounds")
+
+ groups = defaultdict(list)
+
+ for key, value in users_dict.items():
+ excluded_inbounds = tuple(sorted(value["excluded_inbounds"]))
+ groups[excluded_inbounds].append({"id": key, **value})
+
+ try:
+ with open(XRAY_JSON, 'r') as file:
+ config = commentjson.loads(file.read())
+ except Exception:
+ config = base_xray
+
+ inbounds = [{"tag": inbound['tag'], "protocol": inbound["protocol"]}
+ for inbound in config['inbounds'] if 'tag' in inbound]
+
+ users_dict = defaultdict(lambda: {"proxy_type": set(), "excluded_inbounds": [], "inbounds": []})
+ for k, users in groups.items():
+ for user in users:
+ users_dict[user["id"]].update(**user)
+ inbounds_ = []
+ for t in user["proxy_type"]:
+ for i in inbounds:
+ if t == i["protocol"] and i["tag"] not in user["excluded_inbounds"]:
+ inbounds_.append(i["tag"])
+ users_dict[user["id"]]["inbounds"] = inbounds_
+
+ grouped = defaultdict(list)
+
+ for key, value in users_dict.items():
+ group_key = json.dumps({
+ "inbounds": value["inbounds"],
+ }, sort_keys=True)
+
+ grouped[group_key].append({"id": value["id"]})
+
+ result = {}
+ counter = 1
+ result["templates"] = {}
+ for group_key, users in grouped.items():
+ group_name = f"group{counter}"
+ group_data = json.loads(group_key)
+ dbinbounds = session.query(ProxyInbound).filter(ProxyInbound.tag.in_(group_data["inbounds"])).all()
+ session.execute(
+ group_table.insert().values(
+ name=group_name,
+ ))
+ group_id = session.execute(sa.select(group_table).where(group_table.c.name == group_name)).scalar()
+
+ # Fix: Check if dbinbounds is not empty before inserting
+ if dbinbounds:
+ inbound_data = [
+ {"inbound_id": i.id, "group_id": group_id} for i in dbinbounds if i.id is not None
+ ]
+
+ # Add additional check to ensure we're not inserting empty values
+ if inbound_data:
+ session.execute(
+ inbounds_groups_association.insert(),
+ inbound_data
+ )
+
+ users_data = [{"user_id": user["id"], "groups_id": group_id} for user in users]
+ session.execute(
+ users_groups_association.insert(),
+ users_data,
+ )
+ counter += 1
+ if template_count <= 0:
+ continue
+ session.commit()
+ for k, val in template_dict.items():
+ if Counter(val["inbounds"]) == Counter(group_data["inbounds"]):
+ # Check if association already exists
+ exists = session.execute(
+ sa.select(template_group_association).where(
+ (template_group_association.c.user_template_id == int(k)) &
+ (template_group_association.c.group_id == group_id)
+ )
+ ).scalar()
+
+ if not exists:
+ # Insert new association
+ session.execute(
+ template_group_association.insert().values(
+ user_template_id=int(k),
+ group_id=group_id
+ )
+ )
+
+ if template_count > 0:
+ dbtemplates_assin = session.execute(template_inbounds_association.select()).all()
+ grouped_data = defaultdict(list)
+ for number, text in dbtemplates_assin:
+ grouped_data[number].append(text)
+
+ for k,inbounds in grouped_data.items():
+ group_name = f"group{counter}"
+ user_template = session.execute(user_template_table.select().where(user_template_table.c.id == int(k))).first()
+
+ template_groups = session.execute(template_group_association.select().where(template_group_association.c.user_template_id == k)).first()
+ if not template_groups:
+ session.execute(group_table.insert().values(
+ name=group_name
+ ))
+ group_id = session.execute(sa.select(group_table).where(group_table.c.name == group_name)).scalar()
+
+ dbinbounds = session.query(ProxyInbound).filter(ProxyInbound.tag.in_(inbounds)).all()
+
+ # Fix: Similar check here to ensure we have valid data before insert
+ if dbinbounds:
+ inbound_data = [
+ {"inbound_id": i.id, "group_id": group_id} for i in dbinbounds if i.id is not None
+ ]
+
+ # Add additional check to ensure we're not inserting empty values
+ if inbound_data:
+ session.execute(
+ inbounds_groups_association.insert(),
+ inbound_data
+ )
+
+ session.execute(
+ template_group_association.insert().values(
+ user_template_id=int(k),
+ group_id=group_id,
+ )
+ )
+ counter += 1
+
+ finally:
+ session.commit()
+ session.close()
+
+def downgrade() -> None:
+ pass
\ No newline at end of file
diff --git a/app/db/migrations/versions/1ad79b97fdcf_.py b/app/db/migrations/versions/1ad79b97fdcf_.py
new file mode 100644
index 000000000..ec44ec8ac
--- /dev/null
+++ b/app/db/migrations/versions/1ad79b97fdcf_.py
@@ -0,0 +1,24 @@
+"""empty message
+
+Revision ID: 1ad79b97fdcf
+Revises: 852d951c9c08, adda2dd4a741
+Create Date: 2024-02-28 15:11:19.999585
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '1ad79b97fdcf'
+down_revision = ('852d951c9c08', 'adda2dd4a741')
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ pass
+
+
+def downgrade() -> None:
+ pass
diff --git a/app/db/migrations/versions/1ccf7b18b283_add_extra_sttings_to_usertemplates_table.py b/app/db/migrations/versions/1ccf7b18b283_add_extra_sttings_to_usertemplates_table.py
new file mode 100644
index 000000000..aa5379479
--- /dev/null
+++ b/app/db/migrations/versions/1ccf7b18b283_add_extra_sttings_to_usertemplates_table.py
@@ -0,0 +1,28 @@
+"""add extra_sttings to usertemplates table
+
+Revision ID: 1ccf7b18b283
+Revises: 35760ebfabd2
+Create Date: 2025-04-07 11:57:39.917127
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '1ccf7b18b283'
+down_revision = '35760ebfabd2'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('user_templates', sa.Column('extra_settings', sa.JSON(none_as_null=True), nullable=True))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('user_templates', 'extra_settings')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/1cf7d159fdbb_add_online_at.py b/app/db/migrations/versions/1cf7d159fdbb_add_online_at.py
new file mode 100644
index 000000000..b29aff0ca
--- /dev/null
+++ b/app/db/migrations/versions/1cf7d159fdbb_add_online_at.py
@@ -0,0 +1,28 @@
+"""add online at
+
+Revision ID: 1cf7d159fdbb
+Revises: 025d427831dd
+Create Date: 2023-08-23 15:48:26.281666
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '1cf7d159fdbb'
+down_revision = '025d427831dd'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('users', sa.Column('online_at', sa.DateTime(), nullable=True))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('users', 'online_at')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/1dc5f89b706a_drop_proxies_table.py b/app/db/migrations/versions/1dc5f89b706a_drop_proxies_table.py
new file mode 100644
index 000000000..3ce576d28
--- /dev/null
+++ b/app/db/migrations/versions/1dc5f89b706a_drop_proxies_table.py
@@ -0,0 +1,35 @@
+"""drop proxies table
+
+Revision ID: 1dc5f89b706a
+Revises: db68f8d3d40b
+Create Date: 2025-03-17 13:08:21.002382
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import sqlite
+
+# revision identifiers, used by Alembic.
+revision = '1dc5f89b706a'
+down_revision = 'db68f8d3d40b'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table('proxies')
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('proxies',
+ sa.Column('id', sa.INTEGER(), nullable=False),
+ sa.Column('user_id', sa.INTEGER(), nullable=True),
+ sa.Column('type', sa.VARCHAR(length=11), nullable=False),
+ sa.Column('settings', sqlite.JSON(), nullable=False),
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/1f6e978be88e_migrate_to_sqlalchemy_v2.py b/app/db/migrations/versions/1f6e978be88e_migrate_to_sqlalchemy_v2.py
new file mode 100644
index 000000000..71f428190
--- /dev/null
+++ b/app/db/migrations/versions/1f6e978be88e_migrate_to_sqlalchemy_v2.py
@@ -0,0 +1,244 @@
+"""migrate to sqlalchemy v2
+
+Revision ID: 1f6e978be88e
+Revises: 0d22271ee06e
+Create Date: 2025-03-22 22:08:45.392485
+
+"""
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import mysql, postgresql
+from app.db.models import CaseSensitiveString
+
+
+# revision identifiers, used by Alembic.
+revision = "1f6e978be88e"
+down_revision = "0d22271ee06e"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ connection = op.get_bind()
+ dialect = connection.dialect.name
+ # Alter admin_usage_logs table
+ with op.batch_alter_table("admin_usage_logs") as batch_op:
+ batch_op.alter_column("admin_id", existing_type=sa.INTEGER(), nullable=False)
+ batch_op.alter_column("reset_at", existing_type=sa.DATETIME(), nullable=False)
+
+ # Alter admins table
+ with op.batch_alter_table("admins") as batch_op:
+ batch_op.alter_column("username", existing_type=sa.VARCHAR(length=34), nullable=False)
+ batch_op.alter_column("hashed_password", existing_type=sa.VARCHAR(length=128), nullable=False)
+ batch_op.alter_column("created_at", existing_type=sa.DATETIME(), nullable=False)
+ batch_op.alter_column(
+ "is_sudo", existing_type=sa.BOOLEAN(), nullable=False, existing_server_default=sa.text("0")
+ )
+
+ # Alter groups table
+ with op.batch_alter_table("groups") as batch_op:
+ batch_op.alter_column("name", existing_type=sa.VARCHAR(length=64), nullable=False)
+
+ # Alter node_usages table
+ with op.batch_alter_table("node_usages") as batch_op:
+ batch_op.alter_column("uplink", existing_type=sa.BIGINT(), nullable=False)
+ batch_op.alter_column("downlink", existing_type=sa.BIGINT(), nullable=False)
+
+ # Alter node_user_usages table
+ with op.batch_alter_table("node_user_usages") as batch_op:
+ batch_op.alter_column("user_id", existing_type=sa.INTEGER(), nullable=False)
+ batch_op.alter_column("used_traffic", existing_type=sa.BIGINT(), nullable=False)
+
+ # Alter nodes table
+ with op.batch_alter_table("nodes") as batch_op:
+ batch_op.alter_column("name", existing_type=sa.VARCHAR(length=256), nullable=False)
+ batch_op.alter_column("created_at", existing_type=sa.DATETIME(), nullable=False)
+ batch_op.alter_column("uplink", existing_type=sa.BIGINT(), nullable=False)
+ batch_op.alter_column("downlink", existing_type=sa.BIGINT(), nullable=False)
+
+ # Alter notification_reminders table
+ with op.batch_alter_table("notification_reminders") as batch_op:
+ batch_op.alter_column("user_id", existing_type=sa.INTEGER(), nullable=False)
+ batch_op.alter_column("created_at", existing_type=sa.DATETIME(), nullable=False)
+
+ # Alter system table
+ with op.batch_alter_table("system") as batch_op:
+ batch_op.alter_column("uplink", existing_type=sa.BIGINT(), nullable=False)
+ batch_op.alter_column("downlink", existing_type=sa.BIGINT(), nullable=False)
+
+ # Alter user_templates table
+ with op.batch_alter_table("user_templates") as batch_op:
+ batch_op.alter_column("data_limit", existing_type=sa.BIGINT(), nullable=False)
+ batch_op.alter_column("expire_duration", existing_type=sa.BIGINT(), nullable=False)
+
+ # Alter user_usage_logs table
+ with op.batch_alter_table("user_usage_logs") as batch_op:
+ batch_op.alter_column("reset_at", existing_type=sa.DATETIME(), nullable=False)
+
+ # Alter users table
+ with op.batch_alter_table("users") as batch_op:
+ batch_op.alter_column("username", existing_type=sa.VARCHAR(length=34), type_=CaseSensitiveString(length=34), nullable=False)
+ batch_op.alter_column("used_traffic", existing_type=sa.BIGINT(), nullable=False)
+ if dialect == "sqlite":
+ batch_op.alter_column(
+ "created_at",
+ existing_type=sa.DATETIME(),
+ nullable=False,
+ existing_server_default=sa.text("(CURRENT_TIMESTAMP)"),
+ )
+ elif dialect == "mysql":
+ batch_op.alter_column(
+ "created_at", existing_type=mysql.DATETIME(), nullable=False, existing_server_default=sa.text("(now())")
+ )
+ elif dialect == "postgresql":
+ batch_op.alter_column(
+ "created_at",
+ existing_type=postgresql.TIMESTAMP(),
+ nullable=False,
+ existing_server_default=sa.text("CURRENT_TIMESTAMP"),
+ )
+
+ if dialect == "mysql":
+ op.alter_column(
+ "hosts",
+ "alpn",
+ existing_type=mysql.ENUM("h3", "h3,h2", "h3,h2,http/1.1", "none", "h2", "http/1.1", "h2,http/1.1"),
+ type_=sa.Enum(
+ "none", "h3", "h2", "http/1.1", "h3,h2,http/1.1", "h3,h2", "h2,http/1.1", name="proxyhostalpn"
+ ),
+ existing_nullable=False,
+ )
+ op.alter_column(
+ "users",
+ "status",
+ existing_type=mysql.ENUM("on_hold", "active", "limited", "expired", "disabled"),
+ type_=sa.Enum("active", "disabled", "limited", "expired", "on_hold", name="userstatus"),
+ existing_nullable=False,
+ )
+
+ if dialect == "postgresql":
+ op.alter_column(
+ "users",
+ "proxy_settings",
+ existing_type=postgresql.JSONB(astext_type=sa.Text()),
+ type_=sa.JSON(none_as_null=True),
+ existing_nullable=False,
+ existing_server_default=sa.text("'{}'::jsonb"),
+ )
+
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ connection = op.get_bind()
+ dialect = connection.dialect.name
+
+ if dialect == "postgresql":
+ op.alter_column(
+ "users",
+ "proxy_settings",
+ existing_type=sa.JSON(none_as_null=True),
+ type_=postgresql.JSONB(astext_type=sa.Text()),
+ existing_nullable=False,
+ existing_server_default=sa.text("'{}'::jsonb"),
+ postgresql_using="proxy_settings::jsonb",
+ )
+
+ if dialect == "mysql":
+ op.alter_column(
+ "users",
+ "status",
+ existing_type=sa.Enum("active", "disabled", "limited", "expired", "on_hold", name="userstatus"),
+ type_=mysql.ENUM("on_hold", "active", "limited", "expired", "disabled"),
+ existing_nullable=False,
+ )
+ op.alter_column(
+ "hosts",
+ "alpn",
+ existing_type=sa.Enum(
+ "none", "h3", "h2", "http/1.1", "h3,h2,http/1.1", "h3,h2", "h2,http/1.1", name="proxyhostalpn"
+ ),
+ type_=mysql.ENUM("h3", "h3,h2", "h3,h2,http/1.1", "none", "h2", "http/1.1", "h2,http/1.1"),
+ existing_nullable=False,
+ )
+
+ # Alter users table
+ with op.batch_alter_table("users") as batch_op:
+ if dialect == "sqlite":
+ batch_op.alter_column(
+ "created_at",
+ existing_type=sa.DATETIME(),
+ nullable=True,
+ existing_server_default=sa.text("(CURRENT_TIMESTAMP)"),
+ )
+
+ elif dialect == "mysql":
+ batch_op.alter_column(
+ "created_at", existing_type=mysql.DATETIME(), nullable=True, existing_server_default=sa.text("(now())")
+ )
+
+ elif dialect == "postgresql":
+ batch_op.alter_column(
+ "created_at",
+ existing_type=postgresql.TIMESTAMP(),
+ nullable=True,
+ existing_server_default=sa.text("CURRENT_TIMESTAMP"),
+ )
+
+ batch_op.alter_column("used_traffic", existing_type=sa.BIGINT(), nullable=True)
+ batch_op.alter_column("username", existing_type=sa.VARCHAR(length=34), type_=CaseSensitiveString(length=34), nullable=True)
+
+ # Alter user_usage_logs table
+ with op.batch_alter_table("user_usage_logs") as batch_op:
+ batch_op.alter_column("reset_at", existing_type=sa.DATETIME(), nullable=True)
+
+ # Alter user_templates table
+ with op.batch_alter_table("user_templates") as batch_op:
+ batch_op.alter_column("expire_duration", existing_type=sa.BIGINT(), nullable=True)
+ batch_op.alter_column("data_limit", existing_type=sa.BIGINT(), nullable=True)
+
+ # Alter system table
+ with op.batch_alter_table("system") as batch_op:
+ batch_op.alter_column("downlink", existing_type=sa.BIGINT(), nullable=True)
+ batch_op.alter_column("uplink", existing_type=sa.BIGINT(), nullable=True)
+
+ # Alter notification_reminders table
+ with op.batch_alter_table("notification_reminders") as batch_op:
+ batch_op.alter_column("created_at", existing_type=sa.DATETIME(), nullable=True)
+ batch_op.alter_column("user_id", existing_type=sa.INTEGER(), nullable=True)
+
+ # Alter nodes table
+ with op.batch_alter_table("nodes") as batch_op:
+ batch_op.alter_column("downlink", existing_type=sa.BIGINT(), nullable=True)
+ batch_op.alter_column("uplink", existing_type=sa.BIGINT(), nullable=True)
+ batch_op.alter_column("created_at", existing_type=sa.DATETIME(), nullable=True)
+ batch_op.alter_column("name", existing_type=sa.VARCHAR(length=256), nullable=True)
+
+ # Alter node_user_usages table
+ with op.batch_alter_table("node_user_usages") as batch_op:
+ batch_op.alter_column("used_traffic", existing_type=sa.BIGINT(), nullable=True)
+ batch_op.alter_column("user_id", existing_type=sa.INTEGER(), nullable=True)
+
+ # Alter node_usages table
+ with op.batch_alter_table("node_usages") as batch_op:
+ batch_op.alter_column("downlink", existing_type=sa.BIGINT(), nullable=True)
+ batch_op.alter_column("uplink", existing_type=sa.BIGINT(), nullable=True)
+
+ # Alter groups table
+ with op.batch_alter_table("groups") as batch_op:
+ batch_op.alter_column("name", existing_type=sa.VARCHAR(length=64), nullable=True)
+
+ # Alter admins table
+ with op.batch_alter_table("admins") as batch_op:
+ batch_op.alter_column(
+ "is_sudo", existing_type=sa.BOOLEAN(), nullable=True, existing_server_default=sa.text("0")
+ )
+ batch_op.alter_column("created_at", existing_type=sa.DATETIME(), nullable=True)
+ batch_op.alter_column("hashed_password", existing_type=sa.VARCHAR(length=128), nullable=True)
+ batch_op.alter_column("username", existing_type=sa.VARCHAR(length=34), nullable=True)
+
+ # Alter admin_usage_logs table
+ with op.batch_alter_table("admin_usage_logs") as batch_op:
+ batch_op.alter_column("reset_at", existing_type=sa.DATETIME(), nullable=True)
+ batch_op.alter_column("admin_id", existing_type=sa.INTEGER(), nullable=True)
diff --git a/app/db/migrations/versions/21226bc711ac_add_threshold_to_notificationreminder.py b/app/db/migrations/versions/21226bc711ac_add_threshold_to_notificationreminder.py
new file mode 100644
index 000000000..e7fa93775
--- /dev/null
+++ b/app/db/migrations/versions/21226bc711ac_add_threshold_to_notificationreminder.py
@@ -0,0 +1,28 @@
+"""add threshold to NotificationReminder
+
+Revision ID: 21226bc711ac
+Revises: 2ea33513efc0
+Create Date: 2024-10-18 12:26:30.504491
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '21226bc711ac'
+down_revision = '2ea33513efc0'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('notification_reminders', sa.Column('threshold', sa.Integer(), nullable=True))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('notification_reminders', 'threshold')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/2313cdc30da3_.py b/app/db/migrations/versions/2313cdc30da3_.py
new file mode 100644
index 000000000..a55726ec0
--- /dev/null
+++ b/app/db/migrations/versions/2313cdc30da3_.py
@@ -0,0 +1,24 @@
+"""empty message
+
+Revision ID: 2313cdc30da3
+Revises: 305943d779c4, 07f9bbb3db4e
+Create Date: 2024-07-12 16:18:15.012072
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '2313cdc30da3'
+down_revision = ('305943d779c4', '07f9bbb3db4e')
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ pass
+
+
+def downgrade() -> None:
+ pass
diff --git a/app/db/migrations/versions/2b231de97dc3_add_use_sni_as_host_to_hosts.py b/app/db/migrations/versions/2b231de97dc3_add_use_sni_as_host_to_hosts.py
new file mode 100644
index 000000000..b724c999e
--- /dev/null
+++ b/app/db/migrations/versions/2b231de97dc3_add_use_sni_as_host_to_hosts.py
@@ -0,0 +1,27 @@
+"""add use sni as host to hosts
+
+Revision ID: 2b231de97dc3
+Revises: e7b869e999b4
+Create Date: 2024-12-15 09:48:24.330959
+
+"""
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = '2b231de97dc3'
+down_revision = 'e7b869e999b4'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('hosts', sa.Column('use_sni_as_host', sa.Boolean(), server_default='0', nullable=False))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('hosts', 'use_sni_as_host')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/2ea33513efc0_noise_for_sqlite.py b/app/db/migrations/versions/2ea33513efc0_noise_for_sqlite.py
new file mode 100644
index 000000000..dd847127e
--- /dev/null
+++ b/app/db/migrations/versions/2ea33513efc0_noise_for_sqlite.py
@@ -0,0 +1,32 @@
+"""noise for sqlite
+
+Revision ID: 2ea33513efc0
+Revises: a9cfd5611a82
+Create Date: 2024-10-11 15:36:32.096594
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '2ea33513efc0'
+down_revision = 'a9cfd5611a82'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+
+ if bind.engine.name == 'sqlite':
+ with op.batch_alter_table('hosts') as batch_op:
+ batch_op.alter_column('noise_setting',
+ existing_type=sa.String(length=2000),
+ nullable=True)
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ pass
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/305943d779c4_add_h3_to_alpn_enum.py b/app/db/migrations/versions/305943d779c4_add_h3_to_alpn_enum.py
new file mode 100644
index 000000000..a2d739be3
--- /dev/null
+++ b/app/db/migrations/versions/305943d779c4_add_h3_to_alpn_enum.py
@@ -0,0 +1,119 @@
+"""add h3 to alpn enum
+
+Revision ID: 305943d779c4
+Revises: 31f92220c0d0
+Create Date: 2024-07-03 19:27:15.282711
+
+"""
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.dialects import postgresql
+
+
+# revision identifiers, used by Alembic.
+revision = '305943d779c4'
+down_revision = '31f92220c0d0'
+branch_labels = None
+depends_on = None
+
+
+# Enum configuration
+enum_name = "alpn"
+temp_enum_name = f"temp_{enum_name}"
+old_values = ("none", "h2", "http/1.1", "h2,http/1.1")
+new_values = ("h3", "h3,h2", "h3,h2,http/1.1", *old_values)
+
+# Downgrade configuration
+downgrade_from = ("h3", "h3,h2", "h3,h2,http/1.1", "")
+downgrade_to = "none"
+
+old_type = sa.Enum(*old_values, name=enum_name)
+new_type = sa.Enum(*new_values, name=enum_name)
+
+table_name = "hosts"
+column_name = "alpn"
+
+def upgrade() -> None:
+ # 1. Create new enum type
+ new_type.create(op.get_bind(), checkfirst=True)
+
+ # 2. Handle PostgreSQL-specific migration
+ if op.get_bind().dialect.name == 'postgresql':
+ connection = op.get_bind()
+
+ # Temporary migration column
+ with op.batch_alter_table(table_name) as batch_op:
+ batch_op.add_column(sa.Column('alpn_new', sa.Text(), nullable=True))
+
+ # Copy existing values, using a CASE to handle potential unmapped values
+ connection.execute(sa.text(f"""
+ UPDATE {table_name}
+ SET alpn_new = CASE
+ WHEN alpn IS NULL THEN 'none'
+ ELSE alpn::text
+ END
+ """))
+
+ # Drop old column and rename new column
+ with op.batch_alter_table(table_name) as batch_op:
+ batch_op.drop_column('alpn')
+ batch_op.alter_column('alpn_new',
+ new_column_name='alpn',
+ type_=new_type,
+ nullable=False,
+ server_default=sa.text("'none'"),
+ postgresql_using='alpn_new::alpn'
+ )
+ else:
+ # For other databases, use standard column type modification
+ with op.batch_alter_table(table_name) as batch_op:
+ batch_op.alter_column(
+ column_name,
+ existing_type=old_type,
+ type_=new_type,
+ existing_nullable=False
+ )
+
+def downgrade() -> None:
+ # 1. Create old enum type
+ old_type.create(op.get_bind(), checkfirst=True)
+
+ # 2. Handle PostgreSQL-specific downgrade
+ if op.get_bind().dialect.name == 'postgresql':
+ connection = op.get_bind()
+
+ # Temporary migration column
+ with op.batch_alter_table(table_name) as batch_op:
+ batch_op.add_column(sa.Column('alpn_old', sa.Text(), nullable=True))
+
+ # Copy existing values with specific downgrade logic
+ connection.execute(sa.text(f"""
+ UPDATE {table_name}
+ SET alpn_old = CASE
+ WHEN alpn::text IN {downgrade_from} THEN '{downgrade_to}'
+ ELSE alpn::text
+ END
+ """))
+
+ # Drop current column and rename old column
+ with op.batch_alter_table(table_name) as batch_op:
+ batch_op.drop_column('alpn')
+ batch_op.alter_column('alpn_old',
+ new_column_name='alpn',
+ type_=old_type,
+ nullable=False,
+ server_default=sa.text("'none'"),
+ postgresql_using='alpn_old::alpn'
+ )
+ else:
+ # For other databases, use standard column type modification
+ with op.batch_alter_table(table_name) as batch_op:
+ batch_op.alter_column(
+ column_name,
+ existing_type=new_type,
+ type_=old_type,
+ existing_nullable=False
+ )
+
+ # Drop the new enum type
+ new_type.drop(op.get_bind(), checkfirst=True)
\ No newline at end of file
diff --git a/app/db/migrations/versions/31f92220c0d0_add_support_random_user_agent.py b/app/db/migrations/versions/31f92220c0d0_add_support_random_user_agent.py
new file mode 100644
index 000000000..65c9b3412
--- /dev/null
+++ b/app/db/migrations/versions/31f92220c0d0_add_support_random_user_agent.py
@@ -0,0 +1,28 @@
+"""Add Support Random User-Agent
+
+Revision ID: 31f92220c0d0
+Revises: 4f045f53bef8
+Create Date: 2024-06-01 21:28:33.310627
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '31f92220c0d0'
+down_revision = '4f045f53bef8'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('hosts', sa.Column('random_user_agent', sa.Boolean(), server_default='0', nullable=False))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('hosts', 'random_user_agent')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/343ad7904b19_remove_tls_table.py b/app/db/migrations/versions/343ad7904b19_remove_tls_table.py
new file mode 100644
index 000000000..78853fb67
--- /dev/null
+++ b/app/db/migrations/versions/343ad7904b19_remove_tls_table.py
@@ -0,0 +1,38 @@
+"""Remove tls table
+
+Revision ID: 343ad7904b19
+Revises: d607d3ca5246
+Create Date: 2025-07-12 23:25:45.994090
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from app.utils.crypto import generate_certificate
+
+
+# revision identifiers, used by Alembic.
+revision = '343ad7904b19'
+down_revision = 'd607d3ca5246'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table('tls')
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ table = op.create_table('tls',
+ sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
+ sa.Column('key', sa.VARCHAR(length=4096), autoincrement=False, nullable=False),
+ sa.Column('certificate', sa.VARCHAR(length=2048), autoincrement=False, nullable=False),
+ sa.PrimaryKeyConstraint('id', name='tls_pkey')
+ )
+
+ tls = generate_certificate()
+ op.bulk_insert(table, [{"id": 1, "key": tls['key'], "certificate": tls['cert']}])
+
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/35760ebfabd2_add_status_to_hosts.py b/app/db/migrations/versions/35760ebfabd2_add_status_to_hosts.py
new file mode 100644
index 000000000..0ae1679df
--- /dev/null
+++ b/app/db/migrations/versions/35760ebfabd2_add_status_to_hosts.py
@@ -0,0 +1,45 @@
+"""add status to hosts
+
+Revision ID: 35760ebfabd2
+Revises: f44ec4769d5d
+Create Date: 2025-03-28 09:04:24.440491
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from app.db.compiles_types import EnumArray
+
+
+# revision identifiers, used by Alembic.
+revision = '35760ebfabd2'
+down_revision = 'f44ec4769d5d'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ dialect = op.get_bind().dialect.name
+
+ # Define the column type once for reusability
+ status_column_type = EnumArray(sa.Enum('active', 'disabled', 'limited', 'expired', 'on_hold', name='userstatus'))
+
+ if dialect == 'mysql':
+ # MySQL: Add nullable, update, then alter to non-nullable
+ op.add_column('hosts', sa.Column('status', status_column_type, nullable=True))
+ op.execute("UPDATE hosts SET status = '[]'") # Set default for existing rows
+ op.alter_column('hosts', 'status', nullable=False,
+ existing_type=status_column_type)
+ elif dialect == 'postgresql':
+ # PostgreSQL: Use server_default='{}' as PostgreSQL uses array syntax
+ op.add_column('hosts', sa.Column('status', status_column_type,
+ server_default='{}', nullable=False))
+ else:
+ # SQLite and others: Use server_default='[]'
+ op.add_column('hosts', sa.Column('status', status_column_type,
+ server_default='[]', nullable=False))
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('hosts', 'status')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/35f7f8fa9cf2_add_sub_revoked_at_to_users.py b/app/db/migrations/versions/35f7f8fa9cf2_add_sub_revoked_at_to_users.py
new file mode 100644
index 000000000..87e8c0400
--- /dev/null
+++ b/app/db/migrations/versions/35f7f8fa9cf2_add_sub_revoked_at_to_users.py
@@ -0,0 +1,28 @@
+"""add sub_revoked_at to users
+
+Revision ID: 35f7f8fa9cf2
+Revises: fc01b1520e72
+Create Date: 2023-07-06 15:52:00.307575
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '35f7f8fa9cf2'
+down_revision = 'fc01b1520e72'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('users', sa.Column('sub_revoked_at', sa.DateTime(), nullable=True))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('users', 'sub_revoked_at')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/37692c1c9715_nodes.py b/app/db/migrations/versions/37692c1c9715_nodes.py
new file mode 100644
index 000000000..47a1cfd39
--- /dev/null
+++ b/app/db/migrations/versions/37692c1c9715_nodes.py
@@ -0,0 +1,47 @@
+"""nodes
+
+Revision ID: 37692c1c9715
+Revises: 97dd9311ab93
+Create Date: 2023-03-27 02:49:00.118903
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '37692c1c9715'
+down_revision = '97dd9311ab93'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('nodes',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('name', sa.String(length=256, collation=(
+ 'NOCASE' if op.get_bind().engine.name == 'sqlite' else ''
+ )), nullable=True),
+ sa.Column('address', sa.String(length=256), nullable=False),
+ sa.Column('port', sa.Integer(), nullable=False),
+ sa.Column('api_port', sa.Integer(), nullable=False),
+ sa.Column('certificate', sa.String(length=2048), nullable=False),
+ sa.Column('status', sa.Enum('connected','connecting','error','disabled', name='nodestatus'), nullable=False),
+ sa.Column('last_status_change', sa.DateTime(), nullable=True),
+ sa.Column('message', sa.String(length=1024), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=True),
+ sa.Column('uplink', sa.BigInteger(), nullable=True),
+ sa.Column('downlink', sa.BigInteger(), nullable=True),
+ sa.PrimaryKeyConstraint('id'),
+ sa.UniqueConstraint('name')
+ )
+ op.create_index(op.f('ix_nodes_id'), 'nodes', ['id'], unique=False)
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_index(op.f('ix_nodes_id'), table_name='nodes')
+ op.drop_table('nodes')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/3b59f3680c90_add_groups_table_and_add_proxy_settings_column_to_users_table.py.py b/app/db/migrations/versions/3b59f3680c90_add_groups_table_and_add_proxy_settings_column_to_users_table.py.py
new file mode 100644
index 000000000..7f546cf20
--- /dev/null
+++ b/app/db/migrations/versions/3b59f3680c90_add_groups_table_and_add_proxy_settings_column_to_users_table.py.py
@@ -0,0 +1,82 @@
+"""add groups table and add proxy_settings column to users table
+
+Revision ID: 3b59f3680c90
+Revises: c41c441de44c
+Create Date: 2025-03-17 08:35:44.071861
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import mysql, postgresql
+
+
+# revision identifiers, used by Alembic.
+revision = '3b59f3680c90'
+down_revision = 'c41c441de44c'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('groups',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('name', sa.String(length=64), nullable=True),
+ sa.Column('is_disabled', sa.Boolean(), nullable=False, server_default='0', default=False),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_table('inbounds_groups_association',
+ sa.Column('inbound_id', sa.Integer(), nullable=False),
+ sa.Column('group_id', sa.Integer(), nullable=False),
+ sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ),
+ sa.ForeignKeyConstraint(['inbound_id'], ['inbounds.id'], ),
+ sa.PrimaryKeyConstraint('inbound_id', 'group_id')
+ )
+ op.create_table('template_group_association',
+ sa.Column('user_template_id', sa.Integer(), nullable=True),
+ sa.Column('group_id', sa.Integer(), nullable=True),
+ sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ),
+ sa.ForeignKeyConstraint(['user_template_id'], ['user_templates.id'], )
+ )
+ op.create_table('users_groups_association',
+ sa.Column('user_id', sa.Integer(), nullable=False),
+ sa.Column('groups_id', sa.Integer(), nullable=False),
+ sa.ForeignKeyConstraint(['groups_id'], ['groups.id'], ),
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
+ sa.PrimaryKeyConstraint('user_id', 'groups_id')
+ )
+
+ # Handle proxy_settings column addition based on database type
+ dialect = op.get_bind().dialect.name
+ if dialect == 'mysql':
+ # For MySQL: Add column first, then update with default value
+ op.add_column('users', sa.Column('proxy_settings', mysql.JSON(), nullable=True))
+ op.execute("UPDATE users SET proxy_settings = '{}'")
+ # Make it not nullable after setting default, specifying the existing type
+ with op.batch_alter_table('users') as batch_op:
+ batch_op.alter_column('proxy_settings',
+ existing_type=mysql.JSON(),
+ nullable=False)
+ elif dialect == 'postgresql':
+ # For PostgreSQL: Can use JSONB with default
+ op.add_column('users', sa.Column('proxy_settings',
+ postgresql.JSONB(),
+ server_default='{}',
+ nullable=False))
+ else:
+ # For SQLite and others: Use standard JSON
+ op.add_column('users', sa.Column('proxy_settings',
+ sa.JSON(none_as_null=True),
+ server_default='{}',
+ nullable=False))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table('users_groups_association')
+ op.drop_table('template_group_association')
+ op.drop_table('inbounds_groups_association')
+ op.drop_table('groups')
+ op.drop_column('users', 'proxy_settings')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/3c466ce2ab63_round_float_values.py b/app/db/migrations/versions/3c466ce2ab63_round_float_values.py
new file mode 100644
index 000000000..ada1a7994
--- /dev/null
+++ b/app/db/migrations/versions/3c466ce2ab63_round_float_values.py
@@ -0,0 +1,61 @@
+"""round float values
+
+Revision ID: 3c466ce2ab63
+Revises: fbfc49f01004
+Create Date: 2025-06-09 10:45:51.691719
+
+"""
+from alembic import op
+from sqlalchemy import MetaData, Table, func, update
+
+
+# revision identifiers, used by Alembic.
+revision = '3c466ce2ab63'
+down_revision = 'fbfc49f01004'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # Get connection and metadata
+ connection = op.get_bind()
+ metadata = MetaData()
+
+ # Reflect tables
+ admins = Table('admins', metadata, autoload_with=connection)
+ node_user_usages = Table('node_user_usages', metadata, autoload_with=connection)
+ user_usage_logs = Table('user_usage_logs', metadata, autoload_with=connection)
+ users = Table('users', metadata, autoload_with=connection)
+
+ # Update admins table - truncate used_traffic column (remove decimal part)
+ connection.execute(
+ update(admins).values(
+ used_traffic=func.floor(admins.c.used_traffic)
+ )
+ )
+
+ # Update node_user_usages table - truncate used_traffic column (remove decimal part)
+ connection.execute(
+ update(node_user_usages).values(
+ used_traffic=func.floor(node_user_usages.c.used_traffic)
+ )
+ )
+
+ # Update user_usage_logs table - truncate used_traffic_at_reset column (remove decimal part)
+ connection.execute(
+ update(user_usage_logs).values(
+ used_traffic_at_reset=func.floor(user_usage_logs.c.used_traffic_at_reset)
+ )
+ )
+
+ # Update users table - truncate used_traffic column (remove decimal part)
+ connection.execute(
+ update(users).values(
+ used_traffic=func.floor(users.c.used_traffic)
+ )
+ )
+
+
+def downgrade() -> None:
+ # No downgrade needed as truncating decimals is irreversible
+ pass
diff --git a/app/db/migrations/versions/3cf36a5fde73_init_system_table.py b/app/db/migrations/versions/3cf36a5fde73_init_system_table.py
new file mode 100644
index 000000000..a02523291
--- /dev/null
+++ b/app/db/migrations/versions/3cf36a5fde73_init_system_table.py
@@ -0,0 +1,39 @@
+"""init system table
+
+Revision ID: 3cf36a5fde73
+Revises: 94a5cc12c0d6
+Create Date: 2022-11-22 04:48:55.227490
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '3cf36a5fde73'
+down_revision = '94a5cc12c0d6'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ table = op.create_table('system',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('uplink', sa.BigInteger(), nullable=True),
+ sa.Column('downlink', sa.BigInteger(), nullable=True),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_system_id'), 'system', ['id'], unique=False)
+
+ # INSERT DEFAULT ROW
+ op.bulk_insert(table, [{"id": 1, "uplink": 0, "downlink": 0}])
+
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_index(op.f('ix_system_id'), table_name='system')
+ op.drop_table('system')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/470465472326_add_path_to_hosts.py b/app/db/migrations/versions/470465472326_add_path_to_hosts.py
new file mode 100644
index 000000000..00093c35c
--- /dev/null
+++ b/app/db/migrations/versions/470465472326_add_path_to_hosts.py
@@ -0,0 +1,26 @@
+"""add path to hosts
+
+Revision ID: 470465472326
+Revises: e56f1c781e46
+Create Date: 2023-12-15 05:46:43.493605
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '470465472326'
+down_revision = 'e56f1c781e46'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ with op.batch_alter_table('hosts') as batch_op:
+ batch_op.add_column(sa.Column('path', sa.String(256), nullable=True))
+
+
+def downgrade() -> None:
+ with op.batch_alter_table('hosts') as batch_op:
+ batch_op.drop_column('path')
diff --git a/app/db/migrations/versions/4e66033a2b9b_separate_nodes_config.py b/app/db/migrations/versions/4e66033a2b9b_separate_nodes_config.py
new file mode 100644
index 000000000..b5de7c44b
--- /dev/null
+++ b/app/db/migrations/versions/4e66033a2b9b_separate_nodes_config.py
@@ -0,0 +1,111 @@
+"""separate nodes config
+
+Revision ID: 4e66033a2b9b
+Revises: 1ccf7b18b283
+Create Date: 2025-04-08 17:39:03.984285
+
+"""
+
+from alembic import op
+import sqlalchemy as sa
+import commentjson
+from datetime import datetime as dt, timezone as tz
+from decouple import config as decouple_config
+
+from app.core.xray import XRayConfig
+
+
+# revision identifiers, used by Alembic.
+revision = "4e66033a2b9b"
+down_revision = "1ccf7b18b283"
+branch_labels = None
+depends_on = None
+
+
+base_xray = {
+ "log": {"loglevel": "warning"},
+ "inbounds": [
+ {
+ "tag": "Shadowsocks TCP",
+ "listen": "0.0.0.0",
+ "port": 1080,
+ "protocol": "shadowsocks",
+ "settings": {"clients": [], "network": "tcp,udp"},
+ }
+ ],
+ "outbounds": [{"protocol": "freedom", "tag": "DIRECT"}, {"protocol": "blackhole", "tag": "BLOCK"}],
+ "routing": {"rules": [{"ip": ["geoip:private"], "outboundTag": "BLOCK", "type": "field"}]},
+}
+
+def get_config(key, default=None, cast=None):
+ if cast is not None:
+ return decouple_config(key, default=default, cast=cast)
+ else:
+ return decouple_config(key, default=default)
+
+
+XRAY_JSON = get_config("XRAY_JSON", default="./xray_config.json")
+XRAY_FALLBACKS_INBOUND_TAGS = get_config(
+ "XRAY_FALLBACKS_INBOUND_TAG", cast=lambda v: [tag.strip() for tag in v.split(",")] if v else [], default=""
+)
+XRAY_EXCLUDE_INBOUND_TAGS = get_config("XRAY_EXCLUDE_INBOUND_TAGS", default="").split()
+
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table(
+ "core_configs",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("name", sa.String(length=256), nullable=False),
+ sa.Column("config", sa.JSON(), nullable=False),
+ sa.Column("exclude_inbound_tags", sa.String(length=2048), nullable=True),
+ sa.Column("fallbacks_inbound_tags", sa.String(length=2048), nullable=True),
+ sa.PrimaryKeyConstraint("id"),
+ )
+ op.add_column("nodes", sa.Column("core_config_id", sa.Integer(), nullable=True))
+ with op.batch_alter_table("nodes", schema=None) as batch_op:
+ batch_op.create_foreign_key(
+ "nodes_ibfk_1",
+ "core_configs",
+ ["core_config_id"],
+ ["id"],
+ ondelete="SET NULL"
+ )
+ # ### end Alembic commands ###
+ try:
+ with open(XRAY_JSON, "r") as file:
+ config = commentjson.loads(file.read())
+
+ XRayConfig(config, XRAY_EXCLUDE_INBOUND_TAGS, XRAY_FALLBACKS_INBOUND_TAGS)
+ except Exception:
+ config = base_xray
+
+ op.bulk_insert(
+ sa.table(
+ "core_configs",
+ sa.Column("created_at", sa.DateTime),
+ sa.Column("name", sa.String),
+ sa.Column("config", sa.JSON),
+ sa.Column("exclude_inbound_tags", sa.String),
+ sa.Column("fallbacks_inbound_tags", sa.String),
+ ),
+ [
+ {
+ "created_at": dt.now(tz.utc).replace(tzinfo=None),
+ "name": "Default Core Config",
+ "config": config,
+ "exclude_inbound_tags": ",".join(XRAY_EXCLUDE_INBOUND_TAGS),
+ "fallbacks_inbound_tags": ",".join(XRAY_FALLBACKS_INBOUND_TAGS),
+ }
+ ],
+ )
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_constraint("nodes_ibfk_1", 'nodes', type_='foreignkey')
+ op.drop_column('nodes', 'core_config_id')
+ op.drop_table('core_configs')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/4eb0a0eb835f_reformat_hosts.py b/app/db/migrations/versions/4eb0a0eb835f_reformat_hosts.py
new file mode 100644
index 000000000..cb640d128
--- /dev/null
+++ b/app/db/migrations/versions/4eb0a0eb835f_reformat_hosts.py
@@ -0,0 +1,69 @@
+"""reformat hosts
+
+Revision ID: 4eb0a0eb835f
+Revises: be0c5f840473
+Create Date: 2024-12-04 14:32:38.599601
+
+"""
+from alembic import op
+from sqlalchemy.orm import Session
+import commentjson
+import json
+from decouple import config as decouple_config
+
+from app.db.models import ProxyHost
+
+
+# revision identifiers, used by Alembic.
+revision = '4eb0a0eb835f'
+down_revision = 'be0c5f840473'
+branch_labels = None
+depends_on = None
+
+
+base_xray = {
+ "log": {"loglevel": "warning"},
+ "routing": {"rules": [{"ip": ["geoip:private"], "outboundTag": "BLOCK", "type": "field"}]},
+ "inbounds": [
+ {
+ "tag": "Shadowsocks TCP",
+ "listen": "0.0.0.0",
+ "port": 1080,
+ "protocol": "shadowsocks",
+ "settings": {"clients": [], "network": "tcp,udp"},
+ }
+ ],
+ "outbounds": [{"protocol": "freedom", "tag": "DIRECT"}, {"protocol": "blackhole", "tag": "BLOCK"}],
+}
+
+def get_config(key, default=None, cast=None):
+ if cast is not None:
+ return decouple_config(key, default=default, cast=cast)
+ else:
+ return decouple_config(key, default=default)
+
+
+XRAY_JSON = get_config("XRAY_JSON", default="./xray_config.json")
+
+
+def upgrade() -> None:
+ try:
+ with open(XRAY_JSON, 'r') as file:
+ config = commentjson.loads(file.read())
+ except Exception:
+ config = base_xray
+
+ # find current inbound tags
+ inbounds = [inbound['tag'] for inbound in config['inbounds'] if 'tag' in inbound]
+
+ connection = op.get_bind()
+ session = Session(bind=connection)
+ try:
+ # remove hosts with old inbound tag
+ session.query(ProxyHost).filter(ProxyHost.inbound_tag.notin_(inbounds)).delete(synchronize_session=False)
+ session.commit()
+ finally:
+ session.close()
+
+def downgrade() -> None:
+ pass
diff --git a/app/db/migrations/versions/4f045f53bef8_drop_proxy_outbound_and_sockopt_from_.py b/app/db/migrations/versions/4f045f53bef8_drop_proxy_outbound_and_sockopt_from_.py
new file mode 100644
index 000000000..7f9a2e347
--- /dev/null
+++ b/app/db/migrations/versions/4f045f53bef8_drop_proxy_outbound_and_sockopt_from_.py
@@ -0,0 +1,27 @@
+"""drop proxy_outbound and sockopt from hosts
+
+Revision ID: 4f045f53bef8
+Revises: 1ad79b97fdcf
+Create Date: 2024-02-28 20:45:33.206410
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import mysql
+
+# revision identifiers, used by Alembic.
+revision = '4f045f53bef8'
+down_revision = '1ad79b97fdcf'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ with op.batch_alter_table('hosts') as batch_op:
+ batch_op.drop_column('proxy_outbound')
+ batch_op.drop_column('sockopt')
+
+
+def downgrade() -> None:
+ op.add_column('hosts', sa.Column('sockopt', sa.JSON(), nullable=True))
+ op.add_column('hosts', sa.Column('proxy_outbound', sa.JSON(), nullable=True))
diff --git a/app/db/migrations/versions/508061427170_fix_timezones.py b/app/db/migrations/versions/508061427170_fix_timezones.py
new file mode 100644
index 000000000..e123d6864
--- /dev/null
+++ b/app/db/migrations/versions/508061427170_fix_timezones.py
@@ -0,0 +1,178 @@
+"""fix timezones
+
+Revision ID: 508061427170
+Revises: 81554fa3c345
+Create Date: 2025-04-25 11:35:45.402834
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision = '508061427170'
+down_revision = '81554fa3c345'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ if op.get_bind().dialect.name != 'postgresql':
+ return
+
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.alter_column('admin_usage_logs', 'reset_at',
+ existing_type=postgresql.TIMESTAMP(),
+ type_=sa.DateTime(timezone=True),
+ existing_nullable=False)
+ op.alter_column('admins', 'created_at',
+ existing_type=postgresql.TIMESTAMP(),
+ type_=sa.DateTime(timezone=True),
+ existing_nullable=False)
+ op.alter_column('admins', 'password_reset_at',
+ existing_type=postgresql.TIMESTAMP(),
+ type_=sa.DateTime(timezone=True),
+ existing_nullable=True)
+ op.alter_column('node_usages', 'created_at',
+ existing_type=postgresql.TIMESTAMP(),
+ type_=sa.DateTime(timezone=True),
+ existing_nullable=False)
+ op.alter_column('node_user_usages', 'created_at',
+ existing_type=postgresql.TIMESTAMP(),
+ type_=sa.DateTime(timezone=True),
+ existing_nullable=False)
+ op.alter_column('nodes', 'last_status_change',
+ existing_type=postgresql.TIMESTAMP(),
+ type_=sa.DateTime(timezone=True),
+ existing_nullable=True)
+ op.alter_column('nodes', 'created_at',
+ existing_type=postgresql.TIMESTAMP(),
+ type_=sa.DateTime(timezone=True),
+ existing_nullable=False)
+ op.alter_column('notification_reminders', 'expires_at',
+ existing_type=postgresql.TIMESTAMP(),
+ type_=sa.DateTime(timezone=True),
+ existing_nullable=True)
+ op.alter_column('notification_reminders', 'created_at',
+ existing_type=postgresql.TIMESTAMP(),
+ type_=sa.DateTime(timezone=True),
+ existing_nullable=False)
+ op.alter_column('user_usage_logs', 'reset_at',
+ existing_type=postgresql.TIMESTAMP(),
+ type_=sa.DateTime(timezone=True),
+ existing_nullable=False)
+ op.alter_column('users', 'expire',
+ existing_type=postgresql.TIMESTAMP(),
+ type_=sa.DateTime(timezone=True),
+ existing_nullable=True)
+ op.alter_column('users', 'sub_revoked_at',
+ existing_type=postgresql.TIMESTAMP(),
+ type_=sa.DateTime(timezone=True),
+ existing_nullable=True)
+ op.alter_column('users', 'sub_updated_at',
+ existing_type=postgresql.TIMESTAMP(),
+ type_=sa.DateTime(timezone=True),
+ existing_nullable=True)
+ op.alter_column('users', 'created_at',
+ existing_type=postgresql.TIMESTAMP(),
+ type_=sa.DateTime(timezone=True),
+ existing_nullable=False,
+ existing_server_default=sa.text('CURRENT_TIMESTAMP'))
+ op.alter_column('users', 'online_at',
+ existing_type=postgresql.TIMESTAMP(),
+ type_=sa.DateTime(timezone=True),
+ existing_nullable=True)
+ op.alter_column('users', 'on_hold_timeout',
+ existing_type=postgresql.TIMESTAMP(),
+ type_=sa.DateTime(timezone=True),
+ existing_nullable=True)
+ op.alter_column('users', 'edit_at',
+ existing_type=postgresql.TIMESTAMP(),
+ type_=sa.DateTime(timezone=True),
+ existing_nullable=True)
+ op.alter_column('users', 'last_status_change',
+ existing_type=postgresql.TIMESTAMP(),
+ type_=sa.DateTime(timezone=True),
+ existing_nullable=True)
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ if op.get_bind().dialect.name != 'postgresql':
+ return
+
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.alter_column('users', 'last_status_change',
+ existing_type=sa.DateTime(timezone=True),
+ type_=postgresql.TIMESTAMP(),
+ existing_nullable=True)
+ op.alter_column('users', 'edit_at',
+ existing_type=sa.DateTime(timezone=True),
+ type_=postgresql.TIMESTAMP(),
+ existing_nullable=True)
+ op.alter_column('users', 'on_hold_timeout',
+ existing_type=sa.DateTime(timezone=True),
+ type_=postgresql.TIMESTAMP(),
+ existing_nullable=True)
+ op.alter_column('users', 'online_at',
+ existing_type=sa.DateTime(timezone=True),
+ type_=postgresql.TIMESTAMP(),
+ existing_nullable=True)
+ op.alter_column('users', 'created_at',
+ existing_type=sa.DateTime(timezone=True),
+ type_=postgresql.TIMESTAMP(),
+ existing_nullable=False,
+ existing_server_default=sa.text('CURRENT_TIMESTAMP'))
+ op.alter_column('users', 'sub_updated_at',
+ existing_type=sa.DateTime(timezone=True),
+ type_=postgresql.TIMESTAMP(),
+ existing_nullable=True)
+ op.alter_column('users', 'sub_revoked_at',
+ existing_type=sa.DateTime(timezone=True),
+ type_=postgresql.TIMESTAMP(),
+ existing_nullable=True)
+ op.alter_column('users', 'expire',
+ existing_type=sa.DateTime(timezone=True),
+ type_=postgresql.TIMESTAMP(),
+ existing_nullable=True)
+ op.alter_column('user_usage_logs', 'reset_at',
+ existing_type=sa.DateTime(timezone=True),
+ type_=postgresql.TIMESTAMP(),
+ existing_nullable=False)
+ op.alter_column('notification_reminders', 'created_at',
+ existing_type=sa.DateTime(timezone=True),
+ type_=postgresql.TIMESTAMP(),
+ existing_nullable=False)
+ op.alter_column('notification_reminders', 'expires_at',
+ existing_type=sa.DateTime(timezone=True),
+ type_=postgresql.TIMESTAMP(),
+ existing_nullable=True)
+ op.alter_column('nodes', 'created_at',
+ existing_type=sa.DateTime(timezone=True),
+ type_=postgresql.TIMESTAMP(),
+ existing_nullable=False)
+ op.alter_column('nodes', 'last_status_change',
+ existing_type=sa.DateTime(timezone=True),
+ type_=postgresql.TIMESTAMP(),
+ existing_nullable=True)
+ op.alter_column('node_user_usages', 'created_at',
+ existing_type=sa.DateTime(timezone=True),
+ type_=postgresql.TIMESTAMP(),
+ existing_nullable=False)
+ op.alter_column('node_usages', 'created_at',
+ existing_type=sa.DateTime(timezone=True),
+ type_=postgresql.TIMESTAMP(),
+ existing_nullable=False)
+ op.alter_column('admins', 'password_reset_at',
+ existing_type=sa.DateTime(timezone=True),
+ type_=postgresql.TIMESTAMP(),
+ existing_nullable=True)
+ op.alter_column('admins', 'created_at',
+ existing_type=sa.DateTime(timezone=True),
+ type_=postgresql.TIMESTAMP(),
+ existing_nullable=False)
+ op.alter_column('admin_usage_logs', 'reset_at',
+ existing_type=sa.DateTime(timezone=True),
+ type_=postgresql.TIMESTAMP(),
+ existing_nullable=False)
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/51e941ed9018_deactive_user_status.py b/app/db/migrations/versions/51e941ed9018_deactive_user_status.py
new file mode 100644
index 000000000..1a5193d2b
--- /dev/null
+++ b/app/db/migrations/versions/51e941ed9018_deactive_user_status.py
@@ -0,0 +1,119 @@
+"""deactive user status
+
+Revision ID: 51e941ed9018
+Revises: b15eba6e5867
+Create Date: 2023-03-07 19:15:24.287031
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '51e941ed9018'
+down_revision = 'b15eba6e5867'
+branch_labels = None
+depends_on = None
+
+
+# Describing of enum
+enum_name = "status"
+temp_enum_name = f"temp_{enum_name}"
+old_values = ('active', 'limited', 'expired')
+new_values = ("deactive", *old_values)
+downgrade_to = ("deactive", "active") # on downgrade convert [0] to [1]
+old_type = sa.Enum(*old_values, name=enum_name)
+new_type = sa.Enum(*new_values, name=enum_name)
+temp_type = sa.Enum(*new_values, name=temp_enum_name)
+
+
+# Describing of table
+table_name = "users"
+column_name = "status"
+temp_table = sa.sql.table(
+ table_name,
+ sa.Column(
+ column_name,
+ new_type,
+ nullable=False
+ )
+)
+
+
+def upgrade():
+ status_enum = sa.Enum('active', 'limited', 'expired', name='status')
+ status_enum.create(op.get_bind())
+
+ # temp type to use instead of old one
+ temp_type.create(op.get_bind(), checkfirst=False)
+
+ # changing of column type from old enum to new one.
+ # SQLite will create temp table for this
+ with op.batch_alter_table(table_name) as batch_op:
+ batch_op.alter_column(
+ column_name,
+ existing_type=old_type,
+ type_=temp_type,
+ existing_nullable=False,
+ postgresql_using=f"{column_name}::text::{temp_enum_name}"
+ )
+
+ # remove old enum, create new enum
+ old_type.drop(op.get_bind(), checkfirst=False)
+ new_type.create(op.get_bind(), checkfirst=False)
+
+ # changing of column type from temp enum to new one.
+ # SQLite will create temp table for this
+ with op.batch_alter_table(table_name) as batch_op:
+ batch_op.alter_column(
+ column_name,
+ existing_type=temp_type,
+ type_=new_type,
+ existing_nullable=False,
+ postgresql_using=f"{column_name}::text::{enum_name}"
+ )
+
+ # remove temp enum
+ temp_type.drop(op.get_bind(), checkfirst=False)
+
+
+def downgrade():
+ # old enum don't have new value anymore.
+ # before downgrading from new enum to old one,
+ # we should replace new value from new enum with
+ # somewhat of old values from old enum
+ op.execute(
+ temp_table
+ .update()
+ .where(
+ temp_table.c.status == downgrade_to[0]
+ )
+ .values(
+ status=downgrade_to[1]
+ )
+ )
+
+ temp_type.create(op.get_bind(), checkfirst=False)
+
+ with op.batch_alter_table(table_name) as batch_op:
+ batch_op.alter_column(
+ column_name,
+ existing_type=new_type,
+ type_=temp_type,
+ existing_nullable=False,
+ postgresql_using=f"{column_name}::text::{temp_enum_name}"
+ )
+
+ new_type.drop(op.get_bind(), checkfirst=False)
+ old_type.create(op.get_bind(), checkfirst=False)
+
+ with op.batch_alter_table(table_name) as batch_op:
+ batch_op.alter_column(
+ column_name,
+ existing_type=temp_type,
+ type_=old_type,
+ existing_nullable=False,
+ postgresql_using=f"{column_name}::text::{enum_name}"
+ )
+
+ temp_type.drop(op.get_bind(), checkfirst=False)
diff --git a/app/db/migrations/versions/53586547c10e_node_api_key.py b/app/db/migrations/versions/53586547c10e_node_api_key.py
new file mode 100644
index 000000000..f3def67ca
--- /dev/null
+++ b/app/db/migrations/versions/53586547c10e_node_api_key.py
@@ -0,0 +1,27 @@
+"""node api key
+
+Revision ID: 53586547c10e
+Revises: 58a5b64175a8
+Create Date: 2025-04-13 22:03:43.772863
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = '53586547c10e'
+down_revision = '58a5b64175a8'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('nodes', sa.Column('api_key', sa.String(length=36), nullable=True))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('nodes', 'api_key')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/54c4b8c525fc_last_status_change_for_expired_users.py b/app/db/migrations/versions/54c4b8c525fc_last_status_change_for_expired_users.py
new file mode 100644
index 000000000..09e2d7d90
--- /dev/null
+++ b/app/db/migrations/versions/54c4b8c525fc_last_status_change_for_expired_users.py
@@ -0,0 +1,75 @@
+"""last_status_change_for_expired_users
+
+Revision ID: 54c4b8c525fc
+Revises: 2313cdc30da3
+Create Date: 2024-07-25 11:15:51.776880
+
+"""
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.sql import func, case, text
+from sqlalchemy import text
+
+# revision identifiers, used by Alembic.
+revision = '54c4b8c525fc'
+down_revision = '2313cdc30da3'
+branch_labels = None
+depends_on = None
+
+# Define the 'users' table
+users_table = sa.Table(
+ 'users',
+ sa.MetaData(),
+ sa.Column('id', sa.Integer, primary_key=True),
+ sa.Column('status', sa.String),
+ sa.Column('expire', sa.Integer), # Assuming 'expire' is a UNIX timestamp
+ sa.Column('last_status_change', sa.DateTime)
+)
+
+
+def upgrade() -> None:
+ # Get database connection and dialect
+ connection = op.get_bind()
+ dialect = connection.dialect.name
+
+ # Create the update statement based on dialect
+ if dialect == 'postgresql':
+ update_stmt = text("""
+ UPDATE users
+ SET last_status_change = to_timestamp(expire)
+ WHERE status = 'expired' AND expire IS NOT NULL
+ """)
+ elif dialect == 'mysql':
+ update_stmt = text("""
+ UPDATE users
+ SET last_status_change = FROM_UNIXTIME(expire)
+ WHERE status = 'expired' AND expire IS NOT NULL
+ """)
+ else: # sqlite
+ update_stmt = text("""
+ UPDATE users
+ SET last_status_change = datetime(expire, 'unixepoch')
+ WHERE status = 'expired' AND expire IS NOT NULL
+ """)
+
+ connection.execute(update_stmt)
+
+
+def downgrade() -> None:
+ connection = op.get_bind()
+
+ # Set last_status_change to the current timestamp for 'expired' users
+ update_stmt = (
+ sa.update(users_table)
+ .where(
+ sa.and_(
+ users_table.c.status == 'expired',
+ users_table.c.expire.isnot(None)
+ )
+ )
+ .values(last_status_change=func.now()) # CURRENT_TIMESTAMP equivalent
+ )
+
+ # Execute the update statement
+ connection.execute(update_stmt)
diff --git a/app/db/migrations/versions/5575fe410515_add_telegram_id_to_admin.py b/app/db/migrations/versions/5575fe410515_add_telegram_id_to_admin.py
new file mode 100644
index 000000000..a66a38c48
--- /dev/null
+++ b/app/db/migrations/versions/5575fe410515_add_telegram_id_to_admin.py
@@ -0,0 +1,30 @@
+"""add_telegram_id_to_admin
+
+Revision ID: 5575fe410515
+Revises: 470465472326
+Create Date: 2024-02-01 22:30:47.515512
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '5575fe410515'
+down_revision = '470465472326'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('admins', sa.Column('telegram_id', sa.BigInteger(), nullable=True))
+ op.add_column('admins', sa.Column('discord_webhook', sa.String(length=1024), nullable=True))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('admins', 'discord_webhook')
+ op.drop_column('admins', 'telegram_id')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/57fda18cd9e6_add_xray_version.py b/app/db/migrations/versions/57fda18cd9e6_add_xray_version.py
new file mode 100644
index 000000000..bda26f9d0
--- /dev/null
+++ b/app/db/migrations/versions/57fda18cd9e6_add_xray_version.py
@@ -0,0 +1,28 @@
+"""add xray version
+
+Revision ID: 57fda18cd9e6
+Revises: e4a86bc8ec7b
+Create Date: 2023-05-03 17:14:45.696488
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '57fda18cd9e6'
+down_revision = 'e4a86bc8ec7b'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('nodes', sa.Column('xray_version', sa.String(length=32), nullable=True))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('nodes', 'xray_version')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/58a5b64175a8_node_stats_table.py b/app/db/migrations/versions/58a5b64175a8_node_stats_table.py
new file mode 100644
index 000000000..d816dc028
--- /dev/null
+++ b/app/db/migrations/versions/58a5b64175a8_node_stats_table.py
@@ -0,0 +1,40 @@
+"""node stats table
+
+Revision ID: 58a5b64175a8
+Revises: 4e66033a2b9b
+Create Date: 2025-04-10 13:36:20.215092
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '58a5b64175a8'
+down_revision = '4e66033a2b9b'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('node_stats',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
+ sa.Column('node_id', sa.Integer(), nullable=False),
+ sa.Column('mem_total', sa.BigInteger(), nullable=False),
+ sa.Column('mem_used', sa.BigInteger(), nullable=False),
+ sa.Column('cpu_cores', sa.Integer(), nullable=False),
+ sa.Column('cpu_usage', sa.Float(), nullable=False),
+ sa.Column('incoming_bandwidth_speed', sa.BigInteger(), nullable=False),
+ sa.Column('outgoing_bandwidth_speed', sa.BigInteger(), nullable=False),
+ sa.ForeignKeyConstraint(['node_id'], ['nodes.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table('node_stats')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/5a4446e7b165_add_password_reset_at_to_admins.py b/app/db/migrations/versions/5a4446e7b165_add_password_reset_at_to_admins.py
new file mode 100644
index 000000000..69ffacbb1
--- /dev/null
+++ b/app/db/migrations/versions/5a4446e7b165_add_password_reset_at_to_admins.py
@@ -0,0 +1,28 @@
+"""add password_reset_at to admins
+
+Revision ID: 5a4446e7b165
+Revises: 77c86a261126
+Create Date: 2023-10-27 15:54:42.099101
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '5a4446e7b165'
+down_revision = '77c86a261126'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('admins', sa.Column('password_reset_at', sa.DateTime(), nullable=True))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('admins', 'password_reset_at')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/5b84d88804a1_fix.py b/app/db/migrations/versions/5b84d88804a1_fix.py
new file mode 100644
index 000000000..8404ed25d
--- /dev/null
+++ b/app/db/migrations/versions/5b84d88804a1_fix.py
@@ -0,0 +1,48 @@
+"""fix
+
+Revision ID: 5b84d88804a1
+Revises: 7cbe9d91ac11
+Create Date: 2023-03-08 15:41:25.827671
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '5b84d88804a1'
+down_revision = '7cbe9d91ac11'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table('hosts') as batch_op:
+ batch_op.alter_column('sni',
+ existing_type=sa.VARCHAR(length=256),
+ nullable=True)
+ batch_op.alter_column('host',
+ existing_type=sa.VARCHAR(length=256),
+ nullable=True)
+ with op.batch_alter_table('users') as batch_op:
+ batch_op.alter_column('status',
+ existing_type=sa.VARCHAR(length=8),
+ nullable=False)
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table('users') as batch_op:
+ batch_op.alter_column('status',
+ existing_type=sa.VARCHAR(length=8),
+ nullable=True)
+ with op.batch_alter_table('hosts') as batch_op:
+ batch_op.alter_column('host',
+ existing_type=sa.VARCHAR(length=256),
+ nullable=False)
+ batch_op.alter_column('sni',
+ existing_type=sa.VARCHAR(length=256),
+ nullable=False)
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/671621870b02_init_admin.py b/app/db/migrations/versions/671621870b02_init_admin.py
new file mode 100644
index 000000000..9d5a16783
--- /dev/null
+++ b/app/db/migrations/versions/671621870b02_init_admin.py
@@ -0,0 +1,44 @@
+"""init admin
+
+Revision ID: 671621870b02
+Revises: 8e849e06f131
+Create Date: 2023-01-13 18:57:40.199730
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '671621870b02'
+down_revision = '8e849e06f131'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('admins',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('username', sa.String(34), nullable=True),
+ sa.Column('hashed_password', sa.String(128), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=True),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_admins_id'), 'admins', ['id'], unique=False)
+ op.create_index(op.f('ix_admins_username'), 'admins', ['username'], unique=True)
+ with op.batch_alter_table('users') as batch_op:
+ batch_op.add_column(sa.Column('admin_id', sa.Integer(), nullable=True))
+ batch_op.create_foreign_key('fk_users_admin_id_admins', 'admins', ['admin_id'], ['id'])
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table('users') as batch_op:
+ batch_op.drop_column('admin_id')
+ batch_op.drop_constraint('fk_users_admin_id_admins', type_='foreignkey')
+ op.drop_index(op.f('ix_admins_username'), table_name='admins')
+ op.drop_index(op.f('ix_admins_id'), table_name='admins')
+ op.drop_table('admins')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/68edca039166_next_plan_to_template_relation.py b/app/db/migrations/versions/68edca039166_next_plan_to_template_relation.py
new file mode 100644
index 000000000..5718c8c65
--- /dev/null
+++ b/app/db/migrations/versions/68edca039166_next_plan_to_template_relation.py
@@ -0,0 +1,32 @@
+"""next plan to template relation
+
+Revision ID: 68edca039166
+Revises: 931ed40d6eec
+Create Date: 2025-01-12 23:58:41.810295
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '68edca039166'
+down_revision = '931ed40d6eec'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table('next_plans') as batch_op:
+ batch_op.add_column(sa.Column('user_template_id', sa.Integer(), nullable=True))
+ batch_op.create_foreign_key('fk_next_plans_user_template_id_user_templates', 'user_templates', ['user_template_id'], ['id'])
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table('next_plans') as batch_op:
+ batch_op.drop_constraint('fk_next_plans_user_template_id_user_templates', type_='foreignkey')
+ batch_op.drop_column('user_template_id')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/6980e98bba01_add_data_limit_reset_strategy_and_on_.py b/app/db/migrations/versions/6980e98bba01_add_data_limit_reset_strategy_and_on_.py
new file mode 100644
index 000000000..58fd35154
--- /dev/null
+++ b/app/db/migrations/versions/6980e98bba01_add_data_limit_reset_strategy_and_on_.py
@@ -0,0 +1,31 @@
+"""add data_limit_reset_strategy and on_hold_timeout to usertemplate table
+
+Revision ID: 6980e98bba01
+Revises: 9cfffef342cd
+Create Date: 2025-04-14 21:38:13.835658
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '6980e98bba01'
+down_revision = '9cfffef342cd'
+branch_labels = None
+depends_on = None
+
+new_enum = sa.Enum('no_reset', 'day', 'week', 'month', 'year', name='userdatalimitresetstrategy')
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('user_templates', sa.Column('on_hold_timeout', sa.Integer(), nullable=True))
+ op.add_column('user_templates', sa.Column('data_limit_reset_strategy', new_enum, server_default='no_reset', nullable=False))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('user_templates', 'data_limit_reset_strategy')
+ op.drop_column('user_templates', 'on_hold_timeout')
+ # ### end Alembic commands ###
\ No newline at end of file
diff --git a/app/db/migrations/versions/714f227201a7_fix_user_template.py b/app/db/migrations/versions/714f227201a7_fix_user_template.py
new file mode 100644
index 000000000..fd6b18a81
--- /dev/null
+++ b/app/db/migrations/versions/714f227201a7_fix_user_template.py
@@ -0,0 +1,35 @@
+"""Fix User Template
+
+Revision ID: 714f227201a7
+Revises: 947ebbd8debe
+Create Date: 2023-10-29 13:45:03.223039
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '714f227201a7'
+down_revision = '947ebbd8debe'
+branch_labels = None
+depends_on = None
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table('user_templates') as batch_op:
+ batch_op.alter_column('data_limit',existing_type=sa.BigInteger,
+ nullable=True)
+ batch_op.alter_column('expire_duration',existing_type=sa.BigInteger,
+ nullable=True)
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table('user_templates') as batch_op:
+ batch_op.alter_column('data_limit',existing_type=sa.Integer,
+ nullable=False)
+ batch_op.alter_column('expire_duration',existing_type=sa.Integer,
+ nullable=False)
+ # ### end Alembic commands ###
\ No newline at end of file
diff --git a/app/db/migrations/versions/77c86a261126_nodes_coefficient.py b/app/db/migrations/versions/77c86a261126_nodes_coefficient.py
new file mode 100644
index 000000000..f507965c5
--- /dev/null
+++ b/app/db/migrations/versions/77c86a261126_nodes_coefficient.py
@@ -0,0 +1,28 @@
+"""Nodes coefficient
+
+Revision ID: 77c86a261126
+Revises: a0715c2615f0
+Create Date: 2023-09-16 17:31:42.925189
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '77c86a261126'
+down_revision = 'a0715c2615f0'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('nodes', sa.Column('usage_coefficient', sa.Float(), server_default=sa.text('1.0'), nullable=False))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('nodes', 'usage_coefficient')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/7a0dbb8a2f65_init_tls_table.py b/app/db/migrations/versions/7a0dbb8a2f65_init_tls_table.py
new file mode 100644
index 000000000..45fd377bb
--- /dev/null
+++ b/app/db/migrations/versions/7a0dbb8a2f65_init_tls_table.py
@@ -0,0 +1,39 @@
+"""init tls table
+
+Revision ID: 7a0dbb8a2f65
+Revises: 77c86a261126
+Create Date: 2023-10-22 13:58:12.431246
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import mysql
+from app.utils.crypto import generate_certificate
+
+# revision identifiers, used by Alembic.
+revision = '7a0dbb8a2f65'
+down_revision = '77c86a261126'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ table = op.create_table('tls',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('key', sa.String(length=4096), nullable=False),
+ sa.Column('certificate', sa.String(length=2048), nullable=False),
+ sa.PrimaryKeyConstraint('id')
+ )
+
+ # INSERT DEFAULT ROW
+ tls = generate_certificate()
+ op.bulk_insert(table, [{"id": 1, "key": tls['key'], "certificate": tls['cert']}])
+
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table('tls')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/7a93bcd44713_.py b/app/db/migrations/versions/7a93bcd44713_.py
new file mode 100644
index 000000000..3b78b7f08
--- /dev/null
+++ b/app/db/migrations/versions/7a93bcd44713_.py
@@ -0,0 +1,24 @@
+"""empty message
+
+Revision ID: 7a93bcd44713
+Revises: 2b231de97dc3, f2b9caa23e16
+Create Date: 2025-01-11 12:30:03.803844
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '7a93bcd44713'
+down_revision = ('2b231de97dc3', 'f2b9caa23e16')
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ pass
+
+
+def downgrade() -> None:
+ pass
diff --git a/app/db/migrations/versions/7cbe9d91ac11_proxyhost_security_added.py b/app/db/migrations/versions/7cbe9d91ac11_proxyhost_security_added.py
new file mode 100644
index 000000000..02b6e5000
--- /dev/null
+++ b/app/db/migrations/versions/7cbe9d91ac11_proxyhost_security_added.py
@@ -0,0 +1,40 @@
+"""ProxyHost: security added
+
+Revision ID: 7cbe9d91ac11
+Revises: b15eba6e5867
+Create Date: 2023-03-07 23:30:49.678157
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = "7cbe9d91ac11"
+down_revision = "e3f0e888a563"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ proxyhostsecurity_enum = sa.Enum('inbound_default', 'none', 'tls', name='proxyhostsecurity')
+ proxyhostsecurity_enum.create(op.get_bind())
+
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column(
+ "hosts",
+ sa.Column(
+ "security",
+ sa.Enum("inbound_default", "none", "tls", name="proxyhostsecurity"),
+ nullable=False,
+ server_default="inbound_default"
+ ),
+ )
+
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column("hosts", "security")
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/81554fa3c345_admin_discord_id.py b/app/db/migrations/versions/81554fa3c345_admin_discord_id.py
new file mode 100644
index 000000000..b0d474e95
--- /dev/null
+++ b/app/db/migrations/versions/81554fa3c345_admin_discord_id.py
@@ -0,0 +1,28 @@
+"""admin discord id
+
+Revision ID: 81554fa3c345
+Revises: ac26f36783a2
+Create Date: 2025-04-18 17:42:53.157022
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '81554fa3c345'
+down_revision = 'ac26f36783a2'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('admins', sa.Column('discord_id', sa.BigInteger(), nullable=True))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('admins', 'discord_id')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/852d951c9c08_drop_extra_index.py b/app/db/migrations/versions/852d951c9c08_drop_extra_index.py
new file mode 100644
index 000000000..00d7df928
--- /dev/null
+++ b/app/db/migrations/versions/852d951c9c08_drop_extra_index.py
@@ -0,0 +1,46 @@
+"""drop_extra_index
+
+Revision ID: 852d951c9c08
+Revises: dd725e4d3628
+Create Date: 2024-02-15 23:51:53.510090
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '852d951c9c08'
+down_revision = 'dd725e4d3628'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_index('ix_admins_id', table_name='admins')
+ op.drop_index('ix_node_usages_id', table_name='node_usages')
+ op.drop_index('ix_node_user_usages_id', table_name='node_user_usages')
+ op.drop_index('ix_nodes_id', table_name='nodes')
+ op.drop_index('ix_notification_reminders_id', table_name='notification_reminders')
+ op.drop_index('ix_proxies_id', table_name='proxies')
+ op.drop_index('ix_system_id', table_name='system')
+ op.drop_index('ix_user_templates_id', table_name='user_templates')
+ op.drop_index('ix_user_usage_logs_id', table_name='user_usage_logs')
+ op.drop_index('ix_users_id', table_name='users')
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_index('ix_users_id', 'users', ['id'], unique=False)
+ op.create_index('ix_user_usage_logs_id', 'user_usage_logs', ['id'], unique=False)
+ op.create_index('ix_user_templates_id', 'user_templates', ['id'], unique=False)
+ op.create_index('ix_system_id', 'system', ['id'], unique=False)
+ op.create_index('ix_proxies_id', 'proxies', ['id'], unique=False)
+ op.create_index('ix_notification_reminders_id', 'notification_reminders', ['id'], unique=False)
+ op.create_index('ix_nodes_id', 'nodes', ['id'], unique=False)
+ op.create_index('ix_node_user_usages_id', 'node_user_usages', ['id'], unique=False)
+ op.create_index('ix_node_usages_id', 'node_usages', ['id'], unique=False)
+ op.create_index('ix_admins_id', 'admins', ['id'], unique=False)
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/89bcb1419c66_fix_user_status_enum.py b/app/db/migrations/versions/89bcb1419c66_fix_user_status_enum.py
new file mode 100644
index 000000000..5e63c8519
--- /dev/null
+++ b/app/db/migrations/versions/89bcb1419c66_fix_user_status_enum.py
@@ -0,0 +1,104 @@
+"""fix user status enum
+
+Revision ID: 89bcb1419c66
+Revises: 1f6e978be88e
+Create Date: 2025-03-27 12:03:01.459307
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = '89bcb1419c66'
+down_revision = '1f6e978be88e'
+branch_labels = None
+depends_on = None
+
+
+old_enum_name = "status"
+new_enum_name = "userstatus"
+temp_enum_name = f"temp_{old_enum_name}"
+values = ("active", "disabled", "limited", "expired", "on_hold")
+
+old_type = sa.Enum(*values, name=old_enum_name)
+new_type = sa.Enum(*values, name=new_enum_name)
+temp_type = sa.Enum(*values, name=temp_enum_name)
+
+
+# Describing of table
+table_name = "users"
+column_name = "status"
+temp_table = sa.sql.table(
+ table_name,
+ sa.Column(
+ column_name,
+ new_type,
+ nullable=False
+ )
+)
+
+
+def upgrade():
+ # temp type to use instead of old one
+ try:
+ new_type.drop(op.get_bind(), checkfirst=False)
+ except Exception:
+ pass
+
+ temp_type.create(op.get_bind(), checkfirst=False)
+
+ # changing of column type from old enum to new one.
+ # SQLite will create temp table for this
+ with op.batch_alter_table(table_name) as batch_op:
+ batch_op.alter_column(
+ column_name,
+ existing_type=old_type,
+ type_=temp_type,
+ existing_nullable=False,
+ postgresql_using=f"{column_name}::text::{temp_enum_name}"
+ )
+
+ # remove old enum, create new enum
+ old_type.drop(op.get_bind(), checkfirst=False)
+ new_type.create(op.get_bind(), checkfirst=False)
+
+ # changing of column type from temp enum to new one.
+ # SQLite will create temp table for this
+ with op.batch_alter_table(table_name) as batch_op:
+ batch_op.alter_column(
+ column_name,
+ existing_type=temp_type,
+ type_=new_type,
+ existing_nullable=False,
+ postgresql_using=f"{column_name}::text::{new_enum_name}"
+ )
+
+ # remove temp enum
+ temp_type.drop(op.get_bind(), checkfirst=False)
+
+
+def downgrade():
+ temp_type.create(op.get_bind(), checkfirst=False)
+
+ with op.batch_alter_table(table_name) as batch_op:
+ batch_op.alter_column(
+ column_name,
+ existing_type=new_type,
+ type_=temp_type,
+ existing_nullable=False,
+ postgresql_using=f"{column_name}::text::{temp_enum_name}"
+ )
+
+ new_type.drop(op.get_bind(), checkfirst=False)
+ old_type.create(op.get_bind(), checkfirst=False)
+
+ with op.batch_alter_table(table_name) as batch_op:
+ batch_op.alter_column(
+ column_name,
+ existing_type=temp_type,
+ type_=old_type,
+ existing_nullable=False,
+ postgresql_using=f"{column_name}::text::{old_enum_name}"
+ )
+
+ temp_type.drop(op.get_bind(), checkfirst=False)
diff --git a/app/db/migrations/versions/8e849e06f131_proxy_table.py b/app/db/migrations/versions/8e849e06f131_proxy_table.py
new file mode 100644
index 000000000..3f1cf405c
--- /dev/null
+++ b/app/db/migrations/versions/8e849e06f131_proxy_table.py
@@ -0,0 +1,81 @@
+"""proxy table
+
+Revision ID: 8e849e06f131
+Revises: 9b60be6cd0a2
+Create Date: 2022-12-26 05:47:14.745622
+
+"""
+
+import json
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.sql import select
+from sqlalchemy.dialects import sqlite
+
+# revision identifiers, used by Alembic.
+revision = '8e849e06f131'
+down_revision = '9b60be6cd0a2'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # Define the 'proxies' table
+ proxies_table = op.create_table(
+ 'proxies',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('user_id', sa.Integer(), nullable=True),
+ sa.Column('type', sa.Enum('VMess', 'VLESS', 'Trojan', 'Shadowsocks', name='proxytypes'), nullable=False),
+ sa.Column('settings', sa.JSON(), nullable=False),
+ sa.ForeignKeyConstraint(['user_id'], ['users.id']),
+ sa.PrimaryKeyConstraint('id')
+ )
+
+ # Reflect the 'users' table for data migration
+ metadata = sa.MetaData()
+ users_table = sa.Table(
+ 'users',
+ metadata,
+ sa.Column('id', sa.Integer(), primary_key=True),
+ sa.Column('proxy_type', sa.String(11)),
+ sa.Column('settings', sa.JSON())
+ )
+
+ # Migrate data: Copy `proxy_type` and `settings` from 'users' to 'proxies'
+ connection = op.get_bind()
+ results = connection.execute(select(users_table.c.id, users_table.c.proxy_type, users_table.c.settings)).fetchall()
+ op.bulk_insert(
+ proxies_table,
+ [{"user_id": row.id, "type": row.proxy_type, "settings": json.loads(row.settings)} for row in results]
+ )
+
+ # Alter 'users' table: Drop 'proxy_type' and 'settings'
+ with op.batch_alter_table('users') as batch_op:
+ batch_op.alter_column(
+ 'created_at',
+ existing_type=sa.DATETIME(),
+ nullable=True,
+ existing_server_default=sa.text('(CURRENT_TIMESTAMP)')
+ )
+ batch_op.drop_column('proxy_type')
+ batch_op.drop_column('settings')
+
+ # Create an index on the 'proxies' table
+ op.create_index(op.f('ix_proxies_id'), 'proxies', ['id'], unique=False)
+
+
+def downgrade() -> None:
+ # Recreate 'proxy_type' and 'settings' columns in the 'users' table
+ with op.batch_alter_table('users') as batch_op:
+ batch_op.add_column(sa.Column('settings', sa.JSON(), nullable=False))
+ batch_op.add_column(sa.Column('proxy_type', sa.String(length=11), nullable=False))
+ batch_op.alter_column(
+ 'created_at',
+ existing_type=sa.DATETIME(),
+ nullable=False,
+ existing_server_default=sa.text('(CURRENT_TIMESTAMP)')
+ )
+
+ # Drop the index and table 'proxies'
+ op.drop_index(op.f('ix_proxies_id'), table_name='proxies')
+ op.drop_table('proxies')
diff --git a/app/db/migrations/versions/8fe407cf56c9_add_general_settings_to_app.py b/app/db/migrations/versions/8fe407cf56c9_add_general_settings_to_app.py
new file mode 100644
index 000000000..568e3bab4
--- /dev/null
+++ b/app/db/migrations/versions/8fe407cf56c9_add_general_settings_to_app.py
@@ -0,0 +1,45 @@
+"""add general settings to app
+
+Revision ID: 8fe407cf56c9
+Revises: 3c466ce2ab63
+Create Date: 2025-07-08 15:48:02.163498
+
+"""
+import json
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '8fe407cf56c9'
+down_revision = '3c466ce2ab63'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ bind = op.get_bind()
+ dialect = bind.dialect.name
+
+ # Step 1: Add nullable column using batch_op
+ with op.batch_alter_table('settings') as batch_op:
+ batch_op.add_column(
+ sa.Column('general', sa.JSON(), nullable=True)
+ )
+
+ # Step 2: Backfill with default JSON value
+ default_value = {
+ "default_flow": "",
+ "default_method": "chacha20-ietf-poly1305"
+ }
+ default_str = json.dumps(default_value)
+ op.execute(f"UPDATE settings SET general = '{default_str}'")
+
+ # Step 3: Make column non-nullable (only if not SQLite)
+ with op.batch_alter_table('settings') as batch_op:
+ batch_op.alter_column('general',existing_type=sa.JSON(), type_=sa.JSON(), nullable=False)
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('settings', 'general')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/931ed40d6eec_add_is_disabled_to_admins_table.py b/app/db/migrations/versions/931ed40d6eec_add_is_disabled_to_admins_table.py
new file mode 100644
index 000000000..bdb5517cf
--- /dev/null
+++ b/app/db/migrations/versions/931ed40d6eec_add_is_disabled_to_admins_table.py
@@ -0,0 +1,28 @@
+"""add is_disabled to admins table
+
+Revision ID: 931ed40d6eec
+Revises: 7a93bcd44713
+Create Date: 2025-01-12 08:00:38.731094
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import mysql
+
+# revision identifiers, used by Alembic.
+revision = '931ed40d6eec'
+down_revision = '7a93bcd44713'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('admins', sa.Column('is_disabled', sa.Boolean(), server_default='0', nullable=False))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('admins', 'is_disabled')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/947ebbd8debe_add_on_hold.py b/app/db/migrations/versions/947ebbd8debe_add_on_hold.py
new file mode 100644
index 000000000..0fc23e5c1
--- /dev/null
+++ b/app/db/migrations/versions/947ebbd8debe_add_on_hold.py
@@ -0,0 +1,122 @@
+"""Add on hold
+
+Revision ID: 947ebbd8debe
+Revises: 08b381fc1bc7
+Create Date: 2023-10-08 06:37:10.027798
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '947ebbd8debe'
+down_revision = '08b381fc1bc7'
+branch_labels = None
+depends_on = None
+
+# Describing of enum
+enum_name = "status"
+temp_enum_name = f"temp_{enum_name}"
+old_values = ('active', 'limited', 'expired', 'disabled')
+new_values = ("on_hold", *old_values)
+# on downgrade convert [0] to [1]
+downgrade_to = ("on_hold", "active")
+old_type = sa.Enum(*old_values, name=enum_name)
+new_type = sa.Enum(*new_values, name=enum_name)
+temp_type = sa.Enum(*new_values, name=temp_enum_name)
+
+
+# Describing of table
+table_name = "users"
+column_name = "status"
+temp_table = sa.sql.table(
+ table_name,
+ sa.Column(
+ column_name,
+ new_type,
+ nullable=False
+ )
+)
+
+
+def upgrade():
+ # temp type to use instead of old one
+ temp_type.create(op.get_bind(), checkfirst=False)
+
+ # changing of column type from old enum to new one.
+ # SQLite will create temp table for this
+ with op.batch_alter_table(table_name) as batch_op:
+ batch_op.alter_column(
+ column_name,
+ existing_type=old_type,
+ type_=temp_type,
+ existing_nullable=False,
+ postgresql_using=f"{column_name}::text::{temp_enum_name}"
+ )
+
+ # remove old enum, create new enum
+ old_type.drop(op.get_bind(), checkfirst=False)
+ new_type.create(op.get_bind(), checkfirst=False)
+
+ # changing of column type from temp enum to new one.
+ # SQLite will create temp table for this
+ with op.batch_alter_table(table_name) as batch_op:
+ batch_op.alter_column(
+ column_name,
+ existing_type=temp_type,
+ type_=new_type,
+ existing_nullable=False,
+ postgresql_using=f"{column_name}::text::{enum_name}"
+ )
+
+ # remove temp enum
+ temp_type.drop(op.get_bind(), checkfirst=False)
+
+ op.add_column('users', sa.Column('timeout', sa.Integer(), nullable=True))
+ op.add_column('users', sa.Column('edit_at', sa.DateTime(), nullable=True))
+
+
+def downgrade():
+ # old enum don't have new value anymore.
+ # before downgrading from new enum to old one,
+ # we should replace new value from new enum with
+ # somewhat of old values from old enum
+ op.execute(
+ temp_table
+ .update()
+ .where(
+ temp_table.c.status == downgrade_to[0]
+ )
+ .values(
+ status=downgrade_to[1]
+ )
+ )
+
+ temp_type.create(op.get_bind(), checkfirst=False)
+
+ with op.batch_alter_table(table_name) as batch_op:
+ batch_op.alter_column(
+ column_name,
+ existing_type=new_type,
+ type_=temp_type,
+ existing_nullable=False,
+ postgresql_using=f"{column_name}::text::{temp_enum_name}"
+ )
+
+ new_type.drop(op.get_bind(), checkfirst=False)
+ old_type.create(op.get_bind(), checkfirst=False)
+
+ with op.batch_alter_table(table_name) as batch_op:
+ batch_op.alter_column(
+ column_name,
+ existing_type=temp_type,
+ type_=old_type,
+ existing_nullable=False,
+ postgresql_using=f"{column_name}::text::{enum_name}"
+ )
+
+ temp_type.drop(op.get_bind(), checkfirst=False)
+
+ op.drop_column('users', 'edit_at')
+ op.drop_column('users', 'timeout')
diff --git a/app/db/migrations/versions/94a5cc12c0d6_init_user_table.py b/app/db/migrations/versions/94a5cc12c0d6_init_user_table.py
new file mode 100644
index 000000000..4d87386d5
--- /dev/null
+++ b/app/db/migrations/versions/94a5cc12c0d6_init_user_table.py
@@ -0,0 +1,42 @@
+"""init user table
+
+Revision ID: 94a5cc12c0d6
+Revises:
+Create Date: 2022-11-18 20:54:05.616546
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '94a5cc12c0d6'
+down_revision = None
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('users',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('username', sa.String(34), nullable=True),
+ sa.Column('proxy_type', sa.Enum('VMess', 'VLESS', 'Trojan', 'Shadowsocks', name='proxytypes'), nullable=False),
+ sa.Column('settings', sa.JSON(), nullable=False),
+ sa.Column('status', sa.Enum('active', 'limited', 'expired', name='userstatus'), nullable=True),
+ sa.Column('used_traffic', sa.BigInteger(), nullable=True),
+ sa.Column('data_limit', sa.BigInteger(), nullable=True),
+ sa.Column('expire', sa.Integer(), nullable=True),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
+ op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_index(op.f('ix_users_username'), table_name='users')
+ op.drop_index(op.f('ix_users_id'), table_name='users')
+ op.drop_table('users')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/95da30deba8d_change_fragments_and_noise_from_regex_.py b/app/db/migrations/versions/95da30deba8d_change_fragments_and_noise_from_regex_.py
new file mode 100644
index 000000000..a6647638f
--- /dev/null
+++ b/app/db/migrations/versions/95da30deba8d_change_fragments_and_noise_from_regex_.py
@@ -0,0 +1,178 @@
+"""change fragments and noise from regex to json
+
+Revision ID: 95da30deba8d
+Revises: eaa9f30f983e
+Create Date: 2025-02-26 21:54:13.977279
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.orm import Session
+from sqlalchemy.dialects import mysql
+
+
+# revision identifiers, used by Alembic.
+revision = '95da30deba8d'
+down_revision = 'eaa9f30f983e'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+
+ inspector = sa.inspect(bind)
+ existing_columns = [col['name'] for col in inspector.get_columns('hosts')]
+
+ # If both target columns already exist, skip the migration entirely
+ if 'noise_settings' in existing_columns and 'fragment_settings' in existing_columns:
+ return
+
+ session = Session(bind=bind)
+
+ def regex_to_json(table: sa.Table):
+ # Only query columns that exist
+ columns_to_query = [table.c.id]
+ if 'fragment_setting' in existing_columns:
+ columns_to_query.append(table.c.fragment_setting)
+ if 'noise_setting' in existing_columns:
+ columns_to_query.append(table.c.noise_setting)
+
+ for row in session.query(*columns_to_query).all():
+ updates = {}
+
+ if 'fragment_setting' in existing_columns and hasattr(row, 'fragment_setting') and row.fragment_setting:
+ fragment = row.fragment_setting
+ fragment_json = sa.null()
+ if fragment:
+ try:
+ length, interval, packets = fragment.split(",")
+ fragment_json = {"xray": {"packets": packets, "length": length, "interval": interval}}
+ updates['fragment_settings'] = fragment_json
+ except ValueError:
+ pass
+
+ if 'noise_setting' in existing_columns and hasattr(row, 'noise_setting') and row.noise_setting:
+ noises = row.noise_setting
+ noises_settings_json = sa.null()
+ if noises:
+ try:
+ sn = noises.split("&")
+ noises_settings_json = {"xray": []}
+ for n in sn:
+ try:
+ tp, delay = n.split(",")
+ _type, packet = tp.split(":")
+ noises_settings_json["xray"].append(
+ {"type": _type, "packet": packet, "delay": delay})
+ except ValueError:
+ continue
+ updates['noise_settings'] = noises_settings_json
+ except ValueError:
+ pass
+
+ if updates:
+ session.execute(
+ table.update().where(table.c.id == row.id).values(**updates)
+ )
+
+ # Add new JSON columns
+ if 'noise_settings' not in existing_columns:
+ op.add_column('hosts', sa.Column('noise_settings', sa.JSON(none_as_null=True), nullable=True))
+
+ if 'fragment_settings' not in existing_columns:
+ op.add_column('hosts', sa.Column('fragment_settings', sa.JSON(none_as_null=True), nullable=True))
+
+ try:
+ with op.batch_alter_table('hosts') as batch_op:
+ hosts_table = sa.Table('hosts', sa.MetaData(), autoload_with=bind)
+ regex_to_json(hosts_table)
+
+ # Drop old columns if they exist
+ if 'noise_setting' in existing_columns:
+ batch_op.drop_column('noise_setting')
+
+ if 'fragment_setting' in existing_columns:
+ batch_op.drop_column('fragment_setting')
+
+ session.commit()
+ finally:
+ session.close()
+
+
+def downgrade() -> None:
+ bind = op.get_bind()
+
+ inspector = sa.inspect(bind)
+ existing_columns = [col['name'] for col in inspector.get_columns('hosts')]
+
+ # If both target columns already exist, skip the migration entirely
+ if 'noise_setting' in existing_columns and 'fragment_setting' in existing_columns:
+ return
+
+ session = Session(bind=bind)
+
+ def json_to_regex(table: sa.Table):
+ # Only query columns that exist
+ columns_to_query = [table.c.id]
+ if 'fragment_settings' in existing_columns:
+ columns_to_query.append(table.c.fragment_settings)
+ if 'noise_settings' in existing_columns:
+ columns_to_query.append(table.c.noise_settings)
+
+ for row in session.query(*columns_to_query).all():
+ updates = {}
+
+ if hasattr(row, 'fragment_settings') and row.fragment_settings:
+ fragment_json = row.fragment_settings
+ fragment_regex = sa.null()
+
+ # Convert fragment JSON back to regex
+ if fragment_json and 'xray' in fragment_json:
+ xray = fragment_json['xray']
+ if all(k in xray for k in ['length', 'interval', 'packets']):
+ fragment_regex = f"{xray['length']},{xray['interval']},{xray['packets']}"
+ updates['fragment_setting'] = fragment_regex
+
+ if hasattr(row, 'noise_settings') and row.noise_settings:
+ noise_json = row.noise_settings
+ noise_regex = sa.null()
+
+ # Convert noise JSON back to regex
+ if noise_json and 'xray' in noise_json and isinstance(noise_json['xray'], list):
+ noise_parts = []
+ for noise in noise_json['xray']:
+ if all(k in noise for k in ['type', 'packet', 'delay']):
+ noise_parts.append(f"{noise['type']}:{noise['packet']},{noise['delay']}")
+
+ if noise_parts:
+ noise_regex = "&".join(noise_parts)
+ updates['noise_setting'] = noise_regex
+
+ if updates:
+ session.execute(
+ table.update().where(table.c.id == row.id).values(**updates)
+ )
+
+ # Add old string columns
+ if 'fragment_setting' not in existing_columns:
+ op.add_column('hosts', sa.Column('fragment_setting', sa.String(255), nullable=True))
+
+ if 'noise_setting' not in existing_columns:
+ op.add_column('hosts', sa.Column('noise_setting', sa.String(255), nullable=True))
+
+ try:
+ with op.batch_alter_table('hosts') as batch_op:
+ hosts_table = sa.Table('hosts', sa.MetaData(), autoload_with=bind)
+ json_to_regex(hosts_table)
+
+ # Drop JSON columns if they exist
+ if 'fragment_settings' in existing_columns:
+ batch_op.drop_column('fragment_settings')
+
+ if 'noise_settings' in existing_columns:
+ batch_op.drop_column('noise_settings')
+
+ session.commit()
+ finally:
+ session.close()
diff --git a/app/db/migrations/versions/97dd9311ab93_add_user_templates.py b/app/db/migrations/versions/97dd9311ab93_add_user_templates.py
new file mode 100644
index 000000000..5be7ad43e
--- /dev/null
+++ b/app/db/migrations/versions/97dd9311ab93_add_user_templates.py
@@ -0,0 +1,46 @@
+"""add user templates
+
+Revision ID: 97dd9311ab93
+Revises: e410e5f15c3f
+Create Date: 2023-03-26 04:09:42.829125
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '97dd9311ab93'
+down_revision = 'e410e5f15c3f'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('user_templates',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('name', sa.String(length=64), nullable=False),
+ sa.Column('data_limit', sa.Integer(), nullable=True),
+ sa.Column('expire_duration', sa.Integer(), nullable=True),
+ sa.Column('username_prefix', sa.String(length=20), nullable=True),
+ sa.Column('username_suffix', sa.String(length=20), nullable=True),
+ sa.PrimaryKeyConstraint('id'),
+ sa.UniqueConstraint('name')
+ )
+ op.create_index(op.f('ix_user_templates_id'), 'user_templates', ['id'], unique=False)
+ op.create_table('template_inbounds_association',
+ sa.Column('user_template_id', sa.Integer(), nullable=True),
+ sa.Column('inbound_tag', sa.String(length=256), nullable=True),
+ sa.ForeignKeyConstraint(['inbound_tag'], ['inbounds.tag'], ),
+ sa.ForeignKeyConstraint(['user_template_id'], ['user_templates.id'], )
+ )
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table('template_inbounds_association')
+ op.drop_index(op.f('ix_user_templates_id'), table_name='user_templates')
+ op.drop_table('user_templates')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/9aa6559916be_drop_mux_enable_column_in_hosts_table.py b/app/db/migrations/versions/9aa6559916be_drop_mux_enable_column_in_hosts_table.py
new file mode 100644
index 000000000..639320351
--- /dev/null
+++ b/app/db/migrations/versions/9aa6559916be_drop_mux_enable_column_in_hosts_table.py
@@ -0,0 +1,28 @@
+"""drop mux_enable column in hosts table
+
+Revision ID: 9aa6559916be
+Revises: 95da30deba8d
+Create Date: 2025-02-27 11:28:13.464363
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '9aa6559916be'
+down_revision = '95da30deba8d'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('hosts', 'mux_enable')
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('hosts', sa.Column('mux_enable', sa.BOOLEAN(), server_default=sa.text("'0'"), nullable=False))
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/9af04c077ede_init_settings.py b/app/db/migrations/versions/9af04c077ede_init_settings.py
new file mode 100644
index 000000000..8ae12fd58
--- /dev/null
+++ b/app/db/migrations/versions/9af04c077ede_init_settings.py
@@ -0,0 +1,248 @@
+"""init settings
+
+Revision ID: 9af04c077ede
+Revises: beb47f520963
+Create Date: 2025-05-08 19:01:36.454848
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from decouple import config as decouple_config
+
+
+# revision identifiers, used by Alembic.
+revision = "9af04c077ede"
+down_revision = "beb47f520963"
+branch_labels = None
+depends_on = None
+
+
+# Define a function to mimic config behavior but directly from environment
+def get_config(key, default=None, cast=None):
+ if cast is not None:
+ return decouple_config(key, default=default, cast=cast)
+ else:
+ return decouple_config(key, default=default)
+
+
+# Environment variables management
+TELEGRAM_API_TOKEN = get_config("TELEGRAM_API_TOKEN", default="")
+TELEGRAM_WEBHOOK_URL = get_config("TELEGRAM_WEBHOOK_URL", default="").strip("/")
+TELEGRAM_WEBHOOK_SECRET_KEY = get_config("TELEGRAM_WEBHOOK_SECRET_KEY", default=None)
+TELEGRAM_ADMIN_ID = get_config(
+ "TELEGRAM_ADMIN_ID", default="", cast=lambda v: int(v.split(",")[0].strip()) if v.strip() else None
+)
+TELEGRAM_PROXY_URL = get_config("TELEGRAM_PROXY_URL", default=None)
+TELEGRAM_LOGGER_CHANNEL_ID = get_config("TELEGRAM_LOGGER_CHANNEL_ID", cast=int, default=0)
+TELEGRAM_LOGGER_TOPIC_ID = get_config("TELEGRAM_LOGGER_TOPIC_ID", cast=int, default=0)
+TELEGRAM_NOTIFY = get_config("TELEGRAM_NOTIFY", cast=bool, default=False)
+
+WEBHOOK_ADDRESS = get_config(
+ "WEBHOOK_ADDRESS", default="", cast=lambda v: [address.strip() for address in v.split(",")] if v else []
+)
+WEBHOOK_SECRET = get_config("WEBHOOK_SECRET", default=None)
+WEBHOOK_PROXY_URL = get_config("WEBHOOK_PROXY_URL", default=None)
+
+NOTIFICATION_PROXY_URL = get_config("NOTIFICATION_PROXY_URL", default=None)
+
+# recurrent notifications
+RECURRENT_NOTIFICATIONS_TIMEOUT = get_config("RECURRENT_NOTIFICATIONS_TIMEOUT", default=180, cast=int)
+NUMBER_OF_RECURRENT_NOTIFICATIONS = get_config("NUMBER_OF_RECURRENT_NOTIFICATIONS", default=3, cast=int)
+
+# Notification thresholds
+NOTIFY_REACHED_USAGE_PERCENT = get_config(
+ "NOTIFY_REACHED_USAGE_PERCENT", default="80", cast=lambda v: [int(p.strip()) for p in v.split(",")] if v else []
+)
+NOTIFY_DAYS_LEFT = get_config(
+ "NOTIFY_DAYS_LEFT", default="3", cast=lambda v: [int(d.strip()) for d in v.split(",")] if v else []
+)
+
+# Discord webhook
+DISCORD_WEBHOOK_URL = get_config("DISCORD_WEBHOOK_URL", default="")
+
+# Subscription settings
+XRAY_SUBSCRIPTION_URL_PREFIX = get_config("XRAY_SUBSCRIPTION_URL_PREFIX", default="").strip("/")
+SUB_UPDATE_INTERVAL = get_config("SUB_UPDATE_INTERVAL", default="12")
+SUB_SUPPORT_URL = get_config("SUB_SUPPORT_URL", default="https://t.me/")
+SUB_PROFILE_TITLE = get_config("SUB_PROFILE_TITLE", default="Subscription")
+HOST_STATUS_FILTER = get_config("HOST_STATUS_FILTER", default=True, cast=bool)
+
+# Custom JSON settings
+USE_CUSTOM_JSON_DEFAULT = get_config("USE_CUSTOM_JSON_DEFAULT", default=False, cast=bool)
+USE_CUSTOM_JSON_FOR_V2RAYN = get_config("USE_CUSTOM_JSON_FOR_V2RAYN", default=False, cast=bool)
+USE_CUSTOM_JSON_FOR_V2RAYNG = get_config("USE_CUSTOM_JSON_FOR_V2RAYNG", default=False, cast=bool)
+USE_CUSTOM_JSON_FOR_STREISAND = get_config("USE_CUSTOM_JSON_FOR_STREISAND", default=False, cast=bool)
+USE_CUSTOM_JSON_FOR_HAPP = get_config("USE_CUSTOM_JSON_FOR_HAPP", default=False, cast=bool)
+USE_CUSTOM_JSON_FOR_NPVTUNNEL = get_config("USE_CUSTOM_JSON_FOR_NPVTUNNEL", default=False, cast=bool)
+
+
+# Build settings dictionaries
+telegram = {
+ "enable": True if TELEGRAM_API_TOKEN and TELEGRAM_WEBHOOK_URL and TELEGRAM_WEBHOOK_SECRET_KEY else False,
+ "token": TELEGRAM_API_TOKEN if TELEGRAM_API_TOKEN else None,
+ "webhook_url": TELEGRAM_WEBHOOK_URL if TELEGRAM_WEBHOOK_URL else None,
+ "webhook_secret": TELEGRAM_WEBHOOK_SECRET_KEY if TELEGRAM_WEBHOOK_SECRET_KEY else None,
+ "proxy_url": TELEGRAM_PROXY_URL,
+}
+
+discord = {"enable": False, "token": None, "proxy_url": None}
+
+webhook = {
+ "enable": True if WEBHOOK_ADDRESS else False,
+ "webhooks": [{"url": url, "secret": WEBHOOK_SECRET} for url in WEBHOOK_ADDRESS],
+ "days_left": NOTIFY_DAYS_LEFT,
+ "usage_percent": NOTIFY_REACHED_USAGE_PERCENT,
+ "timeout": RECURRENT_NOTIFICATIONS_TIMEOUT,
+ "recurrent": NUMBER_OF_RECURRENT_NOTIFICATIONS,
+ "proxy_url": WEBHOOK_PROXY_URL,
+}
+
+notification_settings = {
+ "notify_telegram": TELEGRAM_NOTIFY,
+ "notify_discord": True if DISCORD_WEBHOOK_URL else False,
+
+ "telegram_api_token": TELEGRAM_API_TOKEN if TELEGRAM_API_TOKEN else None,
+ "telegram_admin_id": TELEGRAM_ADMIN_ID if TELEGRAM_ADMIN_ID else None,
+ "telegram_channel_id": TELEGRAM_LOGGER_CHANNEL_ID if TELEGRAM_LOGGER_CHANNEL_ID else None,
+ "telegram_topic_id": TELEGRAM_LOGGER_TOPIC_ID if TELEGRAM_LOGGER_TOPIC_ID else None,
+
+ "discord_webhook_url": DISCORD_WEBHOOK_URL if DISCORD_WEBHOOK_URL else None,
+
+ "proxy_url": NOTIFICATION_PROXY_URL,
+
+ "max_retries": 3,
+}
+
+notification_enable = {
+ "admin": True,
+ "core": True,
+ "group": True,
+ "host": True,
+ "login": True,
+ "node": True,
+ "user": True,
+ "user_template": True,
+ "days_left": True,
+ "percentage_reached": True,
+}
+
+xray_rule = ""
+
+def append_rule(pattern: str) -> None:
+ global xray_rule
+ if xray_rule:
+ xray_rule += "|" + pattern
+ else:
+ xray_rule = pattern
+
+if USE_CUSTOM_JSON_DEFAULT:
+ append_rule("[Vv]2rayNG")
+ append_rule("[Vv]2rayN")
+ append_rule("[Ss]treisand")
+ append_rule("[Hh]app")
+ append_rule(r"[Kk]tor\-client")
+
+else:
+ if USE_CUSTOM_JSON_FOR_V2RAYNG:
+ append_rule("[Vv]2rayNG")
+ if USE_CUSTOM_JSON_FOR_V2RAYN:
+ append_rule("[Vv]2rayN")
+ if USE_CUSTOM_JSON_FOR_STREISAND:
+ append_rule("[Ss]treisand")
+ if USE_CUSTOM_JSON_FOR_HAPP:
+ append_rule("[Hh]app")
+ if USE_CUSTOM_JSON_FOR_NPVTUNNEL:
+ append_rule(r"[Kk]tor\-client")
+
+
+rules = [
+ {
+ "pattern": r"^([Cc]lash[\-\.]?[Vv]erge|[Cc]lash[\-\.]?[Mm]eta|[Ff][Ll][Cc]lash|[Mm]ihomo)",
+ "target": "clash_meta"
+ },
+ {
+ "pattern": r"^([Cc]lash|[Ss]tash)",
+ "target": "clash"
+ },
+ {
+ "pattern": r"^(SFA|SFI|SFM|SFT|[Kk]aring|[Hh]iddify[Nn]ext)|.*[Ss]ing[\-b]?ox.*",
+ "target": "sing_box"
+ },
+ {
+ "pattern": r"^(SS|SSR|SSD|SSS|Outline|Shadowsocks|SSconf)",
+ "target": "outline"
+ },
+ {
+ "pattern": r"^.*", # Default catch-all pattern
+ "target": "links_base64"
+ }
+]
+
+if xray_rule:
+ rules.insert(-1, {"pattern": r"^(%s)" % xray_rule, "target": "xray"})
+
+manual_sub_request = {
+ "links": True,
+ "links_base64": True,
+ "xray": True,
+ "sing_box": True,
+ "clash": True,
+ "clash_meta": True,
+ "outline": True,
+}
+
+subscription = {
+ "url_prefix": XRAY_SUBSCRIPTION_URL_PREFIX,
+ "update_interval": SUB_UPDATE_INTERVAL,
+ "support_url": SUB_SUPPORT_URL,
+ "profile_title": SUB_PROFILE_TITLE,
+
+ "host_status_filter": HOST_STATUS_FILTER,
+
+ "rules": rules,
+ "manual_sub_request": manual_sub_request
+}
+
+base_settings = {
+ "telegram": telegram,
+ "discord": discord,
+ "webhook": webhook,
+ "notification_settings": notification_settings,
+ "notification_enable": notification_enable,
+ "subscription": subscription,
+}
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table("settings",
+ sa.Column("id", sa.Integer(), nullable=False),
+ sa.Column("telegram", sa.JSON(), nullable=False),
+ sa.Column("discord", sa.JSON(), nullable=False),
+ sa.Column("webhook", sa.JSON(), nullable=False),
+ sa.Column("notification_settings", sa.JSON(), nullable=False),
+ sa.Column("notification_enable", sa.JSON(), nullable=False),
+ sa.Column("subscription", sa.JSON(), nullable=False),
+ sa.PrimaryKeyConstraint("id")
+ )
+ # ### end Alembic commands ###
+
+ op.bulk_insert(
+ sa.table(
+ "settings",
+ sa.Column("id", sa.Integer),
+ sa.Column("telegram", sa.JSON),
+ sa.Column("discord", sa.JSON),
+ sa.Column("webhook", sa.JSON),
+ sa.Column("notification_settings", sa.JSON),
+ sa.Column("notification_enable", sa.JSON),
+ sa.Column("subscription", sa.JSON)
+ ),
+ [
+ base_settings
+ ],
+ )
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table("settings")
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/9b60be6cd0a2_add_created_at_field_to_users_table.py b/app/db/migrations/versions/9b60be6cd0a2_add_created_at_field_to_users_table.py
new file mode 100644
index 000000000..b7da9743e
--- /dev/null
+++ b/app/db/migrations/versions/9b60be6cd0a2_add_created_at_field_to_users_table.py
@@ -0,0 +1,33 @@
+"""add created_at field to users table
+
+Revision ID: 9b60be6cd0a2
+Revises: 9d5a518ae432
+Create Date: 2022-12-25 18:45:46.743506
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '9b60be6cd0a2'
+down_revision = '9d5a518ae432'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table('users') as batch_op:
+ batch_op.add_column(sa.Column(
+ 'created_at', sa.DateTime(),
+ nullable=False,
+ server_default=sa.func.current_timestamp()))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table('users') as batch_op:
+ batch_op.drop_column('created_at')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/9cfffef342cd_user_template_status_and_reset_usage.py b/app/db/migrations/versions/9cfffef342cd_user_template_status_and_reset_usage.py
new file mode 100644
index 000000000..d7ba80f00
--- /dev/null
+++ b/app/db/migrations/versions/9cfffef342cd_user_template_status_and_reset_usage.py
@@ -0,0 +1,45 @@
+"""user template status and reset usage
+
+Revision ID: 9cfffef342cd
+Revises: 53586547c10e
+Create Date: 2025-04-14 11:08:05.711323
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = '9cfffef342cd'
+down_revision = '53586547c10e'
+branch_labels = None
+depends_on = None
+
+
+new_enum = sa.Enum('active', 'on_hold', name='userstatuscreate')
+
+
+
+def upgrade() -> None:
+ connection = op.get_bind()
+ dialect = connection.dialect.name
+
+ if dialect == "postgresql":
+ new_enum.create(op.get_bind(), checkfirst=False)
+
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('user_templates', sa.Column('status', new_enum, nullable=False, server_default='active'))
+ op.add_column('user_templates', sa.Column('reset_usages', sa.Boolean(), server_default='0', nullable=False))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ connection = op.get_bind()
+ dialect = connection.dialect.name
+
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('user_templates', 'reset_usages')
+ op.drop_column('user_templates', 'status')
+ # ### end Alembic commands ###
+
+ if dialect == "postgresql":
+ new_enum.drop(op.get_bind(), checkfirst=False)
\ No newline at end of file
diff --git a/app/db/migrations/versions/9d5a518ae432_init_jwt_table.py b/app/db/migrations/versions/9d5a518ae432_init_jwt_table.py
new file mode 100644
index 000000000..51c44340d
--- /dev/null
+++ b/app/db/migrations/versions/9d5a518ae432_init_jwt_table.py
@@ -0,0 +1,37 @@
+"""init jwt table
+
+Revision ID: 9d5a518ae432
+Revises: 3cf36a5fde73
+Create Date: 2022-11-24 21:02:44.278773
+
+"""
+import os
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '9d5a518ae432'
+down_revision = '3cf36a5fde73'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ table = op.create_table('jwt',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('secret_key', sa.String(length=64), nullable=False),
+ sa.PrimaryKeyConstraint('id')
+ )
+
+ # INSERT DEFAULT ROW
+ op.bulk_insert(table, [{"id": 1, "secret_key": os.urandom(32).hex()}])
+
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table('jwt')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/9fa4831e991a_add_ech_config_list_to_hosts.py b/app/db/migrations/versions/9fa4831e991a_add_ech_config_list_to_hosts.py
new file mode 100644
index 000000000..aed74df54
--- /dev/null
+++ b/app/db/migrations/versions/9fa4831e991a_add_ech_config_list_to_hosts.py
@@ -0,0 +1,28 @@
+"""add ech_config_list to hosts
+
+Revision ID: 9fa4831e991a
+Revises: e422f859847f
+Create Date: 2025-07-29 13:11:02.048475
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '9fa4831e991a'
+down_revision = 'e422f859847f'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('hosts', sa.Column('ech_config_list', sa.String(length=512), nullable=True))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('hosts', 'ech_config_list')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/a0715c2615f0_add_allow_in_secure_and_disable_host.py b/app/db/migrations/versions/a0715c2615f0_add_allow_in_secure_and_disable_host.py
new file mode 100644
index 000000000..08407b28b
--- /dev/null
+++ b/app/db/migrations/versions/a0715c2615f0_add_allow_in_secure_and_disable_host.py
@@ -0,0 +1,34 @@
+"""Add allow in secure and disable host
+
+Revision ID: a0715c2615f0
+Revises: 1cf7d159fdbb
+Create Date: 2023-09-13 20:39:58.913256
+
+"""
+import sqlalchemy
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'a0715c2615f0'
+down_revision = '1cf7d159fdbb'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ try:
+ op.add_column('hosts', sa.Column('allowinsecure', sa.Boolean(), nullable=True))
+ except sqlalchemy.exc.OperationalError:
+ with op.batch_alter_table('hosts') as batch_op:
+ batch_op.drop_column('allowinsecure')
+ op.add_column('hosts', sa.Column('allowinsecure', sa.Boolean(), nullable=True))
+
+ op.add_column('hosts', sa.Column('is_disabled', sa.Boolean(), nullable=True))
+
+
+def downgrade() -> None:
+ with op.batch_alter_table('hosts') as batch_op:
+ batch_op.drop_column('is_disabled')
+ batch_op.drop_column('allowinsecure')
diff --git a/app/db/migrations/versions/a0d3d400ea75_admin_is_sudo_field.py b/app/db/migrations/versions/a0d3d400ea75_admin_is_sudo_field.py
new file mode 100644
index 000000000..66a2eceb8
--- /dev/null
+++ b/app/db/migrations/versions/a0d3d400ea75_admin_is_sudo_field.py
@@ -0,0 +1,29 @@
+"""Admin: is_sudo field
+
+Revision ID: a0d3d400ea75
+Revises: 5b84d88804a1
+Create Date: 2023-03-10 23:01:23.260630
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.sql import expression
+
+
+# revision identifiers, used by Alembic.
+revision = 'a0d3d400ea75'
+down_revision = '5b84d88804a1'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('admins', sa.Column('is_sudo', sa.Boolean(), nullable=True, server_default=expression.false()))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('admins', 'is_sudo')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/a6e3fff39291_add_notification_reminders.py b/app/db/migrations/versions/a6e3fff39291_add_notification_reminders.py
new file mode 100644
index 000000000..77eaaa6bc
--- /dev/null
+++ b/app/db/migrations/versions/a6e3fff39291_add_notification_reminders.py
@@ -0,0 +1,38 @@
+"""add notification reminders
+
+Revision ID: a6e3fff39291
+Revises: 015cf1dc6eca
+Create Date: 2023-08-21 18:04:37.096599
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'a6e3fff39291'
+down_revision = '015cf1dc6eca'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('notification_reminders',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('user_id', sa.Integer(), nullable=True),
+ sa.Column('type', sa.Enum('expiration_date', 'data_usage', name='remindertype'), nullable=False),
+ sa.Column('expires_at', sa.DateTime(), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=True),
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_notification_reminders_id'), 'notification_reminders', ['id'], unique=False)
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_index(op.f('ix_notification_reminders_id'), table_name='notification_reminders')
+ op.drop_table('notification_reminders')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/a9cfd5611a82_add_noise_settings.py b/app/db/migrations/versions/a9cfd5611a82_add_noise_settings.py
new file mode 100644
index 000000000..6b787aae9
--- /dev/null
+++ b/app/db/migrations/versions/a9cfd5611a82_add_noise_settings.py
@@ -0,0 +1,28 @@
+"""add noise settings
+
+Revision ID: a9cfd5611a82
+Revises: 2313cdc30da3
+Create Date: 2024-09-04 18:55:55.167589
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'a9cfd5611a82'
+down_revision = '2313cdc30da3'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('hosts', sa.Column('noise_setting', sa.String(length=2000), nullable=True))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('hosts', 'noise_setting')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/ac26f36783a2_increase_username_length.py b/app/db/migrations/versions/ac26f36783a2_increase_username_length.py
new file mode 100644
index 000000000..dd546aa75
--- /dev/null
+++ b/app/db/migrations/versions/ac26f36783a2_increase_username_length.py
@@ -0,0 +1,40 @@
+"""increase username length
+
+Revision ID: ac26f36783a2
+Revises: 6980e98bba01
+Create Date: 2025-04-18 16:43:14.242833
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from app.db.compiles_types import CaseSensitiveString
+
+
+# revision identifiers, used by Alembic.
+revision = 'ac26f36783a2'
+down_revision = '6980e98bba01'
+branch_labels = None
+depends_on = None
+
+table_name = 'users'
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table(table_name) as batch_op:
+ batch_op.alter_column('username',
+ existing_type=sa.VARCHAR(length=34),
+ type_=CaseSensitiveString(length=128),
+ existing_nullable=False,
+ )
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table(table_name) as batch_op:
+ batch_op.alter_column('username',
+ existing_type=CaseSensitiveString(length=128),
+ type_=sa.VARCHAR(length=34),
+ existing_nullable=False,
+ )
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/adda2dd4a741_add_sockopt_proxy_outbound_mux_enable_fragment_setting_to_hosts.py b/app/db/migrations/versions/adda2dd4a741_add_sockopt_proxy_outbound_mux_enable_fragment_setting_to_hosts.py
new file mode 100644
index 000000000..9e621f72d
--- /dev/null
+++ b/app/db/migrations/versions/adda2dd4a741_add_sockopt_proxy_outbound_mux_enable_fragment_setting_to_hosts.py
@@ -0,0 +1,34 @@
+"""Add sockopt proxy_outbound mux_enable fragment_setting to hosts
+
+Revision ID: adda2dd4a741
+Revises: dd725e4d3628
+Create Date: 2024-02-18 21:46:55.311329
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'adda2dd4a741'
+down_revision = 'dd725e4d3628'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('hosts', sa.Column('sockopt', sa.JSON(), nullable=True))
+ op.add_column('hosts', sa.Column('proxy_outbound', sa.JSON(), nullable=True))
+ op.add_column('hosts', sa.Column('mux_enable', sa.Boolean(), server_default='0', nullable=False))
+ op.add_column('hosts', sa.Column('fragment_setting', sa.String(length=100), nullable=True))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('hosts', 'proxy_outbound')
+ op.drop_column('hosts', 'sockopt')
+ op.drop_column('hosts', 'mux_enable')
+ op.drop_column('hosts', 'fragment_setting')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/b15eba6e5867_add_host_sni.py b/app/db/migrations/versions/b15eba6e5867_add_host_sni.py
new file mode 100644
index 000000000..de2ecdf7a
--- /dev/null
+++ b/app/db/migrations/versions/b15eba6e5867_add_host_sni.py
@@ -0,0 +1,26 @@
+"""add host and sni columns to hosts table
+
+Revision ID: b15eba6e5867
+Revises: ece13c4c6f65
+Create Date: 2023-02-28 18:34:19.100255
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = "b15eba6e5867"
+down_revision = "ece13c4c6f65"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.add_column("hosts", sa.Column("sni", sa.String(256), nullable=True))
+ op.add_column("hosts", sa.Column("host", sa.String(256), nullable=True))
+
+
+def downgrade() -> None:
+ op.drop_column("hosts", "host")
+ op.drop_column("hosts", "sni")
diff --git a/app/db/migrations/versions/b25e7e6be241_added_users_usage_to_admin.py b/app/db/migrations/versions/b25e7e6be241_added_users_usage_to_admin.py
new file mode 100644
index 000000000..8fe4deb1f
--- /dev/null
+++ b/app/db/migrations/versions/b25e7e6be241_added_users_usage_to_admin.py
@@ -0,0 +1,28 @@
+"""Added users_usage to admin
+
+Revision ID: b25e7e6be241
+Revises: c3cd674b9bcd
+Create Date: 2024-11-11 23:41:08.511867
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'b25e7e6be241'
+down_revision = 'c3cd674b9bcd'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('admins', sa.Column('users_usage', sa.BigInteger(), nullable=False, server_default="0"))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('admins', 'users_usage')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/b3378dc6de01_hosts.py b/app/db/migrations/versions/b3378dc6de01_hosts.py
new file mode 100644
index 000000000..e33e37513
--- /dev/null
+++ b/app/db/migrations/versions/b3378dc6de01_hosts.py
@@ -0,0 +1,36 @@
+"""hosts
+
+Revision ID: b3378dc6de01
+Revises: e91236993f1a
+Create Date: 2023-02-14 18:22:26.791353
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'b3378dc6de01'
+down_revision = 'e91236993f1a'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('hosts',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('remark', sa.String(256), nullable=False),
+ sa.Column('address', sa.String(256), nullable=False),
+ sa.Column('port', sa.Integer(), nullable=True),
+ sa.Column('inbound_tag', sa.String(256), nullable=False),
+ sa.ForeignKeyConstraint(['inbound_tag'], ['inbounds.tag'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table('hosts')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/be0c5f840473_fix_multiple_heads_error.py b/app/db/migrations/versions/be0c5f840473_fix_multiple_heads_error.py
new file mode 100644
index 000000000..cb787c917
--- /dev/null
+++ b/app/db/migrations/versions/be0c5f840473_fix_multiple_heads_error.py
@@ -0,0 +1,24 @@
+"""fix multiple heads error
+
+Revision ID: be0c5f840473
+Revises: d0a3960f5dad, 54c4b8c525fc
+Create Date: 2024-12-02 15:41:08.152196
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'be0c5f840473'
+down_revision = ('d0a3960f5dad', '54c4b8c525fc')
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ pass
+
+
+def downgrade() -> None:
+ pass
diff --git a/app/db/migrations/versions/beb47f520963_gather_node_logs_option.py b/app/db/migrations/versions/beb47f520963_gather_node_logs_option.py
new file mode 100644
index 000000000..e4773e0af
--- /dev/null
+++ b/app/db/migrations/versions/beb47f520963_gather_node_logs_option.py
@@ -0,0 +1,28 @@
+"""gather node logs option
+
+Revision ID: beb47f520963
+Revises: 508061427170
+Create Date: 2025-05-03 11:39:20.303870
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'beb47f520963'
+down_revision = '508061427170'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('nodes', sa.Column('gather_logs', sa.Boolean(), server_default='1', nullable=False))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('nodes', 'gather_logs')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/c106bb40c861_alpn_fingerprint_hosts.py b/app/db/migrations/versions/c106bb40c861_alpn_fingerprint_hosts.py
new file mode 100644
index 000000000..e713922b0
--- /dev/null
+++ b/app/db/migrations/versions/c106bb40c861_alpn_fingerprint_hosts.py
@@ -0,0 +1,35 @@
+"""alpn_fingerprint_hosts
+
+Revision ID: c106bb40c861
+Revises: 57fda18cd9e6
+Create Date: 2023-05-05 18:50:45.505537
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'c106bb40c861'
+down_revision = '57fda18cd9e6'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ proxyhostalpn_enum = sa.Enum('none', 'h2', 'http/1.1', 'h2,http/1.1', name='proxyhostalpn')
+ proxyhostalpn_enum.create(op.get_bind(), checkfirst=True)
+
+ proxyhostfingerprint_enum = sa.Enum('none', 'chrome', 'firefox', 'safari', 'ios', 'android', 'edge', '360', 'qq', 'random', 'randomized', name='proxyhostfingerprint')
+ proxyhostfingerprint_enum.create(op.get_bind(), checkfirst=True)
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('hosts', sa.Column('alpn', sa.Enum('none', 'h2', 'http/1.1', 'h2,http/1.1', name='proxyhostalpn'), server_default='none', nullable=False))
+ op.add_column('hosts', sa.Column('fingerprint', sa.Enum('none', 'chrome', 'firefox', 'safari', 'ios', 'android', 'edge', '360', 'qq', 'random', 'randomized', name='proxyhostfingerprint'), server_default='none', nullable=False))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('hosts', 'fingerprint')
+ op.drop_column('hosts', 'alpn')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/c3cd674b9bcd_added_next_plan_for_user.py b/app/db/migrations/versions/c3cd674b9bcd_added_next_plan_for_user.py
new file mode 100644
index 000000000..4dfe60720
--- /dev/null
+++ b/app/db/migrations/versions/c3cd674b9bcd_added_next_plan_for_user.py
@@ -0,0 +1,37 @@
+"""Added next_plan for user
+
+Revision ID: c3cd674b9bcd
+Revises: 21226bc711ac
+Create Date: 2024-11-07 12:45:51.159960
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'c3cd674b9bcd'
+down_revision = '21226bc711ac'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('next_plans',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('user_id', sa.Integer(), nullable=False),
+ sa.Column('data_limit', sa.BigInteger(), nullable=False),
+ sa.Column('expire', sa.Integer(), nullable=True),
+ sa.Column('add_remaining_traffic', sa.Boolean(), server_default='0', nullable=False),
+ sa.Column('fire_on_either', sa.Boolean(), server_default='0', nullable=False),
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table('next_plans')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/c41c441de44c_gozargah_node.py b/app/db/migrations/versions/c41c441de44c_gozargah_node.py
new file mode 100644
index 000000000..5545d0d79
--- /dev/null
+++ b/app/db/migrations/versions/c41c441de44c_gozargah_node.py
@@ -0,0 +1,50 @@
+"""gozargah-node
+
+Revision ID: c41c441de44c
+Revises: 0b62f893092b
+Create Date: 2025-03-12 15:01:01.918710
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+
+# revision identifiers, used by Alembic.
+revision = 'c41c441de44c'
+down_revision = '0b62f893092b'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('nodes', sa.Column('node_version', sa.String(length=32), nullable=True))
+
+ # Create enum type for PostgreSQL
+ if op.get_bind().dialect.name == 'postgresql':
+ nodeconnectiontype = postgresql.ENUM('grpc', 'rest', name='nodeconnectiontype')
+ nodeconnectiontype.create(op.get_bind())
+
+ op.add_column('nodes', sa.Column('connection_type', sa.Enum('grpc', 'rest', name='nodeconnectiontype', create_type=False), server_default='grpc', nullable=False))
+ op.add_column('nodes', sa.Column('server_ca', sa.String(length=2048), nullable=False, server_default=''))
+ op.add_column('nodes', sa.Column('keep_alive', sa.Integer(), nullable=False, server_default='0'))
+ op.add_column('nodes', sa.Column('max_logs', sa.BigInteger(), server_default=sa.text('1000'), nullable=False))
+ op.drop_column('nodes', 'api_port')
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('nodes', sa.Column('api_port', sa.INTEGER(), nullable=False))
+ op.drop_column('nodes', 'max_logs')
+ op.drop_column('nodes', 'keep_alive')
+ op.drop_column('nodes', 'server_ca')
+ op.drop_column('nodes', 'connection_type')
+ op.drop_column('nodes', 'node_version')
+
+ # Drop enum type for PostgreSQL
+ if op.get_bind().dialect.name == 'postgresql':
+ nodeconnectiontype = postgresql.ENUM('grpc', 'rest', name='nodeconnectiontype')
+ nodeconnectiontype.drop(op.get_bind())
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/c47250b790eb_add_user_note.py b/app/db/migrations/versions/c47250b790eb_add_user_note.py
new file mode 100644
index 000000000..ebce4f605
--- /dev/null
+++ b/app/db/migrations/versions/c47250b790eb_add_user_note.py
@@ -0,0 +1,28 @@
+"""add user note
+
+Revision ID: c47250b790eb
+Revises: 35f7f8fa9cf2
+Create Date: 2023-07-16 15:26:51.350468
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'c47250b790eb'
+down_revision = '35f7f8fa9cf2'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('users', sa.Column('note', sa.String(length=500), nullable=True))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('users', 'note')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/c5c734bd3da2_add_priority_to_proxy_hosts.py b/app/db/migrations/versions/c5c734bd3da2_add_priority_to_proxy_hosts.py
new file mode 100644
index 000000000..c798d4c86
--- /dev/null
+++ b/app/db/migrations/versions/c5c734bd3da2_add_priority_to_proxy_hosts.py
@@ -0,0 +1,150 @@
+"""add priority to proxy hosts
+
+Revision ID: c5c734bd3da2
+Revises: 68edca039166
+Create Date: 2025-02-24 22:29:49.063544
+
+"""
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import mysql
+from sqlalchemy import inspect
+
+
+# revision identifiers, used by Alembic.
+revision = "c5c734bd3da2"
+down_revision = "68edca039166"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+
+ dialect_name = op.get_bind().dialect.name
+ is_sqlite = dialect_name == "sqlite"
+ is_postgresql = dialect_name == "postgresql"
+ is_mysql = dialect_name == "mysql"
+
+ op.add_column("hosts", sa.Column("priority", sa.Integer(), nullable=True))
+ op.execute("UPDATE hosts SET priority = id")
+
+ with op.batch_alter_table("hosts") as batch_op:
+ # Handle priority column first
+ batch_op.alter_column("priority", existing_type=sa.Integer(), nullable=False)
+
+ if is_sqlite:
+ with op.batch_alter_table("hosts") as batch_op:
+ batch_op.alter_column('inbound_tag',
+ existing_type=sa.VARCHAR(length=256),
+ nullable=True)
+ batch_op.create_foreign_key("hosts_ibfk_1", 'inbounds', ['inbound_tag'], ['tag'], onupdate='CASCADE', ondelete='SET NULL')
+
+ else:
+ # Find and drop the existing foreign key constraint FIRST
+ inspector = inspect(op.get_bind())
+ for fk in inspector.get_foreign_keys('hosts'):
+ if fk['referred_table'] == 'inbounds' and 'inbound_tag' in fk['constrained_columns']:
+ op.drop_constraint(
+ fk['name'],
+ 'hosts',
+ type_='foreignkey'
+ )
+ break
+
+ if is_mysql:
+ # For MySQL, we need to drop the constraint before altering the column
+ op.alter_column(
+ 'hosts',
+ 'inbound_tag',
+ existing_type=mysql.VARCHAR(charset='utf8mb4', collation='utf8mb4_bin', length=256),
+ nullable=True,
+ )
+
+ elif is_postgresql:
+ # Now modify the column after dropping the constraint
+ op.alter_column(
+ 'hosts',
+ 'inbound_tag',
+ existing_type=sa.VARCHAR(length=256),
+ nullable=True,
+ )
+
+ # Create new foreign key with appropriate name
+ constraint_name = "hosts_inbound_tag_fkey" if is_postgresql else "hosts_ibfk_1"
+ op.create_foreign_key(
+ constraint_name,
+ 'hosts',
+ 'inbounds',
+ ['inbound_tag'],
+ ['tag'],
+ onupdate='CASCADE',
+ ondelete='SET NULL'
+ )
+
+ # Replace the direct constraint drop with an inspection-based approach
+ inspector = inspect(op.get_bind())
+ for fk in inspector.get_foreign_keys('hosts'):
+ if fk['referred_table'] == 'proxies' and 'proxy_id' in fk['constrained_columns']:
+ op.drop_constraint(
+ fk['name'],
+ 'hosts',
+ type_='foreignkey'
+ )
+ break
+
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+
+ is_sqlite = op.get_bind().dialect.name == "sqlite"
+ is_postgresql = op.get_bind().dialect.name == "postgresql"
+
+ if is_sqlite:
+ with op.batch_alter_table("hosts") as batch_op:
+ batch_op.drop_constraint('hosts_ibfk_1', type_='foreignkey')
+ batch_op.alter_column('inbound_tag',
+ existing_type=sa.VARCHAR(length=256),
+ nullable=False)
+ batch_op.create_foreign_key(
+ 'hosts_ibfk_1',
+ 'inbounds',
+ ['inbound_tag'],
+ ['tag']
+ )
+
+ else:
+ # Find and drop the existing foreign key constraint
+ inspector = inspect(op.get_bind())
+ for fk in inspector.get_foreign_keys('hosts'):
+ if fk['referred_table'] == 'inbounds' and 'inbound_tag' in fk['constrained_columns']:
+ op.drop_constraint(
+ fk['name'],
+ 'hosts',
+ type_='foreignkey'
+ )
+ break
+
+ # Create new foreign key with appropriate name
+ constraint_name = "hosts_inbound_tag_fkey" if is_postgresql else "hosts_ibfk_1"
+ op.create_foreign_key(
+ constraint_name,
+ 'hosts',
+ 'inbounds',
+ ['inbound_tag'],
+ ['tag']
+ )
+
+ op.alter_column(
+ 'hosts',
+ 'inbound_tag',
+ existing_type=sa.VARCHAR(length=256),
+ nullable=False,
+ )
+
+
+ op.drop_column("hosts", "priority")
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/cb99b515fbab_drop_excloud_inbounds_and_template_.py b/app/db/migrations/versions/cb99b515fbab_drop_excloud_inbounds_and_template_.py
new file mode 100644
index 000000000..3aacf14f4
--- /dev/null
+++ b/app/db/migrations/versions/cb99b515fbab_drop_excloud_inbounds_and_template_.py
@@ -0,0 +1,40 @@
+"""drop excloud_inbounds and template_inbound tables
+
+Revision ID: cb99b515fbab
+Revises: 16e19723febc
+Create Date: 2025-03-17 10:32:08.929368
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'cb99b515fbab'
+down_revision = '16e19723febc'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table('template_inbounds_association')
+ op.drop_table('exclude_inbounds_association')
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('exclude_inbounds_association',
+ sa.Column('proxy_id', sa.INTEGER(), nullable=True),
+ sa.Column('inbound_tag', sa.VARCHAR(length=256), nullable=True),
+ sa.ForeignKeyConstraint(['inbound_tag'], ['inbounds.tag'], ),
+ sa.ForeignKeyConstraint(['proxy_id'], ['proxies.id'], )
+ )
+ op.create_table('template_inbounds_association',
+ sa.Column('user_template_id', sa.INTEGER(), nullable=True),
+ sa.Column('inbound_tag', sa.VARCHAR(length=256), nullable=True),
+ sa.ForeignKeyConstraint(['inbound_tag'], ['inbounds.tag'], ),
+ sa.ForeignKeyConstraint(['user_template_id'], ['user_templates.id'], )
+ )
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/ccbf9d322ae3_user_auto_delete_in_days.py b/app/db/migrations/versions/ccbf9d322ae3_user_auto_delete_in_days.py
new file mode 100644
index 000000000..90dbdf363
--- /dev/null
+++ b/app/db/migrations/versions/ccbf9d322ae3_user_auto_delete_in_days.py
@@ -0,0 +1,28 @@
+"""user_auto_delete_in_days
+
+Revision ID: ccbf9d322ae3
+Revises: 4f045f53bef8
+Create Date: 2024-04-22 12:37:35.439501
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'ccbf9d322ae3'
+down_revision = '4f045f53bef8'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('users', sa.Column('auto_delete_in_days', sa.Integer(), nullable=True))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('users', 'auto_delete_in_days')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/cf67676aaf82_add_is_disabled_to_usertemplates.py b/app/db/migrations/versions/cf67676aaf82_add_is_disabled_to_usertemplates.py
new file mode 100644
index 000000000..6f8c9e1e3
--- /dev/null
+++ b/app/db/migrations/versions/cf67676aaf82_add_is_disabled_to_usertemplates.py
@@ -0,0 +1,28 @@
+"""add is_disabled to userTemplates
+
+Revision ID: cf67676aaf82
+Revises: d085fae205b6
+Create Date: 2025-05-16 18:20:41.833188
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'cf67676aaf82'
+down_revision = 'd085fae205b6'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('user_templates', sa.Column('is_disabled', sa.Boolean(), server_default='0', nullable=False))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('user_templates', 'is_disabled')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/d02dcfbf1517_add_userusageresetlogs_model_and_data_.py b/app/db/migrations/versions/d02dcfbf1517_add_userusageresetlogs_model_and_data_.py
new file mode 100644
index 000000000..5a204a730
--- /dev/null
+++ b/app/db/migrations/versions/d02dcfbf1517_add_userusageresetlogs_model_and_data_.py
@@ -0,0 +1,42 @@
+"""add UserUsageResetLogs Model and data_limit_strategy field to user
+
+Revision ID: d02dcfbf1517
+Revises: 671621870b02
+Create Date: 2023-02-01 21:10:49.830928
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'd02dcfbf1517'
+down_revision = '671621870b02'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ userdatalimitresetstrategy = sa.Enum('no_reset', 'day', 'week', 'month', 'year', name='userdatalimitresetstrategy')
+ userdatalimitresetstrategy.create(op.get_bind())
+
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('user_usage_logs',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('user_id', sa.Integer(), nullable=True),
+ sa.Column('used_traffic_at_reset', sa.BigInteger(), nullable=False),
+ sa.Column('reset_at', sa.DateTime(), nullable=True),
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_user_usage_logs_id'), 'user_usage_logs', ['id'], unique=False)
+ op.add_column('users', sa.Column('data_limit_reset_strategy', sa.Enum("no_reset", "day","week", "month", "year", name="userdatalimitresetstrategy"), nullable=False, server_default="no_reset"))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('users', 'data_limit_reset_strategy')
+ op.drop_index(op.f('ix_user_usage_logs_id'), table_name='user_usage_logs')
+ op.drop_table('user_usage_logs')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/d085fae205b6_rename_admin_users_usage_to_used_traffic.py b/app/db/migrations/versions/d085fae205b6_rename_admin_users_usage_to_used_traffic.py
new file mode 100644
index 000000000..0f8cac78f
--- /dev/null
+++ b/app/db/migrations/versions/d085fae205b6_rename_admin_users_usage_to_used_traffic.py
@@ -0,0 +1,24 @@
+"""rename admin users usage to used_traffic
+
+Revision ID: d085fae205b6
+Revises: 9af04c077ede
+Create Date: 2025-05-10 18:13:19.235725
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = 'd085fae205b6'
+down_revision = '9af04c077ede'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ with op.batch_alter_table('admins') as batch_op:
+ batch_op.alter_column('users_usage', new_column_name='used_traffic', existing_type=sa.BigInteger)
+
+def downgrade():
+ with op.batch_alter_table('admins') as batch_op:
+ batch_op.alter_column('used_traffic', new_column_name='users_usage', existing_type=sa.BigInteger)
diff --git a/app/db/migrations/versions/d0a3960f5dad_usagelog_table_for_admin.py b/app/db/migrations/versions/d0a3960f5dad_usagelog_table_for_admin.py
new file mode 100644
index 000000000..3a66228ca
--- /dev/null
+++ b/app/db/migrations/versions/d0a3960f5dad_usagelog_table_for_admin.py
@@ -0,0 +1,35 @@
+"""UsageLog table for Admin
+
+Revision ID: d0a3960f5dad
+Revises: b25e7e6be241
+Create Date: 2024-11-22 19:56:53.185624
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'd0a3960f5dad'
+down_revision = 'b25e7e6be241'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('admin_usage_logs',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('admin_id', sa.Integer(), nullable=True),
+ sa.Column('used_traffic_at_reset', sa.BigInteger(), nullable=False),
+ sa.Column('reset_at', sa.DateTime(), nullable=True),
+ sa.ForeignKeyConstraint(['admin_id'], ['admins.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table('admin_usage_logs')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/d607d3ca5246_change_enumarray_to_string.py b/app/db/migrations/versions/d607d3ca5246_change_enumarray_to_string.py
new file mode 100644
index 000000000..88ec8d918
--- /dev/null
+++ b/app/db/migrations/versions/d607d3ca5246_change_enumarray_to_string.py
@@ -0,0 +1,137 @@
+"""Change EnumArray to String
+
+Revision ID: d607d3ca5246
+Revises: 04a5ec93e9a5
+Create Date: 2025-07-11 16:21:16.858208
+"""
+from alembic import op
+import sqlalchemy as sa
+import json
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision = 'd607d3ca5246'
+down_revision = '04a5ec93e9a5'
+branch_labels = None
+depends_on = None
+
+user_status = sa.Enum('active', 'disabled', 'limited', 'expired', 'on_hold', name='userstatus')
+
+def upgrade() -> None:
+ bind = op.get_bind()
+ is_postgres = bind.engine.name == 'postgresql'
+
+ op.add_column('hosts', sa.Column('status_new', sa.String(length=60), nullable=True, server_default=""))
+ connection = op.get_bind()
+
+ if is_postgres:
+ # Use raw SQL to avoid SQLAlchemy's array parsing issues
+ result = connection.execute(sa.text("SELECT id, status FROM hosts"))
+ for row in result:
+ host_id, status_value = row
+ if status_value:
+ try:
+ # Handle different possible formats
+ if isinstance(status_value, list):
+ # Already a list
+ status_list = status_value
+ elif isinstance(status_value, str):
+ # Parse PostgreSQL array format like '{active,disabled}'
+ if status_value.startswith('{') and status_value.endswith('}'):
+ # Remove braces and split
+ status_list = status_value[1:-1].split(',')
+ else:
+ # Fallback for other string formats
+ status_list = [status_value]
+ else:
+ # Skip invalid data
+ continue
+
+ # Clean and join
+ new_status = ",".join([s.strip('"').strip() for s in status_list if s.strip()])
+ connection.execute(
+ sa.text("UPDATE hosts SET status_new = :status WHERE id = :host_id"),
+ {"status": new_status, "host_id": host_id}
+ )
+ except Exception as e:
+ print(f"Warning: Failed to process status for host {host_id}: {e}")
+ continue
+ else:
+ hosts_table = sa.Table('hosts', sa.MetaData(),
+ sa.Column('id', sa.Integer, primary_key=True),
+ sa.Column('status', sa.JSON),
+ sa.Column('status_new', sa.String(60)))
+
+ for host in connection.execute(sa.select(hosts_table.c.id, hosts_table.c.status)):
+ if host.status:
+ try:
+ status_list = host.status
+ if isinstance(status_list, str):
+ status_list = json.loads(status_list)
+ new_status = ",".join([s.strip('"') for s in status_list])
+ connection.execute(
+ hosts_table.update().where(hosts_table.c.id == host.id).values(status_new=new_status)
+ )
+ except Exception as e:
+ print(f"Warning: Failed to process status for host {host.id}: {e}")
+ continue
+
+ with op.batch_alter_table('hosts') as batch_op:
+ batch_op.drop_column('status')
+ batch_op.alter_column('status_new', new_column_name='status', existing_type=sa.String(60))
+
+
+def downgrade() -> None:
+ bind = op.get_bind()
+ is_postgres = bind.engine.name == 'postgresql'
+
+ if is_postgres:
+ op.add_column('hosts', sa.Column('status_old', postgresql.ARRAY(user_status), nullable=True, server_default="{}"))
+ else:
+ op.add_column('hosts', sa.Column('status_old', sa.JSON(), nullable=True, server_default=sa.text("'[]'")))
+
+ connection = op.get_bind()
+
+ if is_postgres:
+ # Use raw SQL for PostgreSQL to avoid type issues
+ result = connection.execute(sa.text("SELECT id, status FROM hosts"))
+ for row in result:
+ host_id, status_value = row
+ if status_value:
+ try:
+ # Split comma-separated string and clean values
+ cleaned_list = [s.strip().strip('"') for s in status_value.split(',') if s.strip()]
+ if cleaned_list:
+ # Format as PostgreSQL array literal
+ array_literal = "{" + ",".join(cleaned_list) + "}"
+ # Use string substitution instead of parameter binding for casting
+ connection.execute(
+ sa.text(f"UPDATE hosts SET status_old = '{array_literal}'::userstatus[] WHERE id = {host_id}")
+ )
+ except Exception as e:
+ print(f"Warning: Failed to process status for host {host_id}: {e}")
+ continue
+ else:
+ hosts_table = sa.Table('hosts', sa.MetaData(),
+ sa.Column('id', sa.Integer, primary_key=True),
+ sa.Column('status', sa.String(60)),
+ sa.Column('status_old', sa.JSON))
+
+ for host in connection.execute(sa.select(hosts_table.c.id, hosts_table.c.status)):
+ if host.status:
+ try:
+ cleaned_list = [s.strip('"') for s in host.status.split(',')]
+ new_status = json.dumps(cleaned_list)
+ connection.execute(
+ hosts_table.update().where(hosts_table.c.id == host.id).values(status_old=new_status)
+ )
+ except Exception as e:
+ print(f"Warning: Failed to process status for host {host.id}: {e}")
+ continue
+
+ with op.batch_alter_table('hosts') as batch_op:
+ batch_op.drop_column('status')
+ if is_postgres:
+ batch_op.alter_column('status_old', new_column_name='status', existing_type=postgresql.ARRAY(user_status))
+ else:
+ batch_op.alter_column('status_old', new_column_name='status', existing_type=sa.JSON)
\ No newline at end of file
diff --git a/app/db/migrations/versions/db68f8d3d40b_move_data_from_proxies_table_to_users_.py b/app/db/migrations/versions/db68f8d3d40b_move_data_from_proxies_table_to_users_.py
new file mode 100644
index 000000000..e725d1426
--- /dev/null
+++ b/app/db/migrations/versions/db68f8d3d40b_move_data_from_proxies_table_to_users_.py
@@ -0,0 +1,73 @@
+"""move data from proxies table to users table
+
+Revision ID: db68f8d3d40b
+Revises: cb99b515fbab
+Create Date: 2025-03-17 13:01:23.436120
+
+"""
+from uuid import uuid4
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.orm import Session
+
+from app.db.models import User
+from app.utils.system import random_password
+
+# revision identifiers, used by Alembic.
+revision = 'db68f8d3d40b'
+down_revision = 'cb99b515fbab'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+ db = Session(bind=bind)
+ proxy_table = sa.table(
+ 'proxies',
+ sa.column('id', sa.Integer),
+ sa.column('user_id', sa.Integer),
+ sa.column('type', sa.String),
+ sa.column('settings', sa.JSON)
+ )
+ existing_users = set(db.execute(sa.select(User.id)).scalars().all())
+
+ count_result = db.query(proxy_table).count()
+ if count_result <= 0:
+ return
+ proxies = db.execute(
+ sa.select(
+ proxy_table.c.user_id,
+ proxy_table.c.type,
+ proxy_table.c.settings
+ )
+ ).fetchall()
+ user_proxy_map = {}
+ for user_id, proxy_type, settings in proxies:
+ if user_id not in user_proxy_map:
+ user_proxy_map[user_id] = {}
+ user_proxy_map[user_id][proxy_type.lower()] = settings
+
+ for user_id, proxy in user_proxy_map.items():
+ if "vmess" not in proxy:
+ user_proxy_map[user_id]["vmess"] = {"id": str(uuid4())}
+ if "vless" not in proxy:
+ user_proxy_map[user_id]["vless"] = {"id": str(uuid4()), "flow": ""}
+ if "trojan" not in proxy:
+ user_proxy_map[user_id]["trojan"] = {"password": random_password()}
+ if "shadowsocks" not in proxy:
+ user_proxy_map[user_id]["shadowsocks"] = {
+ "password": random_password(), "method": "chacha20-ietf-poly1305"}
+ updates = [
+ {'id': user_id, 'proxy_settings': settings}
+ for user_id, settings in user_proxy_map.items()
+ if user_id in existing_users
+ ]
+
+ db.bulk_update_mappings(User, updates)
+ db.commit()
+
+
+def downgrade() -> None:
+ pass
diff --git a/app/db/migrations/versions/dd725e4d3628_fix_mysql_collations.py b/app/db/migrations/versions/dd725e4d3628_fix_mysql_collations.py
new file mode 100644
index 000000000..8c5d03873
--- /dev/null
+++ b/app/db/migrations/versions/dd725e4d3628_fix_mysql_collations.py
@@ -0,0 +1,130 @@
+"""fix_mysql_collations
+
+Revision ID: dd725e4d3628
+Revises: 5575fe410515
+Create Date: 2024-02-15 21:13:26.606283
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'dd725e4d3628'
+down_revision = '5575fe410515'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+ metadata = sa.MetaData()
+ metadata.reflect(bind=bind.engine)
+
+ if bind.engine.name == 'mysql':
+
+ constraints = []
+ for table_name, table in metadata.tables.items():
+ for constraint in table.foreign_key_constraints:
+ if (
+ isinstance(constraint, sa.sql.schema.ForeignKeyConstraint)
+ and constraint.referred_table.name == 'inbounds'
+ ):
+ for element in constraint.elements:
+ if element.column.name == 'tag':
+ constraints.append(constraint)
+
+ op.alter_column('nodes', 'name', type_=sa.String(length=256, collation='utf8mb4_bin'), nullable=True)
+ op.alter_column('users', 'username', type_=sa.String(length=34, collation='utf8mb4_bin'), nullable=True)
+
+ for cons in constraints:
+ op.drop_constraint(
+ cons.name,
+ cons.table.name,
+ type_='foreignkey'
+ )
+
+ op.alter_column(
+ 'inbounds', 'tag',
+ type_=sa.String(length=256, collation='utf8mb4_bin'),
+ nullable=False
+ )
+ op.alter_column(
+ 'template_inbounds_association', 'inbound_tag',
+ type_=sa.String(length=256, collation='utf8mb4_bin'),
+ nullable=False
+ )
+ op.alter_column(
+ 'hosts', 'inbound_tag',
+ type_=sa.String(length=256, collation='utf8mb4_bin'),
+ nullable=False
+ )
+ op.alter_column(
+ 'exclude_inbounds_association', 'inbound_tag',
+ type_=sa.String(length=256, collation='utf8mb4_bin'),
+ nullable=False
+ )
+
+ for cons in constraints:
+ op.create_foreign_key(
+ cons.name,
+ cons.table.name, cons.referred_table.name,
+ [c.name for c in cons.columns], ['tag'],
+ )
+
+
+def downgrade() -> None:
+ bind = op.get_bind()
+ metadata = sa.MetaData()
+ metadata.reflect(bind=bind.engine)
+
+ if bind.engine.name == 'mysql':
+
+ constraints = []
+ for table_name, table in metadata.tables.items():
+ for constraint in table.foreign_key_constraints:
+ if (
+ isinstance(constraint, sa.sql.schema.ForeignKeyConstraint)
+ and constraint.referred_table.name == 'inbounds'
+ ):
+ for element in constraint.elements:
+ if element.column.name == 'tag':
+ constraints.append(constraint)
+
+ op.alter_column('nodes', 'name', type_=sa.String(length=256, collation='utf8mb4_general_ci'), nullable=True)
+ op.alter_column('users', 'username', type_=sa.String(length=34, collation='utf8mb4_general_ci'), nullable=True)
+
+ for cons in constraints:
+ op.drop_constraint(
+ cons.name,
+ cons.table.name,
+ type_='foreignkey'
+ )
+
+ op.alter_column(
+ 'inbounds', 'tag',
+ type_=sa.String(length=256, collation='utf8mb4_general_ci'),
+ nullable=False
+ )
+ op.alter_column(
+ 'template_inbounds_association', 'inbound_tag',
+ type_=sa.String(length=256, collation='utf8mb4_general_ci'),
+ nullable=False
+ )
+ op.alter_column(
+ 'hosts', 'inbound_tag',
+ type_=sa.String(length=256, collation='utf8mb4_general_ci'),
+ nullable=False
+ )
+ op.alter_column(
+ 'exclude_inbounds_association', 'inbound_tag',
+ type_=sa.String(length=256, collation='utf8mb4_general_ci'),
+ nullable=False
+ )
+
+ for cons in constraints:
+ op.create_foreign_key(
+ cons.name,
+ cons.table.name, cons.referred_table.name,
+ [c.name for c in cons.columns], ['tag'],
+ )
diff --git a/app/db/migrations/versions/e3f0e888a563_rename_deactive_status_to_disabled.py b/app/db/migrations/versions/e3f0e888a563_rename_deactive_status_to_disabled.py
new file mode 100644
index 000000000..bc63fddb5
--- /dev/null
+++ b/app/db/migrations/versions/e3f0e888a563_rename_deactive_status_to_disabled.py
@@ -0,0 +1,115 @@
+"""rename deactive status to disabled
+
+Revision ID: e3f0e888a563
+Revises: 51e941ed9018
+Create Date: 2023-03-08 02:03:33.534948
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'e3f0e888a563'
+down_revision = '51e941ed9018'
+branch_labels = None
+depends_on = None
+
+# Describing of enum
+enum_name = "status"
+temp_enum_name = f"temp_{enum_name}"
+old_values = ('deactive', 'active', 'limited', 'expired')
+new_values = ('disabled', 'active', 'limited', 'expired')
+downgrade_to = ("disabled", "deactive") # on downgrade convert [0] to [1]
+old_type = sa.Enum(*old_values, name=enum_name)
+new_type = sa.Enum(*new_values, name=enum_name)
+temp_type = sa.Enum(*new_values, name=temp_enum_name)
+
+
+# Describing of table
+table_name = "users"
+column_name = "status"
+temp_table = sa.sql.table(
+ table_name,
+ sa.Column(
+ column_name,
+ new_type,
+ nullable=False
+ )
+)
+
+
+def upgrade():
+ # temp type to use instead of old one
+ temp_type.create(op.get_bind(), checkfirst=False)
+
+ # changing of column type from old enum to new one.
+ # SQLite will create temp table for this
+ with op.batch_alter_table(table_name) as batch_op:
+ batch_op.alter_column(
+ column_name,
+ existing_type=old_type,
+ type_=temp_type,
+ existing_nullable=False,
+ postgresql_using=f"{column_name}::text::{temp_enum_name}"
+ )
+
+ # remove old enum, create new enum
+ old_type.drop(op.get_bind(), checkfirst=False)
+ new_type.create(op.get_bind(), checkfirst=False)
+
+ # changing of column type from temp enum to new one.
+ # SQLite will create temp table for this
+ with op.batch_alter_table(table_name) as batch_op:
+ batch_op.alter_column(
+ column_name,
+ existing_type=temp_type,
+ type_=new_type,
+ existing_nullable=False,
+ postgresql_using=f"{column_name}::text::{enum_name}"
+ )
+
+ # remove temp enum
+ temp_type.drop(op.get_bind(), checkfirst=False)
+
+
+def downgrade():
+ # old enum don't have new value anymore.
+ # before downgrading from new enum to old one,
+ # we should replace new value from new enum with
+ # somewhat of old values from old enum
+ op.execute(
+ temp_table
+ .update()
+ .where(
+ temp_table.c.status == downgrade_to[0]
+ )
+ .values(
+ status=downgrade_to[1]
+ )
+ )
+
+ temp_type.create(op.get_bind(), checkfirst=False)
+
+ with op.batch_alter_table(table_name) as batch_op:
+ batch_op.alter_column(
+ column_name,
+ existing_type=new_type,
+ type_=temp_type,
+ existing_nullable=False,
+ postgresql_using=f"{column_name}::text::{temp_enum_name}"
+ )
+
+ new_type.drop(op.get_bind(), checkfirst=False)
+ old_type.create(op.get_bind(), checkfirst=False)
+
+ with op.batch_alter_table(table_name) as batch_op:
+ batch_op.alter_column(
+ column_name,
+ existing_type=temp_type,
+ type_=old_type,
+ existing_nullable=False,
+ postgresql_using=f"{column_name}::text::{enum_name}"
+ )
+
+ temp_type.drop(op.get_bind(), checkfirst=False)
diff --git a/app/db/migrations/versions/e410e5f15c3f_merge_fad8b1997c3a_a0d3d400ea75.py b/app/db/migrations/versions/e410e5f15c3f_merge_fad8b1997c3a_a0d3d400ea75.py
new file mode 100644
index 000000000..2316842f7
--- /dev/null
+++ b/app/db/migrations/versions/e410e5f15c3f_merge_fad8b1997c3a_a0d3d400ea75.py
@@ -0,0 +1,24 @@
+"""merge fad8b1997c3a & a0d3d400ea75
+
+Revision ID: e410e5f15c3f
+Revises: fad8b1997c3a, a0d3d400ea75
+Create Date: 2023-03-20 14:06:53.709408
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'e410e5f15c3f'
+down_revision = ('fad8b1997c3a', 'a0d3d400ea75')
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ pass
+
+
+def downgrade() -> None:
+ pass
diff --git a/app/db/migrations/versions/e422f859847f_refactor_sub_updated_at.py b/app/db/migrations/versions/e422f859847f_refactor_sub_updated_at.py
new file mode 100644
index 000000000..ec75a31da
--- /dev/null
+++ b/app/db/migrations/versions/e422f859847f_refactor_sub_updated_at.py
@@ -0,0 +1,60 @@
+"""refactor sub updated at
+
+Revision ID: e422f859847f
+Revises: 343ad7904b19
+Create Date: 2025-07-21 13:09:02.770670
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'e422f859847f'
+down_revision = '343ad7904b19'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ user_subscription_updates_table = op.create_table('user_subscription_updates',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('user_id', sa.Integer(), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
+ sa.Column('user_agent', sa.String(length=512), nullable=False),
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+
+ # Transfer data from users to user_subscription_updates
+ bind = op.get_bind()
+ session = sa.orm.Session(bind=bind)
+
+ users_table = sa.Table('users', sa.MetaData(),
+ sa.Column('id', sa.Integer),
+ sa.Column('sub_updated_at', sa.DateTime(timezone=True)),
+ sa.Column('sub_last_user_agent', sa.String(length=512)))
+
+ for user in session.query(users_table).filter(users_table.c.sub_updated_at.isnot(None)):
+ session.execute(
+ user_subscription_updates_table.insert().values(
+ user_id=user.id,
+ created_at=user.sub_updated_at,
+ user_agent=user.sub_last_user_agent or "Unknown"
+ )
+ )
+ session.commit()
+
+ with op.batch_alter_table("users") as batch_op:
+ batch_op.drop_column('sub_last_user_agent')
+ batch_op.drop_column('sub_updated_at')
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('users', sa.Column('sub_updated_at', sa.DATETIME(timezone=True), nullable=True))
+ op.add_column('users', sa.Column('sub_last_user_agent', sa.VARCHAR(length=512), nullable=True))
+ op.drop_table('user_subscription_updates')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/e4a86bc8ec7b_node_user_usages.py b/app/db/migrations/versions/e4a86bc8ec7b_node_user_usages.py
new file mode 100644
index 000000000..88d69267f
--- /dev/null
+++ b/app/db/migrations/versions/e4a86bc8ec7b_node_user_usages.py
@@ -0,0 +1,39 @@
+"""node_user_usages
+
+Revision ID: e4a86bc8ec7b
+Revises: 37692c1c9715
+Create Date: 2023-05-03 14:45:25.800476
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'e4a86bc8ec7b'
+down_revision = '37692c1c9715'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('node_user_usages',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('user_username', sa.String(34), nullable=True),
+ sa.Column('node_id', sa.Integer(), nullable=True),
+ sa.Column('used_traffic', sa.BigInteger(), nullable=True),
+ sa.ForeignKeyConstraint(['node_id'], ['nodes.id'], ),
+ sa.ForeignKeyConstraint(['user_username'], ['users.username'], ),
+ sa.PrimaryKeyConstraint('id'),
+ sa.UniqueConstraint('user_username', 'node_id')
+ )
+ op.create_index(op.f('ix_node_user_usages_id'), 'node_user_usages', ['id'], unique=False)
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_index(op.f('ix_node_user_usages_id'), table_name='node_user_usages')
+ op.drop_table('node_user_usages')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/e56f1c781e46_fix_on_hold.py b/app/db/migrations/versions/e56f1c781e46_fix_on_hold.py
new file mode 100644
index 000000000..3b1ca7b81
--- /dev/null
+++ b/app/db/migrations/versions/e56f1c781e46_fix_on_hold.py
@@ -0,0 +1,29 @@
+"""Fix on hold
+
+Revision ID: e56f1c781e46
+Revises: 714f227201a7
+Create Date: 2023-11-03 20:47:52.601783
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'e56f1c781e46'
+down_revision = '714f227201a7'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.add_column('users', sa.Column('on_hold_timeout', sa.DateTime))
+ op.add_column('users', sa.Column('on_hold_expire_duration', sa.BigInteger(), nullable=True))
+ with op.batch_alter_table('users') as batch_op:
+ batch_op.drop_column('timeout')
+
+
+def downgrade():
+ op.add_column('users', sa.Column('timeout', sa.Integer))
+ with op.batch_alter_table('users') as batch_op:
+ batch_op.drop_column('on_hold_timeout')
+ batch_op.drop_column('on_hold_expire_duration')
diff --git a/app/db/migrations/versions/e7b869e999b4_increase_length_of_the_host_and_sni_.py b/app/db/migrations/versions/e7b869e999b4_increase_length_of_the_host_and_sni_.py
new file mode 100644
index 000000000..4d55b674b
--- /dev/null
+++ b/app/db/migrations/versions/e7b869e999b4_increase_length_of_the_host_and_sni_.py
@@ -0,0 +1,41 @@
+"""increase length of the host and sni columns
+
+Revision ID: e7b869e999b4
+Revises: be0c5f840473
+Create Date: 2024-12-12 15:41:55.487859
+
+"""
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = 'e7b869e999b4'
+down_revision = 'be0c5f840473'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # Increase the length of 'sni' and 'host' columns to 1000
+ with op.batch_alter_table('hosts') as batch_op:
+ batch_op.alter_column('host',
+ existing_type=sa.String(length=256),
+ type_=sa.String(length=1000),
+ nullable=True)
+ batch_op.alter_column('sni',
+ existing_type=sa.String(length=256),
+ type_=sa.String(length=1000),
+ nullable=True)
+
+
+def downgrade():
+ # Revert the column lengths back to their original size
+ with op.batch_alter_table('hosts') as batch_op:
+ batch_op.alter_column('host',
+ existing_type=sa.String(length=1000),
+ type_=sa.String(length=256),
+ nullable=True)
+ batch_op.alter_column('sni',
+ existing_type=sa.String(length=1000),
+ type_=sa.String(length=256),
+ nullable=True)
diff --git a/app/db/migrations/versions/e91236993f1a_inbounds_table_excluded_inbounds.py b/app/db/migrations/versions/e91236993f1a_inbounds_table_excluded_inbounds.py
new file mode 100644
index 000000000..147da30c7
--- /dev/null
+++ b/app/db/migrations/versions/e91236993f1a_inbounds_table_excluded_inbounds.py
@@ -0,0 +1,41 @@
+"""inbounds table & excluded_inbounds
+
+Revision ID: e91236993f1a
+Revises: 671621870b02
+Create Date: 2023-02-05 23:21:27.828558
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'e91236993f1a'
+down_revision = '671621870b02'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('inbounds',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('tag', sa.String(256), nullable=False),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_inbounds_tag'), 'inbounds', ['tag'], unique=True)
+ op.create_table('exclude_inbounds_association',
+ sa.Column('proxy_id', sa.Integer(), nullable=True),
+ sa.Column('inbound_tag', sa.String(256), nullable=True),
+ sa.ForeignKeyConstraint(['inbound_tag'], ['inbounds.tag'], ),
+ sa.ForeignKeyConstraint(['proxy_id'], ['proxies.id'], )
+ )
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table('exclude_inbounds_association')
+ op.drop_index(op.f('ix_inbounds_tag'), table_name='inbounds')
+ op.drop_table('inbounds')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/eaa9f30f983e_add_headers_and_transports_settings_.py b/app/db/migrations/versions/eaa9f30f983e_add_headers_and_transports_settings_.py
new file mode 100644
index 000000000..53f625386
--- /dev/null
+++ b/app/db/migrations/versions/eaa9f30f983e_add_headers_and_transports_settings_.py
@@ -0,0 +1,32 @@
+"""add headers and transports_settings columns to hosts table
+
+Revision ID: eaa9f30f983e
+Revises: c5c734bd3da2
+Create Date: 2025-02-26 14:20:14.371695
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'eaa9f30f983e'
+down_revision = "c5c734bd3da2"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('hosts', sa.Column('http_headers', sa.JSON(none_as_null=True), nullable=True))
+ op.add_column('hosts', sa.Column('transport_settings', sa.JSON(none_as_null=True), nullable=True))
+ op.add_column('hosts', sa.Column('mux_settings', sa.JSON(none_as_null=True), nullable=True))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('hosts', 'mux_settings')
+ op.drop_column('hosts', 'transport_settings')
+ op.drop_column('hosts', 'http_headers')
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/ece13c4c6f65_.py b/app/db/migrations/versions/ece13c4c6f65_.py
new file mode 100644
index 000000000..8ecb99d48
--- /dev/null
+++ b/app/db/migrations/versions/ece13c4c6f65_.py
@@ -0,0 +1,24 @@
+"""empty message
+
+Revision ID: ece13c4c6f65
+Revises: d02dcfbf1517, b3378dc6de01
+Create Date: 2023-02-23 14:55:19.953972
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'ece13c4c6f65'
+down_revision = ('d02dcfbf1517', 'b3378dc6de01')
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ pass
+
+
+def downgrade() -> None:
+ pass
diff --git a/app/db/migrations/versions/f2b9caa23e16_change_expire_to_datetime.py b/app/db/migrations/versions/f2b9caa23e16_change_expire_to_datetime.py
new file mode 100644
index 000000000..b92385a2b
--- /dev/null
+++ b/app/db/migrations/versions/f2b9caa23e16_change_expire_to_datetime.py
@@ -0,0 +1,76 @@
+"""Change expire to DateTime
+
+Revision ID: f2b9caa23e16
+Revises: 4eb0a0eb835f
+Create Date: 2024-12-11 13:34:31.935829
+
+"""
+from datetime import datetime, timezone
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.orm import Session
+
+# revision identifiers, used by Alembic.
+revision = 'f2b9caa23e16'
+down_revision = '4eb0a0eb835f'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ bind = op.get_bind()
+ session = Session(bind=bind)
+
+ def expire_to_datetime(table: sa.Table):
+ for row in session.query(table.c.id, table.c.expire).all():
+ expire_timestamp = row.expire
+ expire_datetime = datetime.fromtimestamp(expire_timestamp, tz=timezone.utc) if expire_timestamp else None
+
+ session.execute(
+ table.update().where(table.c.id == row.id).values(expire_temp=expire_datetime)
+ )
+
+ # Add temporary columns with the DateTime data type
+ op.add_column('users', sa.Column('expire_temp', sa.DateTime(timezone=True), nullable=True))
+ try:
+ with op.batch_alter_table('users') as batch_op:
+ users_table = sa.Table('users', sa.MetaData(), autoload_with=bind)
+ expire_to_datetime(users_table)
+
+ batch_op.drop_column('expire')
+ batch_op.alter_column('expire_temp', new_column_name='expire', existing_type=sa.DateTime)
+
+ session.commit()
+ finally:
+ session.close()
+
+def downgrade():
+ bind = op.get_bind()
+ session = Session(bind=bind)
+
+ def expire_to_integer(table: sa.Table):
+ for row in session.query(table.c.id, table.c.expire).all():
+ expire_datetime = row.expire
+ expire_timestamp = int(expire_datetime.timestamp()) if expire_datetime else None
+
+ # Update the temporary column
+ session.execute(
+ table.update().where(table.c.id == row.id).values(expire_temp=expire_timestamp)
+ )
+
+ op.add_column('users', sa.Column('expire_temp', sa.Integer, nullable=True))
+ try:
+ # Add temporary columns with the BigInteger data type
+ with op.batch_alter_table('users') as batch_op:
+ # Fetch all rows and update the temporary column
+ users_table = sa.Table('users', sa.MetaData(), autoload_with=bind)
+ expire_to_integer(users_table)
+
+ batch_op.drop_column('expire')
+ batch_op.alter_column('expire_temp', new_column_name='expire', existing_type=sa.Integer)
+
+ session.commit()
+ finally:
+ session.close()
+
\ No newline at end of file
diff --git a/app/db/migrations/versions/f44ec4769d5d_fix_alpn_enum.py b/app/db/migrations/versions/f44ec4769d5d_fix_alpn_enum.py
new file mode 100644
index 000000000..047537e99
--- /dev/null
+++ b/app/db/migrations/versions/f44ec4769d5d_fix_alpn_enum.py
@@ -0,0 +1,48 @@
+"""fix alpn enum
+
+Revision ID: f44ec4769d5d
+Revises: 89bcb1419c66
+Create Date: 2025-03-27 13:14:48.350098
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = 'f44ec4769d5d'
+down_revision = '89bcb1419c66'
+branch_labels = None
+depends_on = None
+
+
+old_enum_name = "alpn"
+new_enum_name = "proxyhostalpn"
+values = ('none', 'h3', 'h2', 'http/1.1', 'h3,h2,http/1.1', 'h3,h2', 'h2,http/1.1')
+
+new_type = sa.Enum(*values, name=new_enum_name)
+
+
+def upgrade():
+ bind = op.get_bind()
+
+ try:
+ new_type.drop(bind, checkfirst=False)
+ except Exception:
+ pass
+
+ if bind.dialect.name == "postgresql":
+ # Rename the ENUM type directly in PostgreSQL
+ op.execute(f"ALTER TYPE {old_enum_name} RENAME TO {new_enum_name};")
+ else:
+ # For SQLite/MySQL: No-op (ENUM name is irrelevant)
+ pass
+
+def downgrade():
+ bind = op.get_bind()
+
+ if bind.dialect.name == "postgresql":
+ # Reverse the rename
+ op.execute(f"ALTER TYPE {new_enum_name} RENAME TO {old_enum_name};")
+ else:
+ # For SQLite/MySQL: No-op
+ pass
\ No newline at end of file
diff --git a/app/db/migrations/versions/fad8b1997c3a_case_insensitive_username.py b/app/db/migrations/versions/fad8b1997c3a_case_insensitive_username.py
new file mode 100644
index 000000000..b634bef84
--- /dev/null
+++ b/app/db/migrations/versions/fad8b1997c3a_case_insensitive_username.py
@@ -0,0 +1,85 @@
+"""case insensitive username
+
+Revision ID: fad8b1997c3a
+Revises: 5b84d88804a1
+Create Date: 2023-03-17 22:46:32.833004
+
+"""
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.sql import func, select, update
+
+# revision identifiers, used by Alembic.
+revision = 'fad8b1997c3a'
+down_revision = '5b84d88804a1'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+
+ if bind.engine.name == 'mysql':
+ # MySQL is case-insensitive by default, no action needed
+ pass
+
+ elif bind.engine.name == 'sqlite':
+ # Drop the existing index
+ op.drop_index('ix_users_username', table_name='users')
+
+ # Define the 'users' table for SQLAlchemy Core operations
+ users_table = sa.Table(
+ 'users',
+ sa.MetaData(),
+ sa.Column('id', sa.Integer, primary_key=True),
+ sa.Column('username', sa.String(34, collation='NOCASE'))
+ )
+
+ # Identify and resolve duplicate usernames with a case-insensitive check
+ connection = op.get_bind()
+
+ while True:
+ # Use SQLAlchemy Core to find duplicates with COLLATE NOCASE
+ duplicate_query = (
+ select(users_table.c.username, func.count())
+ .group_by(users_table.c.username.collate("NOCASE"))
+ .having(func.count() > 1)
+ )
+ duplicates = connection.execute(duplicate_query).fetchall()
+
+ if not duplicates:
+ break # No duplicates, exit the loop
+
+ # Resolve duplicates
+ for username, count in duplicates:
+ # Update rows with duplicate usernames
+ update_stmt = (
+ update(users_table)
+ .where(users_table.c.username == username)
+ .values(username=f"{username}_{count}")
+ )
+ connection.execute(update_stmt)
+
+ # Alter column to enforce case-insensitivity
+ with op.batch_alter_table('users') as batch_op:
+ batch_op.alter_column('username', type_=sa.String(length=34, collation='NOCASE'))
+
+ # Recreate the unique index
+ op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
+
+
+def downgrade() -> None:
+ bind = op.get_bind()
+
+ if bind.engine.name == 'mysql':
+ pass # MySQL remains unchanged
+
+ elif bind.engine.name == 'sqlite':
+ # Revert the column alteration
+ with op.batch_alter_table('users') as batch_op:
+ batch_op.alter_column('username', type_=sa.String(length=34))
+
+ # Drop and recreate the index without case-insensitivity
+ op.drop_index('ix_users_username', table_name='users')
+ op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
diff --git a/app/db/migrations/versions/fbfc49f01004_drop_fire_on_either_column.py b/app/db/migrations/versions/fbfc49f01004_drop_fire_on_either_column.py
new file mode 100644
index 000000000..2902acb80
--- /dev/null
+++ b/app/db/migrations/versions/fbfc49f01004_drop_fire_on_either_column.py
@@ -0,0 +1,28 @@
+"""drop fire_on_either column
+
+Revision ID: fbfc49f01004
+Revises: cf67676aaf82
+Create Date: 2025-05-26 12:32:37.431078
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'fbfc49f01004'
+down_revision = 'cf67676aaf82'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('next_plans', 'fire_on_either')
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('next_plans', sa.Column('fire_on_either', sa.BOOLEAN(), server_default=sa.text("'0'"), nullable=False))
+ # ### end Alembic commands ###
diff --git a/app/db/migrations/versions/fc01b1520e72_add_node_usages.py b/app/db/migrations/versions/fc01b1520e72_add_node_usages.py
new file mode 100644
index 000000000..2b3f58335
--- /dev/null
+++ b/app/db/migrations/versions/fc01b1520e72_add_node_usages.py
@@ -0,0 +1,73 @@
+"""add node usages
+
+Revision ID: fc01b1520e72
+Revises: c106bb40c861
+Create Date: 2023-05-07 12:08:23.331402
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'fc01b1520e72'
+down_revision = 'c106bb40c861'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('node_usages',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('node_id', sa.Integer(), nullable=True),
+ sa.Column('uplink', sa.BigInteger(), nullable=True),
+ sa.Column('downlink', sa.BigInteger(), nullable=True),
+ sa.ForeignKeyConstraint(['node_id'], ['nodes.id'], ),
+ sa.PrimaryKeyConstraint('id'),
+ sa.UniqueConstraint('created_at', 'node_id')
+ )
+ op.create_index(op.f('ix_node_usages_id'), 'node_usages', ['id'], unique=False)
+ # op.add_column('node_user_usages', sa.Column('created_at', sa.DateTime(), nullable=False))
+ # op.create_unique_constraint(None, 'node_user_usages', ['created_at', 'user_id', 'node_id'])
+ # ### end Alembic commands ###
+
+ # manual
+ op.drop_table('node_user_usages')
+ op.create_table('node_user_usages',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('user_id', sa.Integer(), nullable=True),
+ sa.Column('node_id', sa.Integer(), nullable=True),
+ sa.Column('used_traffic', sa.BigInteger(), nullable=True),
+ sa.ForeignKeyConstraint(['node_id'], ['nodes.id'], ),
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
+ sa.PrimaryKeyConstraint('id'),
+ sa.UniqueConstraint('created_at', 'user_id', 'node_id')
+ )
+ op.create_index(op.f('ix_node_user_usages_id'), 'node_user_usages', ['id'], unique=False)
+
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ # op.drop_constraint(None, 'node_user_usages', type_='unique')
+ # op.drop_column('node_user_usages', 'created_at')
+ op.drop_index(op.f('ix_node_usages_id'), table_name='node_usages')
+ op.drop_table('node_usages')
+ # ### end Alembic commands ###
+
+ # manual
+ op.drop_table('node_user_usages')
+ op.create_table('node_user_usages',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('user_username', sa.String(34), nullable=True),
+ sa.Column('node_id', sa.Integer(), nullable=True),
+ sa.Column('used_traffic', sa.BigInteger(), nullable=True),
+ sa.ForeignKeyConstraint(['node_id'], ['nodes.id'], ),
+ sa.ForeignKeyConstraint(['user_username'], ['users.username'], ),
+ sa.PrimaryKeyConstraint('id'),
+ sa.UniqueConstraint('user_username', 'node_id')
+ )
+ op.create_index(op.f('ix_node_user_usages_id'), 'node_user_usages', ['id'], unique=False)
diff --git a/app/db/migrations/versions/fe7796f840a4_remove_certficiate_from_nodes.py b/app/db/migrations/versions/fe7796f840a4_remove_certficiate_from_nodes.py
new file mode 100644
index 000000000..2da2d3bee
--- /dev/null
+++ b/app/db/migrations/versions/fe7796f840a4_remove_certficiate_from_nodes.py
@@ -0,0 +1,28 @@
+"""remove certficiate from nodes
+
+Revision ID: fe7796f840a4
+Revises: 7a0dbb8a2f65
+Create Date: 2023-10-25 15:38:32.121840
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import mysql
+
+# revision identifiers, used by Alembic.
+revision = 'fe7796f840a4'
+down_revision = '7a0dbb8a2f65'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ with op.batch_alter_table('nodes') as batch_op:
+ batch_op.drop_column('certificate')
+
+
+def downgrade() -> None:
+ with op.batch_alter_table('nodes') as batch_op:
+ batch_op.add_column(sa.Column(
+ 'certificate', mysql.VARCHAR(length=2048),
+ nullable=False, server_default=''))
diff --git a/app/db/models.py b/app/db/models.py
new file mode 100644
index 000000000..d4b3b8440
--- /dev/null
+++ b/app/db/models.py
@@ -0,0 +1,616 @@
+import os
+from datetime import datetime as dt, timezone as tz
+from enum import Enum
+from typing import Any, Dict, List, Optional
+
+from sqlalchemy import (
+ JSON,
+ BigInteger,
+ Column,
+ DateTime,
+ Enum as SQLEnum,
+ Float,
+ ForeignKey,
+ String,
+ Table,
+ UniqueConstraint,
+ and_,
+ case,
+ event,
+ func,
+ or_,
+)
+from sqlalchemy.ext.hybrid import hybrid_property
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+from sqlalchemy.sql.expression import select, text
+
+from app.db.base import Base
+from app.db.compiles_types import CaseSensitiveString, DaysDiff, EnumArray, StringArray
+
+inbounds_groups_association = Table(
+ "inbounds_groups_association",
+ Base.metadata,
+ Column("inbound_id", ForeignKey("inbounds.id"), primary_key=True),
+ Column("group_id", ForeignKey("groups.id"), primary_key=True),
+)
+
+users_groups_association = Table(
+ "users_groups_association",
+ Base.metadata,
+ Column("user_id", ForeignKey("users.id"), primary_key=True),
+ Column("groups_id", ForeignKey("groups.id"), primary_key=True),
+)
+
+
+class Admin(Base):
+ __tablename__ = "admins"
+
+ id: Mapped[int] = mapped_column(primary_key=True, init=False)
+ created_at: Mapped[dt] = mapped_column(DateTime(timezone=True), default_factory=lambda: dt.now(tz.utc), init=False)
+ username: Mapped[str] = mapped_column(String(34), unique=True, index=True)
+ hashed_password: Mapped[str] = mapped_column(String(128))
+ users: Mapped[List["User"]] = relationship(back_populates="admin", init=False, default_factory=list)
+ usage_logs: Mapped[List["AdminUsageLogs"]] = relationship(
+ back_populates="admin", init=False, default_factory=list, cascade="all, delete-orphan"
+ )
+ is_sudo: Mapped[bool] = mapped_column(default=False)
+ password_reset_at: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None)
+ telegram_id: Mapped[Optional[int]] = mapped_column(BigInteger, default=None)
+ discord_webhook: Mapped[Optional[str]] = mapped_column(String(1024), default=None)
+ discord_id: Mapped[Optional[int]] = mapped_column(BigInteger, default=None)
+ used_traffic: Mapped[int] = mapped_column(BigInteger, default=0)
+ is_disabled: Mapped[bool] = mapped_column(server_default="0", default=False)
+ sub_template: Mapped[Optional[str]] = mapped_column(String(1024), default=None)
+ sub_domain: Mapped[Optional[str]] = mapped_column(String(256), default=None)
+ profile_title: Mapped[Optional[str]] = mapped_column(String(512), default=None)
+ support_url: Mapped[Optional[str]] = mapped_column(String(1024), default=None)
+
+ @hybrid_property
+ def reseted_usage(self) -> int:
+ return int(sum([log.used_traffic_at_reset for log in self.usage_logs]))
+
+ @reseted_usage.expression
+ def reseted_usage(cls):
+ return (
+ select(func.sum(AdminUsageLogs.used_traffic_at_reset))
+ .where(AdminUsageLogs.admin_id == cls.id)
+ .label("reseted_usage")
+ )
+
+ @property
+ def lifetime_used_traffic(self) -> int:
+ return self.reseted_usage + self.used_traffic
+
+ @property
+ def total_users(self) -> int:
+ return len(self.users)
+
+
+class AdminUsageLogs(Base):
+ __tablename__ = "admin_usage_logs"
+
+ id: Mapped[int] = mapped_column(primary_key=True, init=False)
+ admin_id: Mapped[int] = mapped_column(ForeignKey("admins.id"))
+ admin: Mapped["Admin"] = relationship(back_populates="usage_logs", init=False)
+ used_traffic_at_reset: Mapped[int] = mapped_column(BigInteger, nullable=False)
+ reset_at: Mapped[dt] = mapped_column(DateTime(timezone=True), default=lambda: dt.now(tz.utc), init=False)
+
+
+class ReminderType(str, Enum):
+ expiration_date = "expiration_date"
+ data_usage = "data_usage"
+
+
+class UserStatus(str, Enum):
+ active = "active"
+ disabled = "disabled"
+ limited = "limited"
+ expired = "expired"
+ on_hold = "on_hold"
+
+
+class UserDataLimitResetStrategy(str, Enum):
+ no_reset = "no_reset"
+ day = "day"
+ week = "week"
+ month = "month"
+ year = "year"
+
+
+class User(Base):
+ __tablename__ = "users"
+
+ id: Mapped[int] = mapped_column(primary_key=True, init=False)
+ created_at: Mapped[dt] = mapped_column(DateTime(timezone=True), default_factory=lambda: dt.now(tz.utc), init=False)
+ username: Mapped[str] = mapped_column(CaseSensitiveString(128), unique=True, index=True)
+ node_usages: Mapped[List["NodeUserUsage"]] = relationship(
+ back_populates="user", cascade="all, delete-orphan", init=False,
+ )
+ notification_reminders: Mapped[List["NotificationReminder"]] = relationship(
+ back_populates="user", cascade="all, delete-orphan", init=False
+ )
+ subscription_updates: Mapped[List["UserSubscriptionUpdate"]] = relationship(
+ back_populates="user", cascade="all, delete-orphan", init=False
+ )
+ usage_logs: Mapped[List["UserUsageResetLogs"]] = relationship(back_populates="user", init=False)
+ admin: Mapped["Admin"] = relationship(back_populates="users", init=False)
+ next_plan: Mapped[Optional["NextPlan"]] = relationship(
+ uselist=False, back_populates="user", cascade="all, delete-orphan", init=False
+ )
+ groups: Mapped[List["Group"]] = relationship(secondary=users_groups_association, back_populates="users", init=False)
+ proxy_settings: Mapped[Dict[str, Any]] = mapped_column(JSON(True), server_default=text("'{}'"), default=lambda: {})
+ status: Mapped[UserStatus] = mapped_column(SQLEnum(UserStatus), default=UserStatus.active)
+ used_traffic: Mapped[int] = mapped_column(BigInteger, default=0)
+ data_limit: Mapped[Optional[int]] = mapped_column(BigInteger, default=None)
+ data_limit_reset_strategy: Mapped[UserDataLimitResetStrategy] = mapped_column(
+ SQLEnum(UserDataLimitResetStrategy),
+ default=UserDataLimitResetStrategy.no_reset,
+ )
+ _expire: Mapped[Optional[dt]] = mapped_column("expire", DateTime(timezone=True), default=None, init=False)
+ admin_id: Mapped[Optional[int]] = mapped_column(ForeignKey("admins.id"), default=None)
+ sub_revoked_at: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None)
+ note: Mapped[Optional[str]] = mapped_column(String(500), default=None)
+ online_at: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None)
+ on_hold_expire_duration: Mapped[Optional[int]] = mapped_column(BigInteger, default=None)
+ on_hold_timeout: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None)
+ auto_delete_in_days: Mapped[Optional[int]] = mapped_column(default=None)
+ edit_at: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None)
+ last_status_change: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None)
+
+ @hybrid_property
+ def expire(self) -> Optional[dt]:
+ if self._expire and self._expire.tzinfo is None:
+ return self._expire.replace(tzinfo=tz.utc)
+ return self._expire
+
+ @expire.inplace.expression
+ def expire(cls):
+ return cls._expire
+
+ @expire.setter
+ def expire(self, value: Optional[dt]):
+ self._expire = value
+
+ @hybrid_property
+ def reseted_usage(self) -> int:
+ return int(sum([log.used_traffic_at_reset for log in self.usage_logs]))
+
+ @reseted_usage.expression
+ def reseted_usage(cls):
+ return (
+ select(func.sum(UserUsageResetLogs.used_traffic_at_reset))
+ .where(UserUsageResetLogs.user_id == cls.id)
+ .label("reseted_usage")
+ )
+
+ @property
+ def lifetime_used_traffic(self) -> int:
+ return int(sum([log.used_traffic_at_reset for log in self.usage_logs]) + self.used_traffic)
+
+ @property
+ def last_traffic_reset_time(self):
+ return self.usage_logs[-1].reset_at if self.usage_logs else self.created_at
+
+ async def inbounds(self) -> list[str]:
+ """Returns a flat list of all included inbound tags across all proxies"""
+ included_tags = set()
+ for group in self.groups:
+ if group.is_disabled:
+ continue
+
+ await group.awaitable_attrs.inbounds
+ for inbound in group.inbound_tags:
+ included_tags.add(inbound)
+ return list(included_tags)
+
+ @property
+ def group_ids(self):
+ return [group.id for group in self.groups]
+
+ @property
+ def group_names(self):
+ return [group.name for group in self.groups]
+
+ @hybrid_property
+ def is_expired(self) -> bool:
+ return self.expire is not None and self.expire <= dt.now(tz.utc)
+
+ @is_expired.expression
+ def is_expired(cls):
+ return and_(cls.expire.isnot(None), cls.expire <= func.current_timestamp())
+
+ @hybrid_property
+ def is_limited(self) -> bool:
+ return self.data_limit is not None and self.data_limit > 0 and self.data_limit <= self.used_traffic
+
+ @is_limited.expression
+ def is_limited(cls):
+ return and_(cls.data_limit.isnot(None), cls.data_limit > 0, cls.data_limit <= cls.used_traffic)
+
+ @hybrid_property
+ def become_online(self) -> bool:
+ now = dt.now(tz.utc)
+
+ # Check if online_at is set and greater than or equal to base time
+ if self.online_at:
+ base_time = (self.edit_at or self.created_at).replace(tzinfo=tz.utc)
+ return self.online_at.replace(tzinfo=tz.utc) >= base_time
+
+ # Check if on_hold_timeout has passed
+ if self.on_hold_timeout and self.on_hold_timeout.replace(tzinfo=tz.utc) <= now:
+ return True
+
+ return False
+
+ @become_online.expression
+ def become_online(cls):
+ now = func.current_timestamp()
+ base_time = case((cls.edit_at.isnot(None), cls.edit_at), else_=cls.created_at)
+
+ return or_(
+ # online_at condition
+ and_(cls.online_at.isnot(None), cls.online_at >= base_time),
+ # on_hold_timeout condition
+ and_(cls.online_at.is_(None), cls.on_hold_timeout.isnot(None), cls.on_hold_timeout <= now),
+ )
+
+ @hybrid_property
+ def usage_percentage(self) -> float:
+ if not self.data_limit or self.data_limit == 0:
+ return 0.0
+ return (self.used_traffic * 100) / self.data_limit
+
+ @usage_percentage.expression
+ def usage_percentage(cls):
+ return case(
+ (and_(cls.data_limit.isnot(None), cls.data_limit > 0), (cls.used_traffic * 100.0) / cls.data_limit),
+ else_=0.0,
+ )
+
+ @hybrid_property
+ def days_left(self) -> int:
+ if not self.expire:
+ return 0
+ remaining_days = (self.expire.replace(tzinfo=tz.utc) - dt.now(tz.utc)).days
+ return max(remaining_days, 0)
+
+ @days_left.expression
+ def days_left(cls):
+ return case((cls.expire.isnot(None), func.floor(DaysDiff())), else_=0)
+
+
+class UserSubscriptionUpdate(Base):
+ __tablename__ = "user_subscription_updates"
+
+ id: Mapped[int] = mapped_column(primary_key=True, init=False)
+ user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
+ user: Mapped["User"] = relationship(back_populates="subscription_updates", init=False)
+ created_at: Mapped[dt] = mapped_column(DateTime(timezone=True), default_factory=lambda: dt.now(tz.utc), init=False)
+ user_agent: Mapped[str] = mapped_column(String(512))
+
+
+template_group_association = Table(
+ "template_group_association",
+ Base.metadata,
+ Column("user_template_id", ForeignKey("user_templates.id")),
+ Column("group_id", ForeignKey("groups.id")),
+)
+
+
+class NextPlan(Base):
+ __tablename__ = "next_plans"
+
+ id: Mapped[int] = mapped_column(primary_key=True, init=False)
+ user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
+ user_template_id: Mapped[Optional[int]] = mapped_column(ForeignKey("user_templates.id"))
+ user: Mapped["User"] = relationship(back_populates="next_plan", init=False)
+ user_template: Mapped[Optional["UserTemplate"]] = relationship(back_populates="next_plans", init=False)
+ data_limit: Mapped[int] = mapped_column(BigInteger, default=0)
+ expire: Mapped[Optional[int]] = mapped_column(default=None)
+ add_remaining_traffic: Mapped[bool] = mapped_column(default=False, server_default="0")
+
+
+class UserStatusCreate(str, Enum):
+ active = "active"
+ on_hold = "on_hold"
+
+
+class UserTemplate(Base):
+ __tablename__ = "user_templates"
+
+ id: Mapped[int] = mapped_column(primary_key=True, init=False)
+ name: Mapped[str] = mapped_column(String(64), unique=True)
+ username_prefix: Mapped[Optional[str]] = mapped_column(String(20))
+ username_suffix: Mapped[Optional[str]] = mapped_column(String(20))
+ extra_settings: Mapped[Optional[Dict]] = mapped_column(JSON(True))
+ next_plans: Mapped[List["NextPlan"]] = relationship(
+ back_populates="user_template", cascade="all, delete-orphan", init=False
+ )
+ groups: Mapped[List["Group"]] = relationship(secondary=template_group_association, back_populates="templates")
+ data_limit: Mapped[int] = mapped_column(BigInteger, default=0)
+ expire_duration: Mapped[int] = mapped_column(BigInteger, default=0) # in seconds
+ on_hold_timeout: Mapped[Optional[int]] = mapped_column(default=None)
+ status: Mapped[UserStatusCreate] = mapped_column(SQLEnum(UserStatusCreate), default=UserStatusCreate.active)
+ reset_usages: Mapped[bool] = mapped_column(default=False, server_default="0")
+ data_limit_reset_strategy: Mapped[UserDataLimitResetStrategy] = mapped_column(
+ SQLEnum(UserDataLimitResetStrategy),
+ default=UserDataLimitResetStrategy.no_reset,
+ server_default="no_reset",
+ )
+ is_disabled: Mapped[bool] = mapped_column(server_default="0", default=False)
+
+ @property
+ def group_ids(self):
+ return [group.id for group in self.groups]
+
+
+class UserUsageResetLogs(Base):
+ __tablename__ = "user_usage_logs"
+
+ id: Mapped[int] = mapped_column(primary_key=True, init=False)
+ user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"), nullable=True)
+ user: Mapped["User"] = relationship(back_populates="usage_logs", init=False)
+ used_traffic_at_reset: Mapped[int] = mapped_column(BigInteger, nullable=False)
+ reset_at: Mapped[dt] = mapped_column(DateTime(timezone=True), default=lambda: dt.now(tz.utc), init=False)
+
+
+class ProxyInbound(Base):
+ __tablename__ = "inbounds"
+
+ id: Mapped[int] = mapped_column(primary_key=True, init=False)
+ tag: Mapped[str] = mapped_column(String(256), unique=True, index=True)
+ hosts: Mapped[List["ProxyHost"]] = relationship(back_populates="inbound", init=False)
+ groups: Mapped[List["Group"]] = relationship(
+ secondary=inbounds_groups_association, back_populates="inbounds", init=False
+ )
+
+
+@event.listens_for(ProxyInbound, "after_delete")
+def delete_association_rows(mapper, connection, target):
+ connection.execute(
+ inbounds_groups_association.delete().where(inbounds_groups_association.c.inbound_id == target.id)
+ )
+
+
+class ProxyHostSecurity(str, Enum):
+ inbound_default = "inbound_default"
+ none = "none"
+ tls = "tls"
+
+
+class ProxyHostALPN(str, Enum):
+ h1 = "http/1.1"
+ h2 = "h2"
+ h3 = "h3"
+
+
+ProxyHostFingerprint = Enum(
+ "ProxyHostFingerprint",
+ {
+ "none": "",
+ "chrome": "chrome",
+ "firefox": "firefox",
+ "safari": "safari",
+ "ios": "ios",
+ "android": "android",
+ "edge": "edge",
+ "360": "360",
+ "qq": "qq",
+ "random": "random",
+ "randomized": "randomized",
+ "randomizednoalpn": "randomizednoalpn",
+ "unsafe": "unsafe",
+ },
+)
+
+
+class ProxyHost(Base):
+ __tablename__ = "hosts"
+
+ id: Mapped[int] = mapped_column(primary_key=True, init=False)
+ remark: Mapped[str] = mapped_column(String(256), unique=False, nullable=False)
+ port: Mapped[Optional[int]] = mapped_column(nullable=True)
+ path: Mapped[Optional[str]] = mapped_column(String(256), unique=False, nullable=True)
+ priority: Mapped[int] = mapped_column(nullable=False)
+ allowinsecure: Mapped[Optional[bool]] = mapped_column(nullable=True)
+ address: Mapped[set[str]] = mapped_column(StringArray(256), default_factory=set, unique=False, nullable=False)
+ sni: Mapped[Optional[set[str]]] = mapped_column(StringArray(1000), default_factory=set, unique=False, nullable=True)
+ host: Mapped[Optional[set[str]]] = mapped_column(
+ StringArray(1000), default_factory=set, unique=False, nullable=True
+ )
+ inbound_tag: Mapped[Optional[str]] = mapped_column(
+ String(256), ForeignKey("inbounds.tag", ondelete="SET NULL", onupdate="CASCADE"), nullable=True, init=False
+ )
+ inbound: Mapped[Optional["ProxyInbound"]] = relationship(back_populates="hosts", init=False)
+ security: Mapped[ProxyHostSecurity] = mapped_column(
+ SQLEnum(ProxyHostSecurity),
+ unique=False,
+ default=ProxyHostSecurity.inbound_default,
+ )
+ alpn: Mapped[Optional[list[ProxyHostALPN]]] = mapped_column(EnumArray(ProxyHostALPN, 14), default=list)
+ fingerprint: Mapped[ProxyHostFingerprint] = mapped_column(
+ SQLEnum(ProxyHostFingerprint),
+ unique=False,
+ default=ProxyHostSecurity.none,
+ server_default=ProxyHostSecurity.none.name,
+ )
+ is_disabled: Mapped[Optional[bool]] = mapped_column(default=False)
+ fragment_settings: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON(none_as_null=True), default=None)
+ noise_settings: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON(none_as_null=True), default=None)
+ random_user_agent: Mapped[bool] = mapped_column(default=False, server_default="0")
+ use_sni_as_host: Mapped[bool] = mapped_column(default=False, server_default="0")
+ http_headers: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON(none_as_null=True), default=None)
+ transport_settings: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON(none_as_null=True), default=None)
+ mux_settings: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON(none_as_null=True), default=None)
+ status: Mapped[Optional[list[UserStatus]]] = mapped_column(
+ EnumArray(UserStatus, 60), default=list, server_default=""
+ )
+ ech_config_list: Mapped[Optional[str]] = mapped_column(String(512), default=None)
+
+
+class System(Base):
+ __tablename__ = "system"
+
+ id: Mapped[int] = mapped_column(primary_key=True, init=False)
+ uplink: Mapped[int] = mapped_column(BigInteger, default=0)
+ downlink: Mapped[int] = mapped_column(BigInteger, default=0)
+
+
+class JWT(Base):
+ __tablename__ = "jwt"
+
+ id: Mapped[int] = mapped_column(primary_key=True, init=False)
+ secret_key: Mapped[str] = mapped_column(String(64), default=lambda: os.urandom(32).hex())
+
+
+class NodeConnectionType(str, Enum):
+ grpc = "grpc"
+ rest = "rest"
+
+
+class NodeStatus(str, Enum):
+ connected = "connected"
+ connecting = "connecting"
+ error = "error"
+ disabled = "disabled"
+
+
+class Node(Base):
+ __tablename__ = "nodes"
+
+ id: Mapped[int] = mapped_column(primary_key=True, init=False)
+ created_at: Mapped[dt] = mapped_column(DateTime(timezone=True), default_factory=lambda: dt.now(tz.utc), init=False)
+ name: Mapped[str] = mapped_column(CaseSensitiveString(256), unique=True)
+ address: Mapped[str] = mapped_column(String(256), unique=False, nullable=False)
+ port: Mapped[int] = mapped_column(unique=False, nullable=False)
+ xray_version: Mapped[Optional[str]] = mapped_column(String(32), nullable=True, init=False)
+ message: Mapped[Optional[str]] = mapped_column(String(1024), nullable=True, init=False)
+ server_ca: Mapped[str] = mapped_column(String(2048), nullable=False)
+ api_key: Mapped[str | None] = mapped_column(String(36))
+ node_version: Mapped[Optional[str]] = mapped_column(String(32), nullable=True, init=False)
+ core_config_id: Mapped[Optional[int]] = mapped_column(
+ ForeignKey("core_configs.id", ondelete="SET NULL"), nullable=True
+ )
+ user_usages: Mapped[List["NodeUserUsage"]] = relationship(
+ back_populates="node", cascade="all, delete-orphan", init=False
+ )
+ usages: Mapped[List["NodeUsage"]] = relationship(back_populates="node", cascade="all, delete-orphan", init=False)
+ core_config: Mapped[Optional["CoreConfig"]] = relationship("CoreConfig", init=False)
+ stats: Mapped[List["NodeStat"]] = relationship(back_populates="node", cascade="all, delete-orphan", init=False)
+ status: Mapped[NodeStatus] = mapped_column(SQLEnum(NodeStatus), default=NodeStatus.connecting)
+ last_status_change: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), init=False)
+ uplink: Mapped[int] = mapped_column(BigInteger, default=0)
+ downlink: Mapped[int] = mapped_column(BigInteger, default=0)
+ usage_coefficient: Mapped[float] = mapped_column(Float, server_default=text("1.0"), default=1)
+ connection_type: Mapped[NodeConnectionType] = mapped_column(
+ SQLEnum(NodeConnectionType),
+ unique=False,
+ default=NodeConnectionType.grpc,
+ server_default=NodeConnectionType.grpc.name,
+ )
+ keep_alive: Mapped[int] = mapped_column(unique=False, default=0)
+ max_logs: Mapped[int] = mapped_column(BigInteger, unique=False, default=1000, server_default=text("1000"))
+ gather_logs: Mapped[bool] = mapped_column(default=True, server_default="1")
+
+
+class NodeUserUsage(Base):
+ __tablename__ = "node_user_usages"
+ __table_args__ = (UniqueConstraint("created_at", "user_id", "node_id"),)
+
+ id: Mapped[int] = mapped_column(primary_key=True, init=False)
+ created_at: Mapped[dt] = mapped_column(DateTime(timezone=True), unique=False) # one hour per record
+ user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
+ user: Mapped["User"] = relationship(back_populates="node_usages", init=False)
+ node_id: Mapped[Optional[int]] = mapped_column(ForeignKey("nodes.id"))
+ node: Mapped["Node"] = relationship(back_populates="user_usages", init=False)
+ used_traffic: Mapped[int] = mapped_column(BigInteger, default=0)
+
+
+class NodeUsage(Base):
+ __tablename__ = "node_usages"
+ __table_args__ = (UniqueConstraint("created_at", "node_id"),)
+
+ id: Mapped[int] = mapped_column(primary_key=True, init=False)
+ created_at: Mapped[dt] = mapped_column(DateTime(timezone=True), unique=False) # one hour per record
+ node_id: Mapped[Optional[int]] = mapped_column(ForeignKey("nodes.id"))
+ node: Mapped["Node"] = relationship(back_populates="usages", init=False)
+ uplink: Mapped[int] = mapped_column(BigInteger, default=0)
+ downlink: Mapped[int] = mapped_column(BigInteger, default=0)
+
+
+class NotificationReminder(Base):
+ __tablename__ = "notification_reminders"
+
+ id: Mapped[int] = mapped_column(primary_key=True, init=False)
+ created_at: Mapped[dt] = mapped_column(DateTime(timezone=True), default_factory=lambda: dt.now(tz.utc), init=False)
+ user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
+ user: Mapped["User"] = relationship(back_populates="notification_reminders", init=False)
+ type: Mapped[ReminderType] = mapped_column(SQLEnum(ReminderType))
+ threshold: Mapped[Optional[int]] = mapped_column(default=None)
+ expires_at: Mapped[Optional[dt]] = mapped_column(DateTime(timezone=True), default=None)
+
+
+class Group(Base):
+ __tablename__ = "groups"
+
+ id: Mapped[int] = mapped_column(primary_key=True, init=False)
+ name: Mapped[str] = mapped_column(String(64))
+ users: Mapped[List["User"]] = relationship(secondary=users_groups_association, back_populates="groups", init=False)
+ inbounds: Mapped[List["ProxyInbound"]] = relationship(
+ secondary=inbounds_groups_association, back_populates="groups"
+ )
+ templates: Mapped[List["UserTemplate"]] = relationship(
+ secondary=template_group_association, back_populates="groups", init=False
+ )
+ is_disabled: Mapped[bool] = mapped_column(server_default="0", default=False)
+
+ @property
+ def inbound_ids(self):
+ return [inbound.id for inbound in self.inbounds]
+
+ @property
+ def inbound_tags(self):
+ return [inbound.tag for inbound in self.inbounds]
+
+ @property
+ def total_users(self):
+ return len(self.users)
+
+
+class CoreConfig(Base):
+ __tablename__ = "core_configs"
+
+ id: Mapped[int] = mapped_column(primary_key=True, init=False)
+ created_at: Mapped[dt] = mapped_column(DateTime(timezone=True), default_factory=lambda: dt.now(tz.utc), init=False)
+ name: Mapped[str] = mapped_column(String(256))
+ config: Mapped[Dict[str, Any]] = mapped_column(JSON(False))
+ exclude_inbound_tags: Mapped[Optional[set[str]]] = mapped_column(StringArray(2048), default_factory=set)
+ fallbacks_inbound_tags: Mapped[Optional[set[str]]] = mapped_column(StringArray(2048), default_factory=set)
+
+
+class NodeStat(Base):
+ __tablename__ = "node_stats"
+
+ id: Mapped[int] = mapped_column(primary_key=True, init=False)
+ created_at: Mapped[dt] = mapped_column(DateTime(timezone=True), default_factory=lambda: dt.now(tz.utc), init=False)
+ node_id: Mapped[int] = mapped_column(ForeignKey("nodes.id"))
+ node: Mapped["Node"] = relationship(back_populates="stats", init=False)
+ mem_total: Mapped[int] = mapped_column(BigInteger, unique=False, nullable=False)
+ mem_used: Mapped[int] = mapped_column(BigInteger, unique=False, nullable=False)
+ cpu_cores: Mapped[int] = mapped_column(unique=False, nullable=False)
+ cpu_usage: Mapped[float] = mapped_column(unique=False, nullable=False)
+ incoming_bandwidth_speed: Mapped[int] = mapped_column(BigInteger, unique=False, nullable=False)
+ outgoing_bandwidth_speed: Mapped[int] = mapped_column(BigInteger, unique=False, nullable=False)
+
+
+class Settings(Base):
+ __tablename__ = "settings"
+
+ id: Mapped[int] = mapped_column(primary_key=True, init=False)
+ telegram: Mapped[dict] = mapped_column(JSON())
+ discord: Mapped[dict] = mapped_column(JSON())
+ webhook: Mapped[dict] = mapped_column(JSON())
+ notification_settings: Mapped[dict] = mapped_column(JSON())
+ notification_enable: Mapped[dict] = mapped_column(JSON())
+ subscription: Mapped[dict] = mapped_column(JSON())
+ general: Mapped[dict] = mapped_column(JSON())
diff --git a/app/jobs/__init__.py b/app/jobs/__init__.py
new file mode 100644
index 000000000..bc7f1935d
--- /dev/null
+++ b/app/jobs/__init__.py
@@ -0,0 +1,13 @@
+import glob
+import importlib.util
+from os.path import basename, dirname, join
+
+modules = glob.glob(join(dirname(__file__), "*.py"))
+
+for file in modules:
+ name = basename(file).replace(".py", "")
+ if name.startswith("_"):
+ continue
+
+ spec = importlib.util.spec_from_file_location(name, file)
+ spec.loader.exec_module(importlib.util.module_from_spec(spec))
diff --git a/app/jobs/cleanup_subscription_updates.py b/app/jobs/cleanup_subscription_updates.py
new file mode 100644
index 000000000..c657fe5c2
--- /dev/null
+++ b/app/jobs/cleanup_subscription_updates.py
@@ -0,0 +1,81 @@
+from sqlalchemy import func, select, delete
+
+from app import scheduler
+from app.db import GetDB
+from app.db.base import DATABASE_DIALECT
+from app.db.models import UserSubscriptionUpdate
+from app.utils.logger import get_logger
+from config import USER_SUBSCRIPTION_CLIENTS_LIMIT, JOB_CLEANUP_SUBSCRIPTION_UPDATES_INTERVAL
+
+logger = get_logger("jobs")
+
+
+async def cleanup_user_subscription_updates():
+ """Clean up excess user subscription updates."""
+
+ async with GetDB() as db:
+ # First query: Find users that have more than the limit
+ users_with_excess = await db.execute(
+ select(UserSubscriptionUpdate.user_id)
+ .group_by(UserSubscriptionUpdate.user_id)
+ .having(func.count(UserSubscriptionUpdate.id) > USER_SUBSCRIPTION_CLIENTS_LIMIT)
+ )
+ user_ids = [row.user_id for row in users_with_excess]
+
+ if not user_ids:
+ logger.info("No users with excess subscription updates")
+ return
+
+ # Second query: Use different approaches based on database type
+ if DATABASE_DIALECT == "mysql":
+ # MySQL/MariaDB: Use correlated subquery without LIMIT
+ total_deleted = 0
+ for user_id in user_ids:
+ # Get IDs to keep (most recent N records)
+ keep_ids_result = await db.execute(
+ select(UserSubscriptionUpdate.id)
+ .where(UserSubscriptionUpdate.user_id == user_id)
+ .order_by(UserSubscriptionUpdate.created_at.desc())
+ .limit(USER_SUBSCRIPTION_CLIENTS_LIMIT)
+ )
+ keep_ids = [row.id for row in keep_ids_result]
+
+ if keep_ids:
+ # Delete records not in keep list
+ result = await db.execute(
+ delete(UserSubscriptionUpdate).where(
+ UserSubscriptionUpdate.user_id == user_id, UserSubscriptionUpdate.id.not_in(keep_ids)
+ )
+ )
+ total_deleted += result.rowcount
+
+ logger.info(f"Cleaned up {total_deleted} old subscription updates")
+ else:
+ # SQLite and PostgreSQL: Use original approach with LIMIT in subquery
+ sub = UserSubscriptionUpdate.__table__.alias("sub")
+
+ keep_subquery = (
+ select(sub.c.id)
+ .where(sub.c.user_id == UserSubscriptionUpdate.user_id)
+ .order_by(sub.c.created_at.desc())
+ .limit(USER_SUBSCRIPTION_CLIENTS_LIMIT)
+ )
+
+ result = await db.execute(
+ delete(UserSubscriptionUpdate).where(
+ UserSubscriptionUpdate.user_id.in_(user_ids), UserSubscriptionUpdate.id.not_in(keep_subquery)
+ )
+ )
+ logger.info(f"Cleaned up {result.rowcount} old subscription updates")
+
+ await db.commit()
+
+
+if USER_SUBSCRIPTION_CLIENTS_LIMIT and USER_SUBSCRIPTION_CLIENTS_LIMIT >= 0:
+ # Schedule the cleanup job to run every few minutes
+ scheduler.add_job(
+ cleanup_user_subscription_updates,
+ "interval",
+ seconds=JOB_CLEANUP_SUBSCRIPTION_UPDATES_INTERVAL,
+ max_instances=1,
+ )
diff --git a/app/jobs/dependencies.py b/app/jobs/dependencies.py
new file mode 100644
index 000000000..992d74668
--- /dev/null
+++ b/app/jobs/dependencies.py
@@ -0,0 +1,3 @@
+from app.models.admin import AdminDetails
+
+SYSTEM_ADMIN = AdminDetails(username="system", is_sudo=True)
diff --git a/app/jobs/inboud.py b/app/jobs/inboud.py
new file mode 100644
index 000000000..ba6b15d53
--- /dev/null
+++ b/app/jobs/inboud.py
@@ -0,0 +1,26 @@
+from app import scheduler
+from app.db import GetDB
+from app.db.crud.host import get_inbounds_not_in_tags, remove_inbounds
+from app.core.manager import core_manager
+from app.utils.logger import get_logger
+from config import JOB_REMOVE_OLD_INBOUNDS_INTERVAL
+
+
+logger = get_logger("jobs")
+
+
+async def remove_old_inbounds():
+ in_use_inbounds = await core_manager.get_inbounds()
+
+ async with GetDB() as db:
+ old_inbounds = await get_inbounds_not_in_tags(db, in_use_inbounds)
+
+ await remove_inbounds(db, old_inbounds)
+
+ for inbound in old_inbounds:
+ logger.info(f"inbound {inbound.tag} removed.")
+
+
+scheduler.add_job(
+ remove_old_inbounds, "interval", seconds=JOB_REMOVE_OLD_INBOUNDS_INTERVAL, coalesce=True, max_instances=1
+)
diff --git a/app/jobs/node_checker.py b/app/jobs/node_checker.py
new file mode 100644
index 000000000..8fe847f6c
--- /dev/null
+++ b/app/jobs/node_checker.py
@@ -0,0 +1,101 @@
+import asyncio
+
+from PasarGuardNodeBridge import NodeAPIError, PasarGuardNode, Health
+
+from app import on_shutdown, on_startup, scheduler
+from app.db import GetDB
+from app.db.models import Node, NodeStatus
+from app.db.crud.node import get_nodes
+from app.node import node_manager
+from app.utils.logger import get_logger
+from app.operation.node import NodeOperation
+from app.operation import OperatorType
+
+from config import JOB_CORE_HEALTH_CHECK_INTERVAL
+
+
+node_operator = NodeOperation(operator_type=OperatorType.SYSTEM)
+logger = get_logger("node-checker")
+
+
+async def node_health_check():
+ async def check_node(id: int, node: PasarGuardNode):
+ try:
+ await node.get_backend_stats(timeout=8)
+
+ await node_operator.update_node_status(
+ id, NodeStatus.connected, await node.core_version(), await node.node_version()
+ )
+ except NodeAPIError as e:
+ if e.code > -3:
+ await node_operator.update_node_status(id, NodeStatus.error, err=e.detail)
+ if e.code > 0:
+ await node_operator.connect_node(node_id=id)
+
+ async def check_health(db_node: Node, node: PasarGuardNode):
+ if node is None:
+ return
+ try:
+ health = await asyncio.wait_for(node.get_health(), timeout=10)
+ except (asyncio.TimeoutError, NodeAPIError):
+ await node_operator.update_node_status(db_node.id, NodeStatus.error, err="Get health timeout")
+
+ if db_node.status in (NodeStatus.connecting, NodeStatus.error) and health is Health.HEALTHY:
+ await node_operator.update_node_status(db_node.id, NodeStatus.connected)
+
+ elif db_node.status in (NodeStatus.connecting, NodeStatus.error) and health in (
+ Health.NOT_CONNECTED,
+ Health.BROKEN,
+ ):
+ await node_operator.connect_node(node_id=db_node.id)
+
+ elif db_node.status == NodeStatus.connected and health is not Health.HEALTHY:
+ await check_node(db_node.id, node)
+
+ async with GetDB() as db:
+ db_nodes = await get_nodes(db=db, enabled=True)
+ dict_nodes = await node_manager.get_nodes()
+
+ check_tasks = [check_health(db_node, dict_nodes[db_node.id]) for db_node in db_nodes]
+ await asyncio.gather(*check_tasks, return_exceptions=True)
+
+
+@on_startup
+async def initialize_nodes():
+ logger.info("Starting main and nodes' cores...")
+
+ async with GetDB() as db:
+ db_nodes = await get_nodes(db=db, enabled=True)
+
+ async def start_node(node: Node):
+ try:
+ await node_manager.update_node(node)
+ except NodeAPIError as e:
+ await node_operator.update_node_status(node.id, NodeStatus.error, err=e.detail)
+ return
+
+ await node_operator.connect_node(node_id=node.id)
+
+ start_tasks = [start_node(node=db_node) for db_node in db_nodes]
+
+ await asyncio.gather(*start_tasks)
+
+ logger.info("All nodes' cores have been started.")
+
+ scheduler.add_job(
+ node_health_check, "interval", seconds=JOB_CORE_HEALTH_CHECK_INTERVAL, coalesce=True, max_instances=1
+ )
+
+
+@on_shutdown
+async def shutdown_nodes():
+ logger.info("Stopping nodes' cores...")
+
+ nodes: dict[int, PasarGuardNode] = await node_manager.get_nodes()
+
+ stop_tasks = [node.stop() for node in nodes.values()]
+
+ # Run all tasks concurrently and wait for them to complete
+ await asyncio.gather(*stop_tasks, return_exceptions=True)
+
+ logger.info("All nodes' cores have been stopped.")
diff --git a/app/jobs/node_stats.py b/app/jobs/node_stats.py
new file mode 100644
index 000000000..17aef86e7
--- /dev/null
+++ b/app/jobs/node_stats.py
@@ -0,0 +1,52 @@
+import asyncio
+
+from PasarGuardNodeBridge import PasarGuardNode
+
+from app import scheduler
+from app.db import GetDB
+from app.db.models import NodeStat
+from app.node import node_manager
+from app.utils.logger import get_logger
+from config import ENABLE_RECORDING_NODES_STATS, JOB_GHATER_NODES_STATS_INTERVAL
+
+
+logger = get_logger("jobs")
+
+
+async def get_stat(id: int, node: PasarGuardNode) -> NodeStat:
+ try:
+ stats = await node.get_system_stats()
+ except Exception:
+ return
+
+ if not stats:
+ return
+
+ return NodeStat(
+ node_id=id,
+ mem_total=stats.mem_total,
+ mem_used=stats.mem_used,
+ cpu_cores=stats.cpu_cores,
+ cpu_usage=stats.cpu_usage,
+ incoming_bandwidth_speed=stats.incoming_bandwidth_speed,
+ outgoing_bandwidth_speed=stats.outgoing_bandwidth_speed,
+ )
+
+
+async def gather_nodes_stats():
+ nodes = await node_manager.get_healthy_nodes()
+
+ stats_list = await asyncio.gather(*[get_stat(id, node) for id, node in nodes])
+
+ valid_stats = [stat for stat in stats_list if stat is not None]
+
+ if valid_stats:
+ async with GetDB() as db:
+ db.add_all(valid_stats)
+ await db.commit()
+
+
+if ENABLE_RECORDING_NODES_STATS:
+ scheduler.add_job(
+ gather_nodes_stats, "interval", seconds=JOB_GHATER_NODES_STATS_INTERVAL, coalesce=True, max_instances=1
+ )
diff --git a/app/jobs/record_usages.py b/app/jobs/record_usages.py
new file mode 100644
index 000000000..eb066f8d6
--- /dev/null
+++ b/app/jobs/record_usages.py
@@ -0,0 +1,305 @@
+import asyncio
+from collections import defaultdict
+from datetime import datetime as dt, timezone as tz
+from operator import attrgetter
+
+from PasarGuardNodeBridge import PasarGuardNode, NodeAPIError
+from PasarGuardNodeBridge.common.service_pb2 import StatType
+from sqlalchemy import and_, bindparam, insert, select, update
+from sqlalchemy.exc import DatabaseError, OperationalError
+from sqlalchemy.sql.expression import Insert
+
+from app import scheduler
+from app.db import AsyncSession, GetDB
+from app.db.models import Admin, NodeUsage, NodeUserUsage, System, User
+from app.node import node_manager as node_manager
+from app.utils.logger import get_logger
+from config import (
+ DISABLE_RECORDING_NODE_USAGE,
+ JOB_RECORD_NODE_USAGES_INTERVAL,
+ JOB_RECORD_USER_USAGES_INTERVAL,
+)
+
+logger = get_logger("record-usages")
+
+
+async def safe_execute(db: AsyncSession, stmt, params=None, max_retries: int = 3):
+ """
+ Safely execute database operations with deadlock and connection handling.
+
+ Args:
+ db (AsyncSession): Async database session
+ stmt: SQLAlchemy statement to execute
+ params (list[dict], optional): Parameters for the statement
+ max_retries (int, optional): Maximum number of retry attempts
+ """
+ dialect = db.bind.dialect.name
+
+ # MySQL-specific IGNORE prefix
+ if dialect == "mysql" and isinstance(stmt, Insert):
+ stmt = stmt.prefix_with("IGNORE")
+
+ for attempt in range(max_retries):
+ try:
+ await (await db.connection()).execute(stmt, params)
+ await db.commit()
+ return
+ except (OperationalError, DatabaseError) as err:
+ # Rollback the session
+ await db.rollback()
+
+ # Specific error code handling
+ if dialect == "mysql":
+ # MySQL deadlock (Error 1213)
+ if err.orig.args[0] == 1213 and attempt < max_retries - 1:
+ continue
+ elif dialect == "postgresql":
+ # PostgreSQL deadlock (Error 40P01)
+ if err.orig.code == "40P01" and attempt < max_retries - 1:
+ continue
+ elif dialect == "sqlite":
+ # SQLite database locked error
+ if "database is locked" in str(err) and attempt < max_retries - 1:
+ await asyncio.sleep(0.1 * (attempt + 1)) # Exponential backoff
+ continue
+
+ # If we've exhausted retries or it's not a retriable error, raise
+ raise
+
+
+async def record_user_stats(params: list[dict], node_id: int, usage_coefficient: int = 1):
+ """
+ Record user statistics for a specific node.
+
+ Args:
+ params (list[dict]): User statistic parameters
+ node_id (int): Node identifier
+ usage_coefficient (int, optional): usage multiplier
+ """
+ if not params:
+ return
+
+ created_at = dt.now(tz.utc).replace(minute=0, second=0, microsecond=0)
+
+ async with GetDB() as db:
+ # Find existing user entries for this node and time
+ select_stmt = select(NodeUserUsage.user_id).where(
+ and_(NodeUserUsage.node_id == node_id, NodeUserUsage.created_at == created_at)
+ )
+ existing_users = set((await db.execute(select_stmt)).scalars().all())
+
+ # Prepare new user entries
+ new_users = [{"uid": int(p["uid"])} for p in params if int(p["uid"]) not in existing_users]
+
+ # Insert missing user entries
+ if new_users:
+ insert_stmt = insert(NodeUserUsage).values(
+ user_id=bindparam("uid"), created_at=created_at, node_id=node_id, used_traffic=0
+ )
+ await safe_execute(db, insert_stmt, new_users)
+
+ # Update user traffic - ensure uid is converted to int
+ update_params = [{"uid": int(p["uid"]), "value": p["value"]} for p in params]
+ update_stmt = (
+ update(NodeUserUsage)
+ .values(used_traffic=NodeUserUsage.used_traffic + bindparam("value") * usage_coefficient)
+ .where(
+ and_(
+ NodeUserUsage.user_id == bindparam("uid"),
+ NodeUserUsage.node_id == node_id,
+ NodeUserUsage.created_at == created_at,
+ )
+ )
+ )
+ await safe_execute(db, update_stmt, update_params)
+
+
+async def record_node_stats(params: dict, node_id: int):
+ """
+ Record node-level statistics.
+
+ Args:
+ params (Dict): Node statistic parameters
+ node_id (int): Node identifier
+ """
+ if not params:
+ return
+
+ created_at = dt.now(tz.utc).replace(minute=0, second=0, microsecond=0)
+
+ async with GetDB() as db:
+ # Check if node usage entry exists
+ select_stmt = select(NodeUsage.node_id).where(
+ and_(NodeUsage.node_id == node_id, NodeUsage.created_at == created_at)
+ )
+ result = await db.execute(select_stmt)
+ node_exists = result.scalar() is not None # Correctly check if row exists
+
+ # Insert node usage entry if not exists
+ if not node_exists:
+ insert_stmt = insert(NodeUsage).values(created_at=created_at, node_id=node_id, uplink=0, downlink=0)
+ await safe_execute(db, insert_stmt)
+
+ # Update node usage
+ update_stmt = (
+ update(NodeUsage)
+ .values(uplink=NodeUsage.uplink + bindparam("up"), downlink=NodeUsage.downlink + bindparam("down"))
+ .where(and_(NodeUsage.node_id == node_id, NodeUsage.created_at == created_at))
+ )
+ await safe_execute(db, update_stmt, params)
+
+
+async def get_users_stats(node: PasarGuardNode):
+ try:
+ stats_respons = await node.get_stats(stat_type=StatType.UsersStat, reset=True, timeout=30)
+ params = defaultdict(int)
+ for stat in filter(attrgetter("value"), stats_respons.stats):
+ params[stat.name.split(".", 1)[0]] += stat.value
+ params = list({"uid": int(uid), "value": value} for uid, value in params.items())
+ return params
+ except NodeAPIError as e:
+ logger.error("Failed to get outbounds stats, error: %s", e.detail)
+ return []
+ except Exception as e:
+ logger.error("Failed to get outbounds stats, unknown error: %s", e)
+ return []
+
+
+async def get_outbounds_stats(node: PasarGuardNode):
+ try:
+ stats_respons = await node.get_stats(stat_type=StatType.Outbounds, reset=True, timeout=10)
+ params = [
+ {"up": stat.value, "down": 0} if stat.link == "uplink" else {"up": 0, "down": stat.value}
+ for stat in filter(attrgetter("value"), stats_respons.stats)
+ ]
+ return params
+ except NodeAPIError as e:
+ logger.error("Failed to get outbounds stats, error: %s", e.detail)
+ return []
+ except Exception as e:
+ logger.error("Failed to get outbounds stats, unknown error: %s", e)
+ return []
+
+
+async def calculate_admin_usage(users_usage: list) -> dict:
+ if not users_usage:
+ return {}
+
+ # Get unique user IDs from users_usage
+ uids = {int(user_usage["uid"]) for user_usage in users_usage}
+
+ async with GetDB() as db:
+ # Query only relevant users' admin IDs
+ stmt = select(User.id, User.admin_id).where(User.id.in_(uids))
+ result = await db.execute(stmt)
+ user_admin_pairs = result.fetchall()
+
+ user_admin_map = {uid: admin_id for uid, admin_id in user_admin_pairs}
+
+ admin_usage = defaultdict(int)
+ for user_usage in users_usage:
+ admin_id = user_admin_map.get(int(user_usage["uid"]))
+ if admin_id:
+ admin_usage[admin_id] += user_usage["value"]
+
+ return admin_usage
+
+
+async def calculate_users_usage(api_params: dict, usage_coefficient: dict) -> list:
+ """Calculate aggregated user usage across all nodes with coefficients applied"""
+ users_usage = defaultdict(int)
+
+ # Process all node data in a single pass
+ for node_id, params in api_params.items():
+ coeff = usage_coefficient.get(node_id, 1)
+ # Use generator to avoid intermediate lists
+ node_usage = ((int(param["uid"]), int(param["value"] * coeff)) for param in params)
+ for uid, value in node_usage:
+ users_usage[uid] += value
+
+ return [{"uid": uid, "value": value} for uid, value in users_usage.items()]
+
+
+async def record_user_usages():
+ nodes: tuple[int, PasarGuardNode] = await node_manager.get_healthy_nodes()
+
+ node_data = await asyncio.gather(*[asyncio.create_task(node.get_extra()) for _, node in nodes])
+ usage_coefficient = {node_id: data.get("usage_coefficient", 1) for (node_id, _), data in zip(nodes, node_data)}
+
+ stats_tasks = [asyncio.create_task(get_users_stats(node)) for _, node in nodes]
+ await asyncio.gather(*stats_tasks)
+
+ api_params = {nodes[i][0]: task.result() for i, task in enumerate(stats_tasks)}
+
+ users_usage = await calculate_users_usage(api_params, usage_coefficient)
+ if not users_usage:
+ return
+
+ async with GetDB() as db:
+ user_stmt = (
+ update(User)
+ .where(User.id == bindparam("uid"))
+ .values(used_traffic=User.used_traffic + bindparam("value"), online_at=dt.now(tz.utc))
+ .execution_options(synchronize_session=False)
+ )
+ await safe_execute(db, user_stmt, users_usage)
+
+ admin_usage = await calculate_admin_usage(users_usage)
+ if admin_usage:
+ admin_data = [{"admin_id": aid, "value": val} for aid, val in admin_usage.items()]
+ admin_stmt = (
+ update(Admin)
+ .where(Admin.id == bindparam("admin_id"))
+ .values(used_traffic=Admin.used_traffic + bindparam("value"))
+ .execution_options(synchronize_session=False)
+ )
+ await safe_execute(db, admin_stmt, admin_data)
+
+ if DISABLE_RECORDING_NODE_USAGE:
+ return
+
+ record_tasks = [
+ asyncio.create_task(
+ record_user_stats(params=api_params[node_id], node_id=node_id, usage_coefficient=usage_coefficient[node_id])
+ )
+ for node_id in api_params
+ ]
+ await asyncio.gather(*record_tasks)
+
+
+async def record_node_usages():
+ # Create tasks for all nodes
+ tasks = {
+ node_id: asyncio.create_task(get_outbounds_stats(node))
+ for node_id, node in await node_manager.get_healthy_nodes()
+ }
+
+ await asyncio.gather(*tasks.values())
+
+ api_params = {node_id: task.result() for node_id, task in tasks.items()}
+
+ total_up = sum(sum(param["up"] for param in params) for params in api_params.values())
+ total_down = sum(sum(param["down"] for param in params) for params in api_params.values())
+
+ if not (total_up or total_down):
+ return
+
+ async with GetDB() as db:
+ system_update_stmt = update(System).values(
+ uplink=System.uplink + total_up, downlink=System.downlink + total_down
+ )
+ await safe_execute(db, system_update_stmt)
+
+ if DISABLE_RECORDING_NODE_USAGE:
+ return
+
+ record_tasks = [asyncio.create_task(record_node_stats(params, node_id)) for node_id, params in api_params.items()]
+ await asyncio.gather(*record_tasks)
+
+
+scheduler.add_job(
+ record_user_usages, "interval", seconds=JOB_RECORD_USER_USAGES_INTERVAL, coalesce=True, max_instances=1
+)
+scheduler.add_job(
+ record_node_usages, "interval", seconds=JOB_RECORD_NODE_USAGES_INTERVAL, coalesce=True, max_instances=1
+)
diff --git a/app/jobs/remove_expired_users.py b/app/jobs/remove_expired_users.py
new file mode 100644
index 000000000..2e177549a
--- /dev/null
+++ b/app/jobs/remove_expired_users.py
@@ -0,0 +1,26 @@
+import asyncio
+
+from app import scheduler
+from app.db import GetDB
+from app.db.crud.user import autodelete_expired_users
+from app import notification
+from app.jobs.dependencies import SYSTEM_ADMIN
+from app.utils.logger import get_logger
+from config import USER_AUTODELETE_INCLUDE_LIMITED_ACCOUNTS, JOB_REMOVE_EXPIRED_USERS_INTERVAL
+
+
+logger = get_logger("jobs")
+
+
+async def remove_expired_users():
+ async with GetDB() as db:
+ deleted_users = await autodelete_expired_users(db, USER_AUTODELETE_INCLUDE_LIMITED_ACCOUNTS)
+
+ for user in deleted_users:
+ asyncio.create_task(notification.remove_user(user=user, by=SYSTEM_ADMIN))
+ logger.info(f"User `{user.username}` has been deleted due to expiration.")
+
+
+scheduler.add_job(
+ remove_expired_users, "interval", coalesce=True, seconds=JOB_REMOVE_EXPIRED_USERS_INTERVAL, max_instances=1
+)
diff --git a/app/jobs/reset_user_data_usage.py b/app/jobs/reset_user_data_usage.py
new file mode 100644
index 000000000..4a3b705d2
--- /dev/null
+++ b/app/jobs/reset_user_data_usage.py
@@ -0,0 +1,47 @@
+import asyncio
+from datetime import datetime as dt, timedelta as td, timezone as tz
+
+from app import scheduler
+from app.db import GetDB
+from app.db.models import UserStatus
+from app.db.crud.user import get_users_to_reset_data_usage, bulk_reset_user_data_usage
+from app.models.user import UserNotificationResponse
+from app import notification
+from app.core.manager import core_manager
+from app.node import node_manager
+from app.jobs.dependencies import SYSTEM_ADMIN
+from app.utils.logger import get_logger
+from config import JOB_RESET_USER_DATA_USAGE_INTERVAL
+
+logger = get_logger("jobs")
+
+
+async def reset_data_usage():
+ async with GetDB() as db:
+ users = await get_users_to_reset_data_usage(db)
+ old_statuses = {user.id: user.status for user in users}
+
+ updated_users = await bulk_reset_user_data_usage(db, users)
+
+ for db_user in updated_users:
+ user = UserNotificationResponse.model_validate(db_user)
+ asyncio.create_task(notification.reset_user_data_usage(user, SYSTEM_ADMIN))
+
+ if old_statuses.get(user.id) != user.status:
+ asyncio.create_task(notification.user_status_change(user, SYSTEM_ADMIN))
+
+ # make user active if limited on usage reset
+ if user.status == UserStatus.active:
+ asyncio.create_task(node_manager.update_user(user=user, inbounds=await core_manager.get_inbounds()))
+
+ logger.info(f'User data usage reset for User "{user.username}"')
+
+
+scheduler.add_job(
+ reset_data_usage,
+ "interval",
+ seconds=JOB_RESET_USER_DATA_USAGE_INTERVAL,
+ coalesce=True,
+ start_date=dt.now(tz.utc) + td(minutes=1),
+ max_instances=1,
+)
diff --git a/app/jobs/review_users.py b/app/jobs/review_users.py
new file mode 100644
index 000000000..769dc510a
--- /dev/null
+++ b/app/jobs/review_users.py
@@ -0,0 +1,203 @@
+import asyncio
+from datetime import datetime as dt, timedelta as td, timezone as tz
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app import notification, scheduler
+from app.db import GetDB
+from app.db.models import User, UserStatus, ReminderType
+from app.db.crud.user import (
+ get_active_to_expire_users,
+ get_active_to_limited_users,
+ get_days_left_reached_users,
+ get_on_hold_to_active_users,
+ get_usage_percentage_reached_users,
+ reset_user_by_next,
+ start_users_expire,
+ update_users_status,
+ bulk_create_notification_reminders,
+)
+from app.jobs.dependencies import SYSTEM_ADMIN
+from app.models.settings import Webhook
+from app.models.user import UserNotificationResponse
+from app.node import node_manager as node_manager
+from app.settings import webhook_settings
+from app.utils.logger import get_logger
+from config import JOB_REVIEW_USERS_INTERVAL
+
+logger = get_logger("review-users")
+
+
+async def reset_user_by_next_report(db: AsyncSession, db_user: User):
+ db_user = await reset_user_by_next(db, db_user)
+ inbounds = await db_user.inbounds()
+ user = UserNotificationResponse.model_validate(db_user)
+
+ await node_manager.update_user(user, inbounds)
+ asyncio.create_task(notification.user_data_reset_by_next(user, SYSTEM_ADMIN))
+
+ logger.info(f'User "{db_user.username}" next plan activated')
+
+
+async def change_status(db: AsyncSession, db_user: User, status: UserStatus):
+ user = UserNotificationResponse.model_validate(db_user)
+ if user.status is not UserStatus.active:
+ await node_manager.remove_user(user)
+ asyncio.create_task(notification.user_status_change(user, SYSTEM_ADMIN))
+
+ logger.info(f'User "{db_user.username}" status changed to {status.value}')
+
+ if db_user.next_plan and db_user.status is not UserStatus.active:
+ await reset_user_by_next_report(db, db_user)
+
+
+async def expire_users_job():
+ async with GetDB() as db:
+ if expired_users := await get_active_to_expire_users(db):
+ updated_users = await update_users_status(db, expired_users, UserStatus.expired)
+ for user in updated_users:
+ await change_status(db, user, UserStatus.expired)
+
+
+async def limit_users_job():
+ async with GetDB() as db:
+ if limited_users := await get_active_to_limited_users(db):
+ updated_users = await update_users_status(db, limited_users, UserStatus.limited)
+ for user in updated_users:
+ await change_status(db, user, UserStatus.limited)
+
+
+async def on_hold_to_active_users_job():
+ async with GetDB() as db:
+ if on_hold_users := await get_on_hold_to_active_users(db):
+ updated_users = await start_users_expire(db, on_hold_users)
+ for user in updated_users:
+ await change_status(db, user, UserStatus.active)
+
+
+async def usage_percent_notification_job():
+ settings: Webhook = await webhook_settings()
+ if not settings.enable:
+ return
+ async with GetDB() as db:
+ for percent in settings.usage_percent:
+ users = await get_usage_percentage_reached_users(db, percent)
+
+ # Prepare webhook notifications first
+ webhook_tasks = []
+ reminder_data = []
+
+ for user in users:
+ usage_percentage = user.usage_percentage
+ user_model = UserNotificationResponse.model_validate(user)
+
+ # Queue webhook notification
+ webhook_tasks.append(
+ notification.wh.notify(
+ notification.wh.ReachedUsagePercent(
+ username=user_model.username, user=user_model, used_percent=usage_percentage
+ )
+ )
+ )
+
+ # Prepare reminder data for bulk insert
+ reminder_data.append(
+ {
+ "user_id": user.id,
+ "type": ReminderType.data_usage,
+ "threshold": percent,
+ "expires_at": user.expire if user.expire else None,
+ }
+ )
+
+ # Send webhooks first
+ if webhook_tasks:
+ await asyncio.gather(*webhook_tasks, return_exceptions=True)
+
+ # Bulk create notification reminders
+ if reminder_data:
+ await bulk_create_notification_reminders(db, reminder_data)
+
+
+async def days_left_notification_job():
+ settings: Webhook = await webhook_settings()
+ if not settings.enable:
+ return
+ async with GetDB() as db:
+ for days in settings.days_left:
+ users = await get_days_left_reached_users(db, days)
+
+ # Prepare webhook notifications first
+ webhook_tasks = []
+ reminder_data = []
+
+ for user in users:
+ days_left = user.days_left
+ user_model = UserNotificationResponse.model_validate(user)
+
+ # Queue webhook notification
+ webhook_tasks.append(
+ notification.wh.notify(
+ notification.wh.ReachedDaysLeft(
+ username=user_model.username, user=user_model, days_left=days_left
+ )
+ )
+ )
+
+ # Prepare reminder data for bulk insert
+ reminder_data.append(
+ {
+ "user_id": user.id,
+ "type": ReminderType.expiration_date,
+ "threshold": days,
+ "expires_at": user.expire,
+ }
+ )
+ # Bulk create notification reminders
+ if reminder_data:
+ await bulk_create_notification_reminders(db, reminder_data)
+
+ # Send webhooks first
+ if webhook_tasks:
+ await asyncio.gather(*webhook_tasks, return_exceptions=True)
+
+
+now = dt.now(tz.utc)
+interval = int(JOB_REVIEW_USERS_INTERVAL / 5)
+
+# Register each job separately
+scheduler.add_job(
+ expire_users_job, "interval", seconds=JOB_REVIEW_USERS_INTERVAL, coalesce=True, max_instances=1, start_date=now
+)
+scheduler.add_job(
+ limit_users_job,
+ "interval",
+ seconds=JOB_REVIEW_USERS_INTERVAL,
+ coalesce=True,
+ max_instances=1,
+ start_date=now + td(seconds=interval),
+)
+scheduler.add_job(
+ on_hold_to_active_users_job,
+ "interval",
+ seconds=JOB_REVIEW_USERS_INTERVAL,
+ coalesce=True,
+ max_instances=1,
+ start_date=now + td(seconds=interval * 2),
+)
+scheduler.add_job(
+ usage_percent_notification_job,
+ "interval",
+ seconds=JOB_REVIEW_USERS_INTERVAL,
+ coalesce=True,
+ max_instances=1,
+ start_date=now + td(seconds=interval * 3),
+)
+scheduler.add_job(
+ days_left_notification_job,
+ "interval",
+ seconds=JOB_REVIEW_USERS_INTERVAL,
+ coalesce=True,
+ max_instances=1,
+ start_date=now + td(seconds=interval * 4),
+)
diff --git a/app/jobs/send_notifications.py b/app/jobs/send_notifications.py
new file mode 100644
index 000000000..03e55f2ef
--- /dev/null
+++ b/app/jobs/send_notifications.py
@@ -0,0 +1,115 @@
+import asyncio
+from datetime import datetime as dt, timezone as tz, timedelta as td
+
+import httpx
+from sqlalchemy import delete
+from fastapi.encoders import jsonable_encoder
+
+from app import on_shutdown, scheduler
+from app.db import GetDB
+from app.db.models import NotificationReminder
+from app.models.settings import Webhook
+from app.settings import webhook_settings
+from app.notification.webhook import queue
+from app.utils.logger import get_logger
+from config import JOB_SEND_NOTIFICATIONS_INTERVAL
+
+logger = get_logger("send-notification")
+
+
+async def send_to_all_webhooks(client, notification, webhooks):
+ """
+ Send the notification to all webhooks concurrently.
+ Returns True if at least one webhook succeeds.
+ """
+
+ async def send_one(webhook):
+ webhook_headers = {"x-webhook-secret": webhook.secret} if webhook.secret else None
+ try:
+ r = await client.post(webhook.url, json=jsonable_encoder(notification), headers=webhook_headers)
+ if r.status_code in [200, 201, 202, 204]:
+ return True
+ else:
+ logger.error(f"Webhook {webhook.url} failed: {r.status_code} - {r.text}")
+ except Exception as err:
+ logger.error(f"Webhook {webhook.url} exception: {err}")
+ return False
+
+ results = await asyncio.gather(*(send_one(webhook) for webhook in webhooks))
+ return any(results)
+
+
+async def send_notifications():
+ settings: Webhook = await webhook_settings()
+ if not settings.enable:
+ return
+
+ logger.debug("Processing notifications batch")
+
+ processed = 0
+ failed_to_requeue = []
+ current_time = dt.now(tz.utc).timestamp()
+
+ try:
+ async with httpx.AsyncClient(http2=True, timeout=httpx.Timeout(10), proxy=settings.proxy_url) as client:
+ while True:
+ try:
+ notification = queue.get_nowait()
+ except asyncio.QueueEmpty:
+ break
+
+ try:
+ if notification.tries >= settings.recurrent:
+ continue
+
+ if notification.send_at > current_time:
+ failed_to_requeue.append(notification)
+ continue
+
+ # Send to all webhooks in settings
+ success = await send_to_all_webhooks(client, notification, settings.webhooks)
+
+ if not success:
+ notification.tries += 1
+ if notification.tries < settings.recurrent:
+ notification.send_at = current_time + settings.timeout
+ failed_to_requeue.append(notification)
+
+ processed += 1
+
+ except Exception:
+ failed_to_requeue.append(notification)
+
+ finally:
+ # Don't requeue failed items if webhook disabled
+ if not settings.enable:
+ return
+
+ # Requeue failed items at the end
+ for notif in failed_to_requeue:
+ await queue.put(notif)
+
+ if processed or failed_to_requeue:
+ logger.debug(f"Processed {processed} notifications, requeued {len(failed_to_requeue)}")
+
+
+async def delete_expired_reminders() -> None:
+ async with GetDB() as db:
+ # Get current UTC time and convert to naive datetime
+ now_utc = dt.now(tz=tz.utc)
+ now_naive = now_utc.replace(tzinfo=None)
+
+ result = await db.execute(delete(NotificationReminder).where(NotificationReminder.expires_at < now_naive))
+ logger.info(f"Cleaned up {result.rowcount} expired reminders")
+
+
+async def send_pending_notifications_before_shutdown():
+ logger.info("Webhook final flush before shutdown")
+ await send_notifications()
+
+
+scheduler.add_job(
+ send_notifications, "interval", seconds=JOB_SEND_NOTIFICATIONS_INTERVAL, max_instances=1, coalesce=True
+)
+scheduler.add_job(delete_expired_reminders, "interval", hours=6, start_date=dt.now(tz.utc) + td(minutes=5))
+on_shutdown(send_pending_notifications_before_shutdown)
diff --git a/app/models/admin.py b/app/models/admin.py
new file mode 100644
index 000000000..168fd264c
--- /dev/null
+++ b/app/models/admin.py
@@ -0,0 +1,99 @@
+from passlib.context import CryptContext
+from pydantic import BaseModel, ConfigDict, field_validator
+
+from .validators import DiscordValidator, NumericValidatorMixin, PasswordValidator
+
+pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
+
+
+class Token(BaseModel):
+ access_token: str
+ token_type: str = "bearer"
+
+
+class AdminBase(BaseModel):
+ """Minimal admin model containing only the username."""
+
+ username: str
+
+ model_config = ConfigDict(from_attributes=True)
+
+
+class AdminContactInfo(AdminBase):
+ """Base model containing the core admin identification fields."""
+
+ username: str
+ telegram_id: int | None = None
+ discord_webhook: str | None = None
+ sub_domain: str | None = None
+
+ model_config = ConfigDict(from_attributes=True)
+
+
+class AdminDetails(AdminContactInfo):
+ """Complete admin model with all fields for database representation and API responses."""
+
+ id: int | None = None
+ is_sudo: bool
+ total_users: int = 0
+ used_traffic: int = 0
+ is_disabled: bool = False
+ discord_id: int | None = None
+ sub_template: str | None = None
+ profile_title: str | None = None
+ support_url: str | None = None
+ lifetime_used_traffic: int | None = None
+
+ model_config = ConfigDict(from_attributes=True)
+
+ @field_validator("used_traffic", mode="before")
+ def cast_to_int(cls, v):
+ return NumericValidatorMixin.cast_to_int(v)
+
+
+class AdminModify(BaseModel):
+ password: str | None = None
+ is_sudo: bool
+ telegram_id: int | None = None
+ discord_webhook: str | None = None
+ discord_id: int | None = None
+ is_disabled: bool | None = None
+ sub_template: str | None = None
+ sub_domain: str | None = None
+ profile_title: str | None = None
+ support_url: str | None = None
+
+ @property
+ def hashed_password(self):
+ if self.password:
+ return pwd_context.hash(self.password)
+
+ @field_validator("discord_webhook")
+ @classmethod
+ def validate_discord_webhook(cls, value):
+ return DiscordValidator.validate_webhook(value)
+
+ @field_validator("password")
+ @classmethod
+ def validate_password(cls, value: str | None):
+ return PasswordValidator.validate_password(value)
+
+
+class AdminCreate(AdminModify):
+ """Model for creating new admin accounts requiring username and password."""
+
+ username: str
+ password: str
+
+
+class AdminInDB(AdminDetails):
+ hashed_password: str
+
+ def verify_password(self, plain_password):
+ return pwd_context.verify(plain_password, self.hashed_password)
+
+
+class AdminValidationResult(BaseModel):
+ username: str
+ is_sudo: bool
+ is_disabled: bool
diff --git a/app/models/core.py b/app/models/core.py
new file mode 100644
index 000000000..ab320c678
--- /dev/null
+++ b/app/models/core.py
@@ -0,0 +1,54 @@
+from datetime import datetime as dt
+
+from pydantic import BaseModel, ConfigDict, Field, field_validator
+
+from .validators import StringArrayValidator
+
+
+class CoreBase(BaseModel):
+ name: str
+ config: dict
+ exclude_inbound_tags: set[str]
+ fallbacks_inbound_tags: set[str]
+
+ @property
+ def exclude_tags(self) -> str:
+ if self.exclude_inbound_tags:
+ return ",".join(self.exclude_inbound_tags)
+ return ""
+
+ @property
+ def fallback_tags(self) -> str:
+ if self.fallbacks_inbound_tags:
+ return ",".join(self.fallbacks_inbound_tags)
+ return ""
+
+
+class CoreCreate(CoreBase):
+ name: str | None = Field(max_length=256, default=None)
+ exclude_inbound_tags: set | None = Field(default=None)
+ fallbacks_inbound_tags: set | None = Field(default=None)
+
+ @field_validator("config", mode="before")
+ def validate_config(cls, v: dict) -> dict:
+ if not v:
+ raise ValueError("config dictionary cannot be empty")
+ return v
+
+ @field_validator("exclude_inbound_tags", "fallbacks_inbound_tags", mode="after")
+ def validate_sets(cls, v: set):
+ return StringArrayValidator.len_check(v, 2048)
+
+
+class CoreResponse(CoreBase):
+ id: int
+ created_at: dt
+
+ model_config = ConfigDict(from_attributes=True)
+
+
+class CoreResponseList(BaseModel):
+ count: int
+ cores: list[CoreResponse] = []
+
+ model_config = ConfigDict(from_attributes=True)
diff --git a/app/models/group.py b/app/models/group.py
new file mode 100644
index 000000000..0ea98c215
--- /dev/null
+++ b/app/models/group.py
@@ -0,0 +1,46 @@
+from pydantic import BaseModel, ConfigDict, Field, field_validator
+
+from .validators import ListValidator
+
+
+class Group(BaseModel):
+ name: str = Field(min_length=3, max_length=64)
+ inbound_tags: list[str] | None = []
+ is_disabled: bool = False
+
+ model_config = ConfigDict(from_attributes=True)
+
+
+class GroupCreate(Group):
+ inbound_tags: list[str]
+
+ @field_validator("inbound_tags", mode="after")
+ @classmethod
+ def inbound_tags_validator(cls, v):
+ return ListValidator.not_null_list(v, "inbound")
+
+
+class GroupModify(Group):
+ @field_validator("inbound_tags", mode="after")
+ @classmethod
+ def inbound_tags_validator(cls, v):
+ return ListValidator.nullable_list(v, "inbound")
+
+
+class GroupResponse(Group):
+ id: int
+ total_users: int = 0
+
+ model_config = ConfigDict(from_attributes=True)
+
+
+class GroupsResponse(BaseModel):
+ groups: list[GroupResponse]
+ total: int
+
+
+class BulkGroup(BaseModel):
+ group_ids: set[int]
+ has_group_ids: set[int] = Field(default_factory=set)
+ admins: set[int] = Field(default_factory=set)
+ users: set[int] = Field(default_factory=set)
diff --git a/app/models/host.py b/app/models/host.py
new file mode 100644
index 000000000..be4828587
--- /dev/null
+++ b/app/models/host.py
@@ -0,0 +1,240 @@
+from enum import Enum
+
+from pydantic import BaseModel, ConfigDict, Field, field_validator
+
+from app.db.models import ProxyHostALPN, ProxyHostFingerprint, ProxyHostSecurity, UserStatus
+
+from .validators import ListValidator, StringArrayValidator
+
+
+class XHttpModes(str, Enum):
+ auto = "auto"
+ packet_up = "packet-up"
+ stream_up = "stream-up"
+ stream_one = "stream-one"
+
+
+class MultiplexProtocol(str, Enum):
+ smux = "smux"
+ yamux = "yamux"
+ h2mux = "h2mux"
+
+
+class XUDP(str, Enum):
+ reject = "reject"
+ allow = "allow"
+ skip = "skip"
+
+
+class XrayFragmentSettings(BaseModel):
+ packets: str = Field(pattern=r"^(:?tlshello|[\d-]{1,16})$")
+ length: str = Field(pattern=r"^[\d-]{1,16}$")
+ interval: str = Field(pattern=r"^[\d-]{1,16}$")
+
+
+class SingBoxFragmentSettings(BaseModel):
+ fragment: bool = Field(default=False)
+ fragment_fallback_delay: str = Field("", pattern=r"^$|^\d+ms$")
+ record_fragment: bool = Field(default=False)
+
+
+class FragmentSettings(BaseModel):
+ xray: XrayFragmentSettings | None = Field(default=None)
+ sing_box: SingBoxFragmentSettings | None = Field(default=None)
+
+
+class XrayNoiseSettings(BaseModel):
+ type: str = Field(pattern=r"^(:?rand|str|base64|hex)$")
+ packet: str
+ delay: str = Field(pattern=r"^\d{1,16}(-\d{1,16})?$")
+ apply_to: str = Field(default="ip", pattern=r"ip|ipv4|ipv6")
+
+
+class NoiseSettings(BaseModel):
+ xray: list[XrayNoiseSettings] | None = Field(default=None)
+
+
+class XMuxSettings(BaseModel):
+ max_concurrency: str | int | None = Field(
+ None, pattern=r"^\d{1,16}(-\d{1,16})?$", serialization_alias="maxConcurrency"
+ )
+ max_connections: str | int | None = Field(
+ None, pattern=r"^\d{1,16}(-\d{1,16})?$", serialization_alias="maxConnections"
+ )
+ c_max_reuse_times: str | int | None = Field(
+ None, pattern=r"^\d{1,16}(-\d{1,16})?$", serialization_alias="cMaxReuseTimes"
+ )
+ c_max_lifetime: str | int | None = Field(
+ None, pattern=r"^\d{1,16}(-\d{1,16})?$", serialization_alias="cMaxLifetime"
+ )
+ h_max_request_times: str | int | None = Field(
+ None, pattern=r"^\d{1,16}(-\d{1,16})?$", serialization_alias="hMaxRequestTimes"
+ )
+ h_keep_alive_period: int | None = Field(None, serialization_alias="hKeepAlivePeriod")
+
+
+class XHttpSettings(BaseModel):
+ mode: XHttpModes = Field(default=XHttpModes.auto)
+ no_grpc_header: bool | None = Field(default=None)
+ x_padding_bytes: str | int | None = Field(default=None, pattern=r"^\d{1,16}(-\d{1,16})?$")
+ sc_max_each_post_bytes: str | int | None = Field(default=None, pattern=r"^\d{1,16}(-\d{1,16})?$")
+ sc_min_posts_interval_ms: str | int | None = Field(default=None, pattern=r"^\d{1,16}(-\d{1,16})?$")
+ xmux: XMuxSettings | None = Field(default=None)
+ download_settings: int | None = Field(default=None)
+
+
+class HTTPBase(BaseModel):
+ version: str = Field("1.1", pattern=r"^(1(?:\.0|\.1)|2\.0|3\.0)$")
+ headers: dict[str, list[str]] | None = Field(default=None)
+
+
+class HTTPResponse(HTTPBase):
+ status: str = Field("200", pattern=r"^[1-5]\d{2}$")
+ reason: str = Field(
+ "OK",
+ pattern=r"^(?i)(?:OK|Created|Accepted|Non-Authoritative Information|No Content|Reset Content|Partial Content|Multiple Choices|Moved Permanently|Found|See Other|Not Modified|Use Proxy|Temporary Redirect|Permanent Redirect|Bad Request|Unauthorized|Payment Required|Forbidden|Not Found|Method Not Allowed|Not Acceptable|Proxy Authentication Required|Request Timeout|Conflict|Gone|Length Required|Precondition Failed|Payload Too Large|URI Too Long|Unsupported Media Type|Range Not Satisfiable|Expectation Failed|I'm a teapot|Misdirected Request|Unprocessable Entity|Locked|Failed Dependency|Too Early|Upgrade Required|Precondition Required|Too Many Requests|Request Header Fields Too Large|Unavailable For Legal Reasons|Internal Server Error|Not Implemented|Bad Gateway|Service Unavailable|Gateway Timeout|HTTP Version Not Supported)$",
+ )
+
+
+class HTTPRequest(HTTPBase):
+ method: str = Field("GET", pattern=r"^(GET|POST|PUT|DELETE|HEAD|OPTIONS|PATCH|TRACE|CONNECT)$")
+
+
+class TcpSettings(BaseModel):
+ header: str = Field("none", pattern=r"^(:?none|http)$")
+ request: HTTPRequest | None = Field(default=None)
+ response: HTTPResponse | None = Field(default=None)
+
+
+class WebSocketSettings(BaseModel):
+ heartbeatPeriod: int | None = Field(default=None)
+
+
+class KCPSettings(BaseModel):
+ header: str = Field(default="none", pattern=r"^(:?none|srtp|utp|wechat-video|dtls|wireguard)$")
+ mtu: int | None = Field(default=None)
+ tti: int | None = Field(default=None)
+ uplink_capacity: int | None = Field(default=None)
+ downlink_capacity: int | None = Field(default=None)
+ congestion: int | None = Field(default=None)
+ read_buffer_size: int | None = Field(default=None)
+ write_buffer_size: int | None = Field(default=None)
+
+
+class GRPCSettings(BaseModel):
+ multi_mode: bool | None = Field(default=None)
+ idle_timeout: int | None = Field(default=None)
+ health_check_timeout: int | None = Field(default=None)
+ permit_without_stream: int | None = Field(default=None)
+ initial_windows_size: int | None = Field(default=None)
+
+
+class Brutal(BaseModel):
+ enable: bool = Field(default=False)
+ up_mbps: int
+ down_mbps: int
+
+
+class SingBoxMuxSettings(BaseModel):
+ enable: bool = False
+ protocol: MultiplexProtocol = MultiplexProtocol.smux
+ max_connections: int | None = Field(default=None)
+ max_streams: int | None = Field(default=None)
+ min_streams: int | None = Field(default=None)
+ padding: bool = False
+ brutal: Brutal | None = Field(default=None)
+
+
+class ClashMuxSettings(SingBoxMuxSettings):
+ statistic: bool = False
+ only_tcp: bool = False
+
+
+class XrayMuxSettings(BaseModel):
+ enable: bool = Field(default=False)
+ concurrency: int | None = Field(default=None)
+ xudp_concurrency: int | None = Field(None, serialization_alias="xudpConcurrency")
+ xudp_proxy_udp_443: XUDP = Field(default=XUDP.reject, serialization_alias="xudpProxyUDP443")
+
+
+class MuxSettings(BaseModel):
+ sing_box: SingBoxMuxSettings | None = Field(default=None)
+ clash: ClashMuxSettings | None = Field(default=None)
+ xray: XrayMuxSettings | None = Field(default=None)
+
+
+class TransportSettings(BaseModel):
+ xhttp_settings: XHttpSettings | None = Field(default=None)
+ grpc_settings: GRPCSettings | None = Field(default=None)
+ kcp_settings: KCPSettings | None = Field(default=None)
+ tcp_settings: TcpSettings | None = Field(default=None)
+ websocket_settings: WebSocketSettings | None = Field(default=None)
+
+
+class FormatVariables(dict):
+ def __missing__(self, key):
+ return key.join("{}")
+
+
+class BaseHost(BaseModel):
+ id: int | None = Field(default=None)
+ remark: str
+ address: set[str] = Field(default_factory=set)
+ inbound_tag: str | None = Field(default=None)
+ port: int | None = Field(default=None)
+ sni: set[str] | None = Field(default_factory=set)
+ host: set[str] | None = Field(default_factory=set)
+ path: str | None = Field(default=None)
+ security: ProxyHostSecurity = ProxyHostSecurity.inbound_default
+ alpn: list[ProxyHostALPN] | None = Field(default_factory=list)
+ fingerprint: ProxyHostFingerprint = ProxyHostFingerprint.none
+ allowinsecure: bool | None = Field(default=None)
+ is_disabled: bool = Field(default=False)
+ http_headers: dict[str, str] | None = Field(default=None)
+ transport_settings: TransportSettings | None = Field(default=None)
+ mux_settings: MuxSettings | None = Field(default=None)
+ fragment_settings: FragmentSettings | None = Field(default=None)
+ noise_settings: NoiseSettings | None = Field(default=None)
+ random_user_agent: bool = Field(default=False)
+ use_sni_as_host: bool = Field(default=False)
+ priority: int
+ status: set[UserStatus] | None = Field(default_factory=set)
+ ech_config_list: str | None = Field(default=None)
+
+ model_config = ConfigDict(from_attributes=True)
+
+ @property
+ def address_str(self) -> str:
+ if self.address:
+ return ",".join(self.address)
+ return ""
+
+
+class CreateHost(BaseHost):
+ @field_validator("remark", mode="after")
+ def validate_remark(cls, v):
+ try:
+ v.format_map(FormatVariables())
+ except ValueError:
+ raise ValueError("Invalid formatting variables")
+
+ return v
+
+ @field_validator("alpn", mode="after")
+ def remove_duplicates(cls, v):
+ if v:
+ return ListValidator.remove_duplicates_preserve_order(v)
+
+ @field_validator("alpn", mode="after")
+ def sort_alpn_list(cls, v) -> list:
+ priority = {"h3": 0, "h2": 1, "http/1.1": 2}
+ if v:
+ return sorted(v, key=lambda x: priority[x])
+
+ @field_validator("address", mode="after")
+ def validate_address(cls, v):
+ return StringArrayValidator.len_check(v, 256)
+
+ @field_validator("sni", "host", mode="after")
+ def validate_sets(cls, v: set):
+ return StringArrayValidator.len_check(v, 1000)
diff --git a/app/models/node.py b/app/models/node.py
new file mode 100644
index 000000000..6ba4b47d3
--- /dev/null
+++ b/app/models/node.py
@@ -0,0 +1,164 @@
+import re
+from enum import Enum
+from ipaddress import ip_address
+from uuid import UUID
+
+from cryptography.x509 import load_pem_x509_certificate
+from pydantic import BaseModel, ConfigDict, Field, field_validator
+
+from app.db.models import NodeConnectionType, NodeStatus
+
+# Basic PEM format validation
+CERT_PATTERN = r"-----BEGIN CERTIFICATE-----(.*?)-----END CERTIFICATE-----"
+KEY_PATTERN = r"-----BEGIN (?:RSA )?PRIVATE KEY-----"
+
+
+class UsageTable(str, Enum):
+ node_user_usages = "node_user_usages"
+ node_usages = "node_usages"
+
+
+class NodeSettings(BaseModel):
+ min_node_version: str = "v1.0.0"
+
+
+class Node(BaseModel):
+ name: str
+ address: str
+ port: int = 62050
+ usage_coefficient: float = Field(gt=0, default=1.0)
+ connection_type: NodeConnectionType
+ server_ca: str
+ keep_alive: int
+ max_logs: int = Field(gt=0, default=1000)
+ core_config_id: int
+ api_key: str
+ gather_logs: bool = Field(default=True)
+
+
+class NodeCreate(Node):
+ model_config = ConfigDict(
+ json_schema_extra={
+ "example": {
+ "name": "DE node",
+ "address": "192.168.1.1",
+ "port": 62050,
+ "usage_coefficient": 1,
+ "server_ca": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----",
+ "connection_type": "grpc",
+ "keep_alive": 60,
+ "max_logs": 1000,
+ "core_config_id": 1,
+ "api_key": "valid uuid",
+ "gather_logs": True,
+ }
+ }
+ )
+
+ @field_validator("address")
+ @classmethod
+ def validate_address(cls, v: str) -> str:
+ if not v:
+ return v
+ try:
+ ip_address(v)
+ return v
+ except ValueError:
+ # Regex for domain validation
+ if re.match(r"^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,6}$", v):
+ return v
+ raise ValueError("Invalid address format, must be a valid IPv4/IPv6 or domain")
+
+ @field_validator("port")
+ @classmethod
+ def validate_port(cls, v: int) -> int:
+ if not v:
+ return v
+ if not 1 <= v <= 65535:
+ raise ValueError("Port must be between 1 and 65535")
+ return v
+
+ @field_validator("server_ca")
+ @classmethod
+ def validate_certificate(cls, v: str | None) -> str | None:
+ if v is None:
+ return None
+
+ v = v.strip()
+
+ # Check for PEM certificate format
+ if not re.search(CERT_PATTERN, v, re.DOTALL):
+ raise ValueError("Invalid certificate format - must contain PEM certificate blocks")
+
+ # Check for private key material
+ if re.search(KEY_PATTERN, v):
+ raise ValueError("Certificate contains private key material")
+
+ if len(v) > 2048:
+ raise ValueError("Certificate too large (max 2048 characters)")
+
+ try:
+ load_pem_x509_certificate(v.encode("utf-8"))
+ pass
+ except Exception:
+ raise ValueError("Invalid certificate structure")
+
+ return v
+
+ @field_validator("api_key", mode="before")
+ @classmethod
+ def validate_api_key(cls, v) -> str:
+ if not v:
+ return
+ try:
+ UUID(v)
+ except ValueError:
+ raise ValueError("Invalid UUID format for api_key")
+ return v
+
+
+class NodeModify(NodeCreate):
+ name: str | None = Field(default=None)
+ address: str | None = Field(default=None)
+ port: int | None = Field(default=None)
+ api_port: int | None = Field(default=None)
+ status: NodeStatus | None = Field(default=None)
+ usage_coefficient: float | None = Field(default=None)
+ server_ca: str | None = Field(default=None)
+ connection_type: NodeConnectionType | None = Field(default=None)
+ keep_alive: int | None = Field(default=None)
+ max_logs: int | None = Field(default=None)
+ core_config_id: int | None = Field(default=None)
+ api_key: str | None = Field(default=None)
+ gather_logs: bool | None = Field(default=None)
+
+ model_config = ConfigDict(
+ json_schema_extra={
+ "example": {
+ "name": "DE node",
+ "address": "192.168.1.1",
+ "port": 62050,
+ "status": "disabled",
+ "usage_coefficient": 1.0,
+ "connection_type": "grpc",
+ "server_ca": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----",
+ "keep_alive": 60,
+ "max_logs": 1000,
+ "core_config_id": 1,
+ "api_key": "valid uuid",
+ "gather_logs": True,
+ }
+ }
+ )
+
+
+class NodeResponse(Node):
+ id: int
+ api_key: str | None
+ core_config_id: int | None
+ xray_version: str | None
+ node_version: str | None
+ status: NodeStatus
+ message: str | None
+
+ model_config = ConfigDict(from_attributes=True)
diff --git a/app/models/proxy.py b/app/models/proxy.py
new file mode 100644
index 000000000..db28a78fe
--- /dev/null
+++ b/app/models/proxy.py
@@ -0,0 +1,49 @@
+import json
+from uuid import uuid4, UUID
+from enum import Enum
+
+from pydantic import BaseModel, Field
+
+from app.utils.system import random_password
+
+
+class VMessSettings(BaseModel):
+ id: UUID = Field(default_factory=uuid4)
+
+
+class XTLSFlows(str, Enum):
+ NONE = ""
+ VISION = "xtls-rprx-vision"
+
+
+class VlessSettings(BaseModel):
+ id: UUID = Field(default_factory=uuid4)
+ flow: XTLSFlows = XTLSFlows.NONE
+
+
+class TrojanSettings(BaseModel):
+ password: str = Field(default_factory=random_password)
+
+
+class ShadowsocksMethods(str, Enum): # Already a str, Enum which is good
+ AES_128_GCM = "aes-128-gcm"
+ AES_256_GCM = "aes-256-gcm"
+ CHACHA20_POLY1305 = "chacha20-ietf-poly1305"
+ XCHACHA20_POLY1305 = "xchacha20-poly1305"
+
+
+class ShadowsocksSettings(BaseModel):
+ password: str = Field(default_factory=random_password, min_length=22)
+ method: ShadowsocksMethods = ShadowsocksMethods.CHACHA20_POLY1305
+
+
+class ProxyTable(BaseModel):
+ vmess: VMessSettings = Field(default_factory=VMessSettings)
+ vless: VlessSettings = Field(default_factory=VlessSettings)
+ trojan: TrojanSettings = Field(default_factory=TrojanSettings)
+ shadowsocks: ShadowsocksSettings = Field(default_factory=ShadowsocksSettings)
+
+ def dict(self, *, no_obj=True, **kwargs):
+ if no_obj:
+ return json.loads(self.model_dump_json())
+ return super().model_dump(**kwargs)
diff --git a/app/models/settings.py b/app/models/settings.py
new file mode 100644
index 000000000..558312724
--- /dev/null
+++ b/app/models/settings.py
@@ -0,0 +1,222 @@
+import re
+from enum import Enum, StrEnum
+
+from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
+
+from app.models.proxy import ShadowsocksMethods, XTLSFlows
+
+from .validators import DiscordValidator, ProxyValidator, URLValidator
+
+TELEGRAM_TOKEN_PATTERN = r"^\d{8,12}:[A-Za-z0-9_-]{35}$"
+
+
+class RunMethod(StrEnum):
+ WEBHOOK = "webhook"
+ LONGPOLLING = "long-polling"
+
+
+class Telegram(BaseModel):
+ enable: bool = Field(default=False)
+ token: str | None = Field(default=None)
+ webhook_url: str | None = Field(default=None)
+ webhook_secret: str | None = Field(default=None)
+ proxy_url: str | None = Field(default=None)
+ method: RunMethod = Field(default=RunMethod.WEBHOOK)
+
+ mini_app_login: bool = Field(default=True)
+ mini_app_web_url: str | None = Field(default="")
+
+ for_admins_only: bool = Field(default=True)
+
+ @field_validator("mini_app_web_url")
+ @classmethod
+ def validate_mini_app_web_url(cls, v):
+ return URLValidator.validate_url(v)
+
+ @field_validator("webhook_url")
+ def validate_webhook_url(cls, v, values):
+ method = values.data.get("method", "webhook")
+ if method == "webhook":
+ return URLValidator.validate_url(v)
+
+ @field_validator("proxy_url")
+ @classmethod
+ def validate_proxy_url(cls, v):
+ return ProxyValidator.validate_proxy_url(v)
+
+ @field_validator("token")
+ @classmethod
+ def token_validation(cls, v):
+ if not v:
+ return v
+ if not re.match(TELEGRAM_TOKEN_PATTERN, v):
+ raise ValueError("Invalid telegram token format")
+ return v
+
+ @model_validator(mode="after")
+ def check_enable_requires_token_and_url(self):
+ if self.enable and (
+ (self.method == RunMethod.WEBHOOK and (not self.token or not self.webhook_url or not self.webhook_secret))
+ or (self.method == RunMethod.LONGPOLLING and not self.token)
+ ):
+ if self.method == RunMethod.WEBHOOK:
+ raise ValueError("Telegram bot cannot be enabled without token, webhook_url and webhook_secret.")
+ elif self.method == RunMethod.LONGPOLLING:
+ raise ValueError("Telegram bot cannot be enabled without token.")
+ return self
+
+
+class Discord(BaseModel):
+ enable: bool = Field(default=False)
+ token: str | None = Field(default=None)
+ proxy_url: str | None = Field(default=None)
+
+ @field_validator("proxy_url")
+ @classmethod
+ def validate_proxy_url(cls, v):
+ return ProxyValidator.validate_proxy_url(v)
+
+ @model_validator(mode="after")
+ def check_enable_requires_token(self):
+ if self.enable and not self.token:
+ raise ValueError("Discord bot cannot be enabled without token.")
+ return self
+
+
+class WebhookInfo(BaseModel):
+ url: str
+ secret: str
+
+
+class Webhook(BaseModel):
+ enable: bool = Field(default=False)
+ webhooks: list[WebhookInfo] = Field(default=[])
+ days_left: list[int] = Field(default=[])
+ usage_percent: list[int] = Field(default=[])
+ timeout: int = Field(gt=0)
+ recurrent: int = Field(gt=0)
+ proxy_url: str | None = Field(default=None)
+
+ @field_validator("proxy_url", mode="before")
+ @classmethod
+ def validate_proxy_url(cls, v):
+ return ProxyValidator.validate_proxy_url(v)
+
+ @model_validator(mode="after")
+ def check_enable_requires_webhookinfo(self):
+ if self.enable and (not self.webhooks or len(self.webhooks) == 0):
+ raise ValueError("Webhook cannot be enabled without at least one WebhookInfo.")
+ return self
+
+
+class NotificationSettings(BaseModel):
+ # Define Which Notfication System Work's
+ notify_telegram: bool = Field(default=False)
+ notify_discord: bool = Field(default=False)
+
+ # Telegram Settings
+ telegram_api_token: str | None = Field(default=None)
+ telegram_admin_id: int | None = Field(default=None)
+ telegram_channel_id: int | None = Field(default=None)
+ telegram_topic_id: int | None = Field(default=None)
+
+ # Discord Settings
+ discord_webhook_url: str | None = Field(default=None)
+
+ # Proxy Settings
+ proxy_url: str | None = Field(default=None)
+
+ max_retries: int = Field(gt=1)
+
+ @field_validator("proxy_url", mode="before")
+ @classmethod
+ def validate_proxy_url(cls, v):
+ return ProxyValidator.validate_proxy_url(v)
+
+ @field_validator("discord_webhook_url", mode="before")
+ @classmethod
+ def validate_discord_webhook(cls, value):
+ return DiscordValidator.validate_webhook(value)
+
+ @model_validator(mode="after")
+ def check_notify_discord_requires_url(self):
+ if self.notify_discord and not self.discord_webhook_url:
+ raise ValueError("Discord notification cannot be enabled without webhook url.")
+ return self
+
+ @model_validator(mode="after")
+ def check_notify_telegram_requires_token_and_id(self):
+ if self.notify_telegram and (
+ not self.telegram_api_token or not (self.telegram_channel_id, self.telegram_admin_id)
+ ):
+ raise ValueError("Telegram notification cannot be enabled without token or admin/channel id.")
+ return self
+
+
+class NotificationEnable(BaseModel):
+ admin: bool = Field(default=True)
+ core: bool = Field(default=True)
+ group: bool = Field(default=True)
+ host: bool = Field(default=True)
+ login: bool = Field(default=True)
+ node: bool = Field(default=True)
+ user: bool = Field(default=True)
+ user_template: bool = Field(default=True)
+ days_left: bool = Field(default=True)
+ percentage_reached: bool = Field(default=True)
+
+
+class ConfigFormat(str, Enum):
+ links = "links"
+ links_base64 = "links_base64"
+ xray = "xray"
+ sing_box = "sing_box"
+ clash = "clash"
+ clash_meta = "clash_meta"
+ outline = "outline"
+ block = "block"
+
+
+class SubRule(BaseModel):
+ pattern: str
+ target: ConfigFormat
+
+
+class SubFormatEnable(BaseModel):
+ links: bool = Field(default=True)
+ links_base64: bool = Field(default=True)
+ xray: bool = Field(default=True)
+ sing_box: bool = Field(default=True)
+ clash: bool = Field(default=True)
+ clash_meta: bool = Field(default=True)
+ outline: bool = Field(default=True)
+
+
+class Subscription(BaseModel):
+ url_prefix: str = Field(default="")
+ update_interval: int = Field(default=12)
+ support_url: str = Field(default="https://t.me/")
+ profile_title: str = Field(default="Subscription")
+
+ host_status_filter: bool
+
+ # Rules To Seperate Clients And Send Config As Needed
+ rules: list[SubRule]
+ manual_sub_request: SubFormatEnable = Field(default_factory=SubFormatEnable)
+
+
+class General(BaseModel):
+ default_flow: XTLSFlows = Field(default=XTLSFlows.NONE)
+ default_method: ShadowsocksMethods = Field(default=ShadowsocksMethods.CHACHA20_POLY1305)
+
+
+class SettingsSchema(BaseModel):
+ telegram: Telegram | None = Field(default=None)
+ discord: Discord | None = Field(default=None)
+ webhook: Webhook | None = Field(default=None)
+ notification_settings: NotificationSettings | None = Field(default=None)
+ notification_enable: NotificationEnable | None = Field(default=None)
+ subscription: Subscription | None = Field(default=None)
+ general: General | None = Field(default=None)
+
+ model_config = ConfigDict(from_attributes=True)
diff --git a/app/models/stats.py b/app/models/stats.py
new file mode 100644
index 000000000..b775aa281
--- /dev/null
+++ b/app/models/stats.py
@@ -0,0 +1,77 @@
+from enum import Enum
+from datetime import datetime as dt
+
+from pydantic import BaseModel, field_validator
+
+from .validators import NumericValidatorMixin
+
+
+class Period(str, Enum):
+ minute = "minute"
+ hour = "hour"
+ day = "day"
+ month = "month"
+
+
+class StatList(BaseModel):
+ period: Period | None = None
+ start: dt
+ end: dt
+
+
+class UserUsageStat(BaseModel):
+ total_traffic: int
+ period_start: dt
+
+ @field_validator("total_traffic", mode="before")
+ def cast_to_int(cls, v):
+ return NumericValidatorMixin.cast_to_int(v)
+
+
+class UserUsageStatsList(StatList):
+ stats: dict[int, list[UserUsageStat]]
+
+
+class NodeUsageStat(BaseModel):
+ uplink: int
+ downlink: int
+ period_start: dt
+
+ @field_validator("downlink", "uplink", mode="before")
+ def cast_to_int(cls, v):
+ return NumericValidatorMixin.cast_to_int(v)
+
+
+class NodeUsageStatsList(StatList):
+ stats: dict[int, list[NodeUsageStat]]
+
+
+class NodeRealtimeStats(BaseModel):
+ mem_total: int
+ mem_used: int
+ cpu_cores: int
+ cpu_usage: float
+ incoming_bandwidth_speed: int
+ outgoing_bandwidth_speed: int
+
+
+class NodeStats(BaseModel):
+ period_start: dt
+ mem_usage_percentage: float
+ cpu_usage_percentage: float
+ incoming_bandwidth_speed: float
+ outgoing_bandwidth_speed: float
+
+ @field_validator(
+ "mem_usage_percentage",
+ "cpu_usage_percentage",
+ "incoming_bandwidth_speed",
+ "outgoing_bandwidth_speed",
+ mode="before",
+ )
+ def cast_to_float(cls, v):
+ return NumericValidatorMixin.cast_to_float(v)
+
+
+class NodeStatsList(StatList):
+ stats: list[NodeStats]
diff --git a/app/models/system.py b/app/models/system.py
new file mode 100644
index 000000000..e3e74245d
--- /dev/null
+++ b/app/models/system.py
@@ -0,0 +1,18 @@
+from pydantic import BaseModel
+
+
+class SystemStats(BaseModel):
+ version: str
+ mem_total: int | None = None
+ mem_used: int | None = None
+ cpu_cores: int | None = None
+ cpu_usage: float | None = None
+ total_user: int
+ online_users: int
+ active_users: int
+ on_hold_users: int
+ disabled_users: int
+ expired_users: int
+ limited_users: int
+ incoming_bandwidth: int
+ outgoing_bandwidth: int
diff --git a/app/models/user.py b/app/models/user.py
new file mode 100644
index 000000000..61f7c800e
--- /dev/null
+++ b/app/models/user.py
@@ -0,0 +1,179 @@
+from datetime import datetime as dt
+from enum import Enum
+
+from pydantic import BaseModel, ConfigDict, Field, field_validator
+
+from app.db.models import UserDataLimitResetStrategy, UserStatus, UserStatusCreate
+from app.models.admin import AdminBase, AdminContactInfo
+from app.models.proxy import ProxyTable, ShadowsocksMethods, XTLSFlows
+from app.utils.helpers import fix_datetime_timezone
+
+from .validators import ListValidator, NumericValidatorMixin, UserValidator
+
+
+class UserStatusModify(str, Enum):
+ active = "active"
+ disabled = "disabled"
+ on_hold = "on_hold"
+
+
+class NextPlanModel(BaseModel):
+ user_template_id: int | None = Field(default=None)
+ data_limit: int | None = Field(default=None)
+ expire: int | None = Field(default=None)
+ add_remaining_traffic: bool = False
+ model_config = ConfigDict(from_attributes=True)
+
+
+class User(BaseModel):
+ proxy_settings: ProxyTable = Field(default_factory=ProxyTable)
+ expire: dt | int | None = Field(default=None)
+ data_limit: int | None = Field(ge=0, default=None, description="data_limit can be 0 or greater")
+ data_limit_reset_strategy: UserDataLimitResetStrategy | None = Field(default=None)
+ note: str | None = Field(max_length=500, default=None)
+ on_hold_expire_duration: int | None = Field(default=None)
+ on_hold_timeout: dt | int | None = Field(default=None)
+ group_ids: list[int] | None = Field(default_factory=list)
+ auto_delete_in_days: int | None = Field(default=None)
+
+ next_plan: NextPlanModel | None = Field(default=None)
+
+
+class UserWithValidator(User):
+ @field_validator("on_hold_expire_duration")
+ @classmethod
+ def validate_timeout(cls, v):
+ # Check if expire is 0 or None and timeout is not 0 or None
+ if v in (0, None):
+ return None
+ return v
+
+ @field_validator("on_hold_timeout", check_fields=False)
+ @classmethod
+ def validator_on_hold_timeout(cls, value):
+ return UserValidator.validator_on_hold_timeout(value)
+
+ @field_validator("expire", check_fields=False)
+ @classmethod
+ def validator_expire(cls, value):
+ if not value:
+ return value
+ return fix_datetime_timezone(value)
+
+ @field_validator("status", mode="before", check_fields=False)
+ def validate_status(cls, status, values):
+ return UserValidator.validate_status(status, values)
+
+
+class UserCreate(UserWithValidator):
+ username: str
+ status: UserStatusCreate | None = Field(default=None)
+
+ @field_validator("username", check_fields=False)
+ @classmethod
+ def validate_username(cls, v):
+ return UserValidator.validate_username(v)
+
+ @field_validator("group_ids", mode="after")
+ @classmethod
+ def group_ids_validator(cls, v):
+ return ListValidator.not_null_list(v, "group")
+
+
+class UserModify(UserWithValidator):
+ status: UserStatusModify | None = Field(default=None)
+ proxy_settings: ProxyTable | None = Field(default=None)
+
+ @field_validator("group_ids", mode="after")
+ @classmethod
+ def group_ids_validator(cls, v):
+ return ListValidator.nullable_list(v, "group")
+
+
+class UserNotificationResponse(User):
+ id: int
+ username: str
+ status: UserStatus
+ used_traffic: int
+ lifetime_used_traffic: int = 0
+ created_at: dt
+ edit_at: dt | None = Field(default=None)
+ online_at: dt | None = Field(default=None)
+ subscription_url: str = Field(default="")
+ admin: AdminContactInfo | None = Field(default=None)
+ model_config = ConfigDict(from_attributes=True)
+
+ @field_validator("used_traffic", "lifetime_used_traffic", "data_limit", mode="before")
+ @classmethod
+ def cast_to_int(cls, v):
+ return NumericValidatorMixin.cast_to_int(v)
+
+
+class UserResponse(UserNotificationResponse):
+ admin: AdminBase | None = None
+
+
+class SubscriptionUserResponse(UserResponse):
+ admin: AdminContactInfo | None = Field(default=None, exclude=True)
+ note: str | None = Field(None, exclude=True)
+ auto_delete_in_days: int | None = Field(None, exclude=True)
+ subscription_url: str | None = Field(None, exclude=True)
+ model_config = ConfigDict(from_attributes=True)
+
+
+class UsersResponseWithInbounds(SubscriptionUserResponse):
+ inbounds: list[str] | None = Field(default_factory=list)
+
+ model_config = ConfigDict(from_attributes=True)
+
+
+class UsersResponse(BaseModel):
+ users: list[UserResponse]
+ total: int
+
+
+class UserSubscriptionUpdateSchema(BaseModel):
+ created_at: dt
+ user_agent: str
+
+ model_config = ConfigDict(from_attributes=True)
+
+
+class UserSubscriptionUpdateList(BaseModel):
+ updates: list[UserSubscriptionUpdateSchema] = Field(default_factory=list)
+ count: int
+
+
+class RemoveUsersResponse(BaseModel):
+ users: list[str]
+ count: int
+
+
+class ModifyUserByTemplate(BaseModel):
+ user_template_id: int
+ note: str | None = Field(max_length=500, default=None)
+
+
+class CreateUserFromTemplate(ModifyUserByTemplate):
+ username: str
+
+ @field_validator("username", check_fields=False)
+ @classmethod
+ def validate_username(cls, v):
+ return UserValidator.validate_username(v)
+
+
+class BulkUser(BaseModel):
+ amount: int
+ group_ids: set[int] = Field(default_factory=set)
+ admins: set[int] = Field(default_factory=set)
+ users: set[int] = Field(default_factory=set)
+ status: set[UserStatus] = Field(default_factory=set)
+
+
+class BulkUsersProxy(BaseModel):
+ flow: XTLSFlows | None = Field(default=None)
+ method: ShadowsocksMethods | None = Field(default=None)
+ group_ids: set[int] = Field(default_factory=set)
+ admins: set[int] = Field(default_factory=set)
+ users: set[int] = Field(default_factory=set)
diff --git a/app/models/user_template.py b/app/models/user_template.py
new file mode 100644
index 000000000..a5bc90e6a
--- /dev/null
+++ b/app/models/user_template.py
@@ -0,0 +1,68 @@
+import json
+
+from pydantic import BaseModel, ConfigDict, Field, field_validator
+
+from app.db.models import UserDataLimitResetStrategy, UserStatusCreate
+from app.models.proxy import ShadowsocksMethods, XTLSFlows
+
+from .validators import ListValidator, UserValidator
+
+
+class ExtraSettings(BaseModel):
+ flow: XTLSFlows | None = Field(XTLSFlows.NONE)
+ method: ShadowsocksMethods | None = Field(ShadowsocksMethods.CHACHA20_POLY1305)
+
+ def dict(self, *, no_obj=True, **kwargs):
+ if no_obj:
+ return json.loads(self.model_dump_json())
+ return super().model_dump(**kwargs)
+
+
+class UserTemplate(BaseModel):
+ name: str | None = None
+ data_limit: int | None = Field(ge=0, default=None, description="data_limit can be 0 or greater")
+ expire_duration: int | None = Field(
+ ge=0, default=None, description="expire_duration can be 0 or greater in seconds"
+ )
+ username_prefix: str | None = Field(max_length=20, default=None)
+ username_suffix: str | None = Field(max_length=20, default=None)
+ group_ids: list[int]
+ extra_settings: ExtraSettings | None = None
+ status: UserStatusCreate | None = None
+ reset_usages: bool | None = None
+ on_hold_timeout: int | None = None
+ data_limit_reset_strategy: UserDataLimitResetStrategy = Field(default=UserDataLimitResetStrategy.no_reset)
+ is_disabled: bool | None = None
+
+
+class UserTemplateWithValidator(UserTemplate):
+ @field_validator("status", mode="before", check_fields=False)
+ def validate_status(cls, status, values):
+ return UserValidator.validate_status(status, values)
+
+ @field_validator("username_prefix", "username_suffix", check_fields=False)
+ @classmethod
+ def validate_username(cls, v):
+ return UserValidator.validate_username(v, False, True)
+
+
+class UserTemplateCreate(UserTemplateWithValidator):
+ @field_validator("group_ids", mode="after")
+ @classmethod
+ def group_ids_validator(cls, v):
+ return ListValidator.not_null_list(v, "group")
+
+
+class UserTemplateModify(UserTemplateWithValidator):
+ group_ids: list[int] | None = None
+
+ @field_validator("group_ids", mode="after")
+ @classmethod
+ def group_ids_validator(cls, v):
+ return ListValidator.nullable_list(v, "group")
+
+
+class UserTemplateResponse(UserTemplate):
+ id: int
+
+ model_config = ConfigDict(from_attributes=True)
diff --git a/app/models/validators.py b/app/models/validators.py
new file mode 100644
index 000000000..027f1e9e6
--- /dev/null
+++ b/app/models/validators.py
@@ -0,0 +1,226 @@
+import re
+from datetime import datetime
+from decimal import Decimal
+from typing import Optional
+from urllib.parse import urlparse
+
+from app.db.models import UserStatusCreate
+
+
+class NumericValidatorMixin:
+ @staticmethod
+ def cast_to_int(v):
+ """
+ Static method to validate and convert numeric values to integers.
+
+ Args:
+ v: Input value to be converted
+
+ Returns:
+ int or None: Converted integer value
+
+ Raises:
+ ValueError: If the input cannot be converted to an integer
+ """
+ if v is None: # Allow None values
+ return v
+ elif isinstance(v, float) or isinstance(v, Decimal): # Allow float or Decimal to int conversion
+ return int(v)
+ elif isinstance(v, int): # Allow integers directly
+ return v
+
+ raise ValueError("must be an integer, Decimal or a float, not a string") # Reject strings
+
+ @staticmethod
+ def cast_to_float(v):
+ """
+ Static method to validate and convert numeric values to floats.
+
+ Args:
+ v: Input value to be converted
+
+ Returns:
+ float or None: Converted float value
+
+ Raises:
+ ValueError: If the input cannot be converted to an float
+ """
+ if v is None:
+ return v
+ elif isinstance(v, int) or isinstance(v, Decimal):
+ return float(v)
+ elif isinstance(v, float):
+ return v
+
+ raise ValueError("must be an integer, Decimal or a float, not a string")
+
+
+class ListValidator:
+ @staticmethod
+ def nullable_list(list: list | None, name: str) -> list:
+ if list and len(list) < 1:
+ raise ValueError(f"you must select at least one {name}")
+ return list
+
+ @staticmethod
+ def not_null_list(list: list, name: str) -> list:
+ if not list or len(list) < 1:
+ raise ValueError(f"you must select at least one {name}")
+ return list
+
+ @staticmethod
+ def remove_duplicates_preserve_order(list_: list) -> list:
+ """
+ Remove duplicates from list while preserving order using dict.fromkeys()
+ """
+ return list(dict.fromkeys(list_))
+
+
+class PasswordValidator:
+ @staticmethod
+ def validate_password(value: str | None, check_username: str | None = None):
+ if value is None:
+ return value # Allow None for optional passwords
+
+ errors = []
+ # Length check
+ if len(value) < 12:
+ errors.append("Password must be at least 12 characters long")
+ # At least 2 digits
+ if len(re.findall(r"\d", value)) < 2:
+ errors.append("Password must contain at least 2 digits")
+ # At least 2 uppercase letters
+ if len(re.findall(r"[A-Z]", value)) < 2:
+ errors.append("Password must contain at least 2 uppercase letters")
+ # At least 2 lowercase letters
+ if len(re.findall(r"[a-z]", value)) < 2:
+ errors.append("Password must contain at least 2 lowercase letters")
+ # At least 1 special character
+ if not re.search(r"[!@#$%^&*()\-_=+\[\]{}|;:,.<>?/~`]", value):
+ errors.append("Password must contain at least one special character")
+ # Check if password contains the username
+ if check_username and check_username.lower() in value.lower():
+ errors.append("Password cannot contain the username")
+
+ if errors:
+ raise ValueError("; ".join(errors))
+ return value
+
+
+class UserValidator:
+ @staticmethod
+ def validate_status(status, values):
+ on_hold_expire = values.data.get("on_hold_expire_duration") or values.data.get("expire_duration")
+ expire = values.data.get("expire")
+ if status == UserStatusCreate.on_hold:
+ if on_hold_expire == 0 or on_hold_expire is None:
+ raise ValueError("User cannot be on hold without a valid on_hold_expire_duration.")
+ if expire:
+ raise ValueError("User cannot be on hold with specified expire.")
+ return status
+
+ @staticmethod
+ def validate_username(username: str, len_check: bool = True, accept_null: bool = False):
+ if accept_null and not username:
+ return username
+
+ if len_check and not (3 <= len(username) <= 128):
+ raise ValueError("Username only can be 3 to 128 characters.")
+
+ if not re.match(r"^[a-zA-Z0-9-_@.]+$", username):
+ raise ValueError("Username can only contain alphanumeric characters, -, _, @, and .")
+
+ # Additional check to prevent consecutive special characters
+ if re.search(r"[-_@.]{2,}", username):
+ raise ValueError("Username cannot have consecutive special characters")
+
+ return username
+
+ @staticmethod
+ def validator_on_hold_timeout(value):
+ if value == 0 or isinstance(value, datetime) or value is None:
+ return value
+ else:
+ raise ValueError("on_hold_timeout can be datetime or 0")
+
+
+class ProxyValidator:
+ @staticmethod
+ def validate_proxy_url(value: str | None) -> str | None:
+ """
+ Validates a proxy URL. Accepts HTTP, HTTPS, SOCKS4, SOCKS5, with or without authentication.
+ Examples:
+ http://host:port
+ https://host:port
+ socks5://host:port
+ http://user:pass@host:port
+ socks5://user:pass@host:port
+ """
+ if value is None:
+ return value
+ pattern = (
+ r"^(?Phttp|https|socks4|socks5)://"
+ r"((?P[^\s:@]+):(?P[^\s:@]+)@)?"
+ r"(?P[a-zA-Z0-9\.-]+)"
+ r":(?P\d{1,5})$"
+ )
+ if not re.match(pattern, value):
+ raise ValueError(
+ "proxy_url must be a valid proxy address (e.g., http://host:port, socks5://user:pass@host:port)"
+ )
+ return value
+
+
+class DiscordValidator:
+ @staticmethod
+ def validate_webhook(value: str | None):
+ if value:
+ parsed = urlparse(value)
+ # validate scheme and hostname
+ if parsed.scheme != "https" or parsed.hostname not in {"discord.com"}:
+ raise ValueError("Discord webhook must use https scheme and point to 'discord.com'")
+ return value
+
+
+class URLValidator:
+ @staticmethod
+ def validate_url(value: Optional[str]) -> Optional[str]:
+ """
+ Validates a general-purpose URL.
+ Examples:
+ http://example.com
+ https://example.com:8443
+ """
+ if not value:
+ return value
+
+ pattern = (
+ r"^(?Phttp|https)://" # Scheme
+ r"(:(?P\d{1,5}))?" # Optional port
+ r"(?P/[^\s?#]*)?" # Optional path
+ )
+
+ if not re.match(pattern, value):
+ raise ValueError(f"URL must be a valid address (e.g., https://example.com:8443/path): {value}")
+
+ return value
+
+
+class StringArrayValidator:
+ @staticmethod
+ def len_check(array: dict | list | set, max: int) -> set[str] | None:
+ if array is None:
+ return None
+
+ # Ensure the input is a set for validation
+ if isinstance(array, set):
+ pass
+ elif isinstance(array, dict):
+ array = set(array.keys())
+ else:
+ array = set(array)
+
+ compiled_string = ",".join([str(v) for v in array])
+ if len(compiled_string) > max:
+ raise ValueError(f"String can't be bigger that {max} charachter")
+ return array
diff --git a/app/node/__init__.py b/app/node/__init__.py
new file mode 100644
index 000000000..5cf3a9b5c
--- /dev/null
+++ b/app/node/__init__.py
@@ -0,0 +1,113 @@
+import asyncio
+
+from PasarGuardNodeBridge import PasarGuardNode, create_node, Health, NodeType
+from aiorwlock import RWLock
+
+from app.db.models import Node, NodeConnectionType, User
+from app.node.user import serialize_user_for_node, core_users, serialize_users_for_node
+from app.models.user import UserResponse
+
+
+type_map = {
+ NodeConnectionType.rest: NodeType.rest,
+ NodeConnectionType.grpc: NodeType.grpc,
+}
+
+
+class NodeManager:
+ def __init__(self):
+ self._nodes: dict[int, PasarGuardNode] = {}
+ self._lock = RWLock(fast=True)
+
+ async def update_node(self, node: Node) -> PasarGuardNode:
+ async with self._lock.writer_lock:
+ old_node: PasarGuardNode | None = self._nodes.get(node.id, None)
+ if old_node is not None:
+ try:
+ await old_node.set_health(Health.INVALID)
+ await old_node.stop()
+ except Exception:
+ pass
+ finally:
+ del self._nodes[node.id]
+
+ new_node = create_node(
+ connection=type_map[node.connection_type],
+ address=node.address,
+ port=node.port,
+ server_ca=node.server_ca,
+ api_key=node.api_key,
+ max_logs=node.max_logs,
+ extra={"id": node.id, "usage_coefficient": node.usage_coefficient},
+ )
+
+ self._nodes[node.id] = new_node
+
+ return new_node
+
+ async def remove_node(self, id: int) -> None:
+ async with self._lock.writer_lock:
+ old_node: PasarGuardNode | None = self._nodes.get(id, None)
+ if old_node is not None:
+ try:
+ await old_node.set_health(Health.INVALID)
+ await old_node.stop()
+ except Exception:
+ pass
+ finally:
+ del self._nodes[id]
+
+ async def get_node(self, id: int) -> PasarGuardNode | None:
+ async with self._lock.reader_lock:
+ return self._nodes.get(id, None)
+
+ async def get_nodes(self) -> dict[int, PasarGuardNode]:
+ async with self._lock.reader_lock:
+ return self._nodes
+
+ async def get_healthy_nodes(self) -> list[tuple[int, PasarGuardNode]]:
+ async with self._lock.reader_lock:
+ nodes: list[tuple[int, PasarGuardNode]] = [
+ (id, node) for id, node in self._nodes.items() if (await node.get_health() == Health.HEALTHY)
+ ]
+ return nodes
+
+ async def get_broken_nodes(self) -> list[tuple[int, PasarGuardNode]]:
+ async with self._lock.reader_lock:
+ nodes: list[tuple[int, PasarGuardNode]] = [
+ (id, node) for id, node in self._nodes.items() if (await node.get_health() == Health.BROKEN)
+ ]
+ return nodes
+
+ async def get_not_connected_nodes(self) -> list[tuple[int, PasarGuardNode]]:
+ async with self._lock.reader_lock:
+ nodes: list[tuple[int, PasarGuardNode]] = [
+ (id, node) for id, node in self._nodes.items() if (await node.get_health() == Health.NOT_CONNECTED)
+ ]
+ return nodes
+
+ async def update_user(self, user: UserResponse, inbounds: list[str] = None):
+ proto_user = serialize_user_for_node(user.id, user.username, user.proxy_settings.dict(), inbounds)
+
+ async with self._lock.reader_lock:
+ add_tasks = [node.update_user(proto_user) for node in self._nodes.values()]
+ await asyncio.gather(*add_tasks, return_exceptions=True)
+
+ async def update_users(self, users: list[User]):
+ proto_users = await serialize_users_for_node(users)
+ async with self._lock.reader_lock:
+ add_tasks = [node.update_users(proto_users) for node in self._nodes.values()]
+ await asyncio.gather(*add_tasks, return_exceptions=True)
+
+ async def remove_user(self, user: UserResponse):
+ proto_user = serialize_user_for_node(user.id, user.username, user.proxy_settings.dict())
+
+ async with self._lock.reader_lock:
+ remove_tasks = [node.update_user(proto_user) for node in self._nodes.values()]
+ await asyncio.gather(*remove_tasks, return_exceptions=True)
+
+
+node_manager: NodeManager = NodeManager()
+
+
+__all__ = ["core_users", "node_manager"]
diff --git a/app/node/user.py b/app/node/user.py
new file mode 100644
index 000000000..1b25e5721
--- /dev/null
+++ b/app/node/user.py
@@ -0,0 +1,60 @@
+from sqlalchemy.orm import load_only, selectinload
+from sqlalchemy import select
+from PasarGuardNodeBridge import create_user, create_proxy
+
+from app.db import AsyncSession
+from app.db.models import ProxyInbound, Group, User, UserStatus
+
+
+def serialize_user_for_node(id: int, username: str, user_settings: dict, inbounds: list[str] = None):
+ vmess_settings = user_settings.get("vmess", {})
+ vless_settings = user_settings.get("vless", {})
+ trojan_settings = user_settings.get("trojan", {})
+ shadowsocks_settings = user_settings.get("shadowsocks", {})
+
+ return create_user(
+ f"{id}.{username}",
+ create_proxy(
+ vmess_id=vmess_settings.get("id"),
+ vless_id=vless_settings.get("id"),
+ vless_flow=vless_settings.get("flow"),
+ trojan_password=trojan_settings.get("password"),
+ shadowsocks_password=shadowsocks_settings.get("password"),
+ shadowsocks_method=shadowsocks_settings.get("method"),
+ ),
+ inbounds,
+ )
+
+
+async def core_users(db: AsyncSession):
+ stmt = (
+ select(User)
+ .options(
+ load_only(User.id, User.username, User.proxy_settings),
+ selectinload(User.groups),
+ selectinload(User.groups).selectinload(Group.inbounds).load_only(ProxyInbound.tag),
+ )
+ .filter(User.status.in_([UserStatus.active, UserStatus.on_hold]))
+ )
+ users = (await db.execute(stmt)).unique().scalars().all()
+ bridge_users: list = []
+
+ for user in users:
+ inbounds_list = await user.inbounds()
+ if len(inbounds_list) > 0:
+ bridge_users.append(serialize_user_for_node(user.id, user.username, user.proxy_settings, inbounds_list))
+
+ return bridge_users
+
+
+async def serialize_users_for_node(users: list[User]):
+ bridge_users: list = []
+
+ for user in users:
+ inbounds_list = []
+ if user.status in [UserStatus.active, UserStatus.on_hold]:
+ inbounds_list = await user.inbounds()
+
+ bridge_users.append(serialize_user_for_node(user.id, user.username, user.proxy_settings, inbounds_list))
+
+ return bridge_users
diff --git a/app/notification/__init__.py b/app/notification/__init__.py
new file mode 100644
index 000000000..a264e0080
--- /dev/null
+++ b/app/notification/__init__.py
@@ -0,0 +1,192 @@
+import asyncio
+
+from . import discord as ds
+from . import telegram as tg
+from . import webhook as wh
+from app.models.host import BaseHost
+from app.models.user_template import UserTemplateResponse
+from app.models.node import NodeResponse
+from app.models.group import GroupResponse
+from app.models.core import CoreResponse
+from app.models.admin import AdminDetails
+from app.models.user import UserNotificationResponse
+from app.settings import notification_enable
+
+
+async def create_host(host: BaseHost, by: str):
+ if (await notification_enable()).host:
+ await asyncio.gather(ds.create_host(host, by), tg.create_host(host, by))
+
+
+async def modify_host(host: BaseHost, by: str):
+ if (await notification_enable()).host:
+ await asyncio.gather(ds.modify_host(host, by), tg.modify_host(host, by))
+
+
+async def remove_host(host: BaseHost, by: str):
+ if (await notification_enable()).host:
+ await asyncio.gather(ds.remove_host(host, by), tg.remove_host(host, by))
+
+
+async def modify_hosts(by: str):
+ if (await notification_enable()).host:
+ await asyncio.gather(ds.modify_hosts(by), tg.modify_hosts(by))
+
+
+async def create_user_template(user: UserTemplateResponse, by: str):
+ if (await notification_enable()).user_template:
+ await asyncio.gather(ds.create_user_template(user, by), tg.create_user_template(user, by))
+
+
+async def modify_user_template(user: UserTemplateResponse, by: str):
+ if (await notification_enable()).user_template:
+ await asyncio.gather(ds.modify_user_template(user, by), tg.modify_user_template(user, by))
+
+
+async def remove_user_template(name: str, by: str):
+ if (await notification_enable()).user_template:
+ await asyncio.gather(ds.remove_user_template(name, by), tg.remove_user_template(name, by))
+
+
+async def create_node(node: NodeResponse, by: str):
+ if (await notification_enable()).node:
+ await asyncio.gather(ds.create_node(node, by), tg.create_node(node, by))
+
+
+async def modify_node(node: NodeResponse, by: str):
+ if (await notification_enable()).node:
+ await asyncio.gather(ds.modify_node(node, by), tg.modify_node(node, by))
+
+
+async def remove_node(node: NodeResponse, by: str):
+ if (await notification_enable()).node:
+ await asyncio.gather(ds.remove_node(node, by), tg.remove_node(node, by))
+
+
+async def connect_node(node: NodeResponse):
+ if (await notification_enable()).node:
+ await asyncio.gather(ds.connect_node(node), tg.connect_node(node))
+
+
+async def error_node(node: NodeResponse):
+ if (await notification_enable()).node:
+ await asyncio.gather(ds.error_node(node), tg.error_node(node))
+
+
+async def create_group(group: GroupResponse, by: str):
+ if (await notification_enable()).group:
+ await asyncio.gather(ds.create_group(group, by), tg.create_group(group, by))
+
+
+async def modify_group(group: GroupResponse, by: str):
+ if (await notification_enable()).group:
+ await asyncio.gather(ds.modify_group(group, by), tg.modify_group(group, by))
+
+
+async def remove_group(group_id: int, by: str):
+ if (await notification_enable()).group:
+ await asyncio.gather(ds.remove_group(group_id, by), tg.remove_group(group_id, by))
+
+
+async def create_admin(admin: AdminDetails, by: str):
+ if (await notification_enable()).admin:
+ await asyncio.gather(ds.create_admin(admin, by), tg.create_admin(admin, by))
+
+
+async def modify_admin(admin: AdminDetails, by: str):
+ if (await notification_enable()).admin:
+ await asyncio.gather(ds.modify_admin(admin, by), tg.modify_admin(admin, by))
+
+
+async def remove_admin(username: str, by: str):
+ if (await notification_enable()).admin:
+ await asyncio.gather(ds.remove_admin(username, by), tg.remove_admin(username, by))
+
+
+async def admin_usage_reset(admin: AdminDetails, by: str):
+ if (await notification_enable()).admin:
+ await asyncio.gather(ds.admin_reset_usage(admin, by), tg.admin_reset_usage(admin, by))
+
+
+async def admin_login(username: str, password: str, client_ip: str, success: bool):
+ if (await notification_enable()).login:
+ await asyncio.gather(
+ ds.admin_login(username, password, client_ip, success),
+ tg.admin_login(username, password, client_ip, success),
+ )
+
+
+async def user_status_change(user: UserNotificationResponse, by: AdminDetails):
+ if (await notification_enable()).user:
+ await asyncio.gather(
+ ds.user_status_change(user, by.username), tg.user_status_change(user, by.username), wh.status_change(user)
+ )
+
+
+async def create_user(user: UserNotificationResponse, by: AdminDetails):
+ if (await notification_enable()).user:
+ await asyncio.gather(
+ ds.create_user(user, by.username),
+ tg.create_user(user, by.username),
+ wh.notify(wh.UserCreated(username=user.username, user=user, by=by)),
+ )
+
+
+async def modify_user(user: UserNotificationResponse, by: AdminDetails):
+ if (await notification_enable()).user:
+ await asyncio.gather(
+ ds.modify_user(user, by.username),
+ tg.modify_user(user, by.username),
+ wh.notify(wh.UserUpdated(username=user.username, user=user, by=by)),
+ )
+
+
+async def remove_user(user: UserNotificationResponse, by: AdminDetails):
+ if (await notification_enable()).user:
+ await asyncio.gather(
+ ds.remove_user(user, by.username),
+ tg.remove_user(user, by.username),
+ wh.notify(wh.UserDeleted(username=user.username, user=user, by=by)),
+ )
+
+
+async def reset_user_data_usage(user: UserNotificationResponse, by: AdminDetails):
+ if (await notification_enable()).user:
+ await asyncio.gather(
+ ds.reset_user_data_usage(user, by.username),
+ tg.reset_user_data_usage(user, by.username),
+ wh.notify(wh.UserDataUsageReset(username=user.username, user=user, by=by)),
+ )
+
+
+async def user_data_reset_by_next(user: UserNotificationResponse, by: AdminDetails):
+ if (await notification_enable()).user:
+ await asyncio.gather(
+ ds.user_data_reset_by_next(user, by.username),
+ tg.user_data_reset_by_next(user, by.username),
+ wh.notify(wh.UserDataResetByNext(username=user.username, user=user, by=by)),
+ )
+
+
+async def user_subscription_revoked(user: UserNotificationResponse, by: AdminDetails):
+ if (await notification_enable()).user:
+ await asyncio.gather(
+ ds.user_subscription_revoked(user, by.username),
+ tg.user_subscription_revoked(user, by.username),
+ wh.notify(wh.UserSubscriptionRevoked(username=user.username, user=user, by=by)),
+ )
+
+
+async def create_core(core: CoreResponse, by: str):
+ if (await notification_enable()).core:
+ await asyncio.gather(ds.create_core(core, by), tg.create_core(core, by))
+
+
+async def modify_core(core: CoreResponse, by: str):
+ if (await notification_enable()).core:
+ await asyncio.gather(ds.modify_core(core, by), tg.modify_core(core, by))
+
+
+async def remove_core(core_id: int, by: str):
+ if (await notification_enable()).core:
+ await asyncio.gather(ds.remove_core(core_id, by), tg.remove_core(core_id, by))
diff --git a/app/notification/client.py b/app/notification/client.py
new file mode 100644
index 000000000..8bfb4b960
--- /dev/null
+++ b/app/notification/client.py
@@ -0,0 +1,113 @@
+import httpx
+import asyncio
+
+from app.models.settings import NotificationSettings
+from app.settings import notification_settings
+from app.utils.logger import get_logger
+from app import on_startup
+
+
+client = None
+
+
+async def define_client():
+ """
+ Re-create the global httpx.AsyncClient.
+ Call this function after changing the proxy setting.
+ """
+ global client
+ if client and not client.is_closed:
+ asyncio.create_task(client.aclose())
+ client = httpx.AsyncClient(
+ http2=True,
+ timeout=httpx.Timeout(10),
+ proxy=(await notification_settings()).proxy_url,
+ )
+
+
+on_startup(define_client)
+
+logger = get_logger("Notification")
+
+
+async def send_discord_webhook(json_data, webhook):
+ max_retries = (await notification_settings()).max_retries
+ retries = 0
+ while retries < max_retries:
+ try:
+ response = await client.post(webhook, json=json_data)
+ if response.status_code in [200, 204]:
+ logger.debug(f"Discord webhook payload delivered successfully, code {response.status_code}.")
+ return
+ elif response.status_code == 429:
+ retries += 1
+ if retries < max_retries:
+ await asyncio.sleep(0.5)
+ continue
+ else:
+ response_text = response.text
+ logger.error(f"Discord webhook failed: {response.status_code} - {response_text}")
+ return
+ except Exception as err:
+ logger.error(f"Discord webhook failed Exception: {str(err)}")
+ return
+
+ logger.error(f"Discord webhook failed after {max_retries} retries")
+
+
+async def send_telegram_message(
+ message, chat_id: int | None = None, channel_id: int | None = None, topic_id: int | None = None
+):
+ """
+ Send a message to Telegram based on the available IDs.
+ Args:
+ message (str): The message to send
+ chat_id (int, optional): The chat ID for direct messages
+ channel_id (int, optional): The channel ID for channel messages
+ topic_id (int, optional): The topic ID for forum topics in channels
+ Returns:
+ bool: True if message was sent successfully, False otherwise
+ """
+ # Ensure TELEGRAM_API_TOKEN is available
+ settings: NotificationSettings = await notification_settings()
+ if not settings.telegram_api_token:
+ logger.error("TELEGRAM_API_TOKEN is not defined")
+ return
+
+ base_url = f"https://api.telegram.org/bot{settings.telegram_api_token}/sendMessage"
+ payload = {"parse_mode": "HTML", "text": message}
+
+ # Determine the target chat/channel/topic
+ if topic_id and channel_id:
+ payload["chat_id"] = channel_id
+ payload["message_thread_id"] = topic_id
+ elif channel_id:
+ payload["chat_id"] = channel_id
+ elif chat_id:
+ payload["chat_id"] = chat_id
+ else:
+ logger.error("At least one of chat_id, channel_id must be provided")
+ return
+
+ max_retries = settings.max_retries
+ retries = 0
+ while retries < max_retries:
+ try:
+ response = await client.post(base_url, data=payload)
+ if response.status_code == 200:
+ logger.debug(f"Telegram message sent successfully, code {response.status_code}.")
+ return
+ elif response.status_code == 429:
+ retries += 1
+ if retries < max_retries:
+ await asyncio.sleep(0.5)
+ continue
+ else:
+ response_text = response.text
+ logger.error(f"Telegram message failed: {response.status_code} - {response_text}")
+ return
+ except Exception as err:
+ logger.error(f"Telegram message failed: {str(err)}")
+ return
+
+ logger.error(f"Telegram message failed after {max_retries} retries")
diff --git a/app/notification/discord/__init__.py b/app/notification/discord/__init__.py
new file mode 100644
index 000000000..94f5c9bd7
--- /dev/null
+++ b/app/notification/discord/__init__.py
@@ -0,0 +1,48 @@
+from .host import create_host, modify_host, remove_host, modify_hosts
+from .user_template import create_user_template, modify_user_template, remove_user_template
+from .node import create_node, modify_node, remove_node, connect_node, error_node
+from .group import create_group, modify_group, remove_group
+from .core import create_core, modify_core, remove_core
+from .admin import create_admin, modify_admin, remove_admin, admin_reset_usage, admin_login
+from .user import (
+ user_status_change,
+ create_user,
+ modify_user,
+ remove_user,
+ reset_user_data_usage,
+ user_data_reset_by_next,
+ user_subscription_revoked,
+)
+
+__all__ = [
+ "create_host",
+ "modify_host",
+ "remove_host",
+ "modify_hosts",
+ "create_user_template",
+ "modify_user_template",
+ "remove_user_template",
+ "create_node",
+ "modify_node",
+ "remove_node",
+ "connect_node",
+ "error_node",
+ "create_group",
+ "modify_group",
+ "remove_group",
+ "create_core",
+ "modify_core",
+ "remove_core",
+ "create_admin",
+ "modify_admin",
+ "remove_admin",
+ "admin_reset_usage",
+ "admin_login",
+ "user_status_change",
+ "create_user",
+ "modify_user",
+ "remove_user",
+ "reset_user_data_usage",
+ "user_data_reset_by_next",
+ "user_subscription_revoked",
+]
diff --git a/app/notification/discord/admin.py b/app/notification/discord/admin.py
new file mode 100644
index 000000000..ed26f146d
--- /dev/null
+++ b/app/notification/discord/admin.py
@@ -0,0 +1,97 @@
+import copy
+
+from app.notification.client import send_discord_webhook
+from app.models.admin import AdminDetails
+from app.models.settings import NotificationSettings
+from app.settings import notification_settings
+from app.utils.helpers import escape_ds_markdown_list
+
+from . import colors, messages
+
+
+async def create_admin(admin: AdminDetails, by: str):
+ username, by = escape_ds_markdown_list((admin.username, by))
+ message = copy.deepcopy(messages.CREATE_ADMIN)
+ message["description"] = message["description"].format(
+ username=username,
+ is_sudo=admin.is_sudo,
+ is_disabled=admin.is_disabled,
+ used_traffic=admin.used_traffic,
+ )
+ message["footer"]["text"] = message["footer"]["text"].format(by=by)
+ data = {
+ "content": "",
+ "embeds": [message],
+ }
+ data["embeds"][0]["color"] = colors.GREEN
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_discord:
+ await send_discord_webhook(data, settings.discord_webhook_url)
+
+
+async def modify_admin(admin: AdminDetails, by: str):
+ username, by = escape_ds_markdown_list((admin.username, by))
+ message = copy.deepcopy(messages.MODIFY_ADMIN)
+ message["description"] = message["description"].format(
+ username=username,
+ is_sudo=admin.is_sudo,
+ is_disabled=admin.is_disabled,
+ used_traffic=admin.used_traffic,
+ )
+ message["footer"]["text"] = message["footer"]["text"].format(by=by)
+ data = {
+ "content": "",
+ "embeds": [message],
+ }
+ data["embeds"][0]["color"] = colors.YELLOW
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_discord:
+ await send_discord_webhook(data, settings.discord_webhook_url)
+
+
+async def remove_admin(username: str, by: str):
+ username, by = escape_ds_markdown_list((username, by))
+ message = copy.deepcopy(messages.REMOVE_ADMIN)
+ message["description"] = message["description"].format(username=username)
+ message["footer"]["text"] = message["footer"]["text"].format(by=by)
+ data = {
+ "content": "",
+ "embeds": [message],
+ }
+ data["embeds"][0]["color"] = colors.RED
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_discord:
+ await send_discord_webhook(data, settings.discord_webhook_url)
+
+
+async def admin_reset_usage(admin: AdminDetails, by: str):
+ username, by = escape_ds_markdown_list((admin.username, by))
+ message = copy.deepcopy(messages.ADMIN_RESET_USAGE)
+ message["description"] = message["description"].format(username=username)
+ message["footer"]["text"] = message["footer"]["text"].format(by=by)
+ data = {
+ "content": "",
+ "embeds": [message],
+ }
+ data["embeds"][0]["color"] = colors.CYAN
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_discord:
+ await send_discord_webhook(data, settings.discord_webhook_url)
+
+
+async def admin_login(username: str, password: str, client_ip: str, success: bool):
+ username, password = escape_ds_markdown_list((username, password))
+ message = copy.deepcopy(messages.ADMIN_LOGIN)
+ message["description"] = message["description"].format(
+ username=username,
+ password="🔒" if success else password,
+ client_ip=client_ip,
+ )
+ message["footer"]["text"] = message["footer"]["text"].format(status="Successful" if success else "Failed")
+ data = {
+ "embeds": [message],
+ }
+ data["embeds"][0]["color"] = colors.GREEN if success else colors.RED
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_discord:
+ await send_discord_webhook(data, settings.discord_webhook_url)
diff --git a/app/notification/discord/colors.py b/app/notification/discord/colors.py
new file mode 100644
index 000000000..56294e2ca
--- /dev/null
+++ b/app/notification/discord/colors.py
@@ -0,0 +1,7 @@
+RED = 0xFF4F4F
+GREEN = 0x4FFF4F
+BLUE = 0x4F4FFF
+YELLOW = 0xFFFF4F
+PURPLE = 0xFF4FFF
+CYAN = 0x4FFFFF
+WHITE = 0xFFFFFF
diff --git a/app/notification/discord/core.py b/app/notification/discord/core.py
new file mode 100644
index 000000000..8a573594b
--- /dev/null
+++ b/app/notification/discord/core.py
@@ -0,0 +1,63 @@
+import copy
+
+from app.notification.client import send_discord_webhook
+from app.models.core import CoreResponse
+from app.models.settings import NotificationSettings
+from app.settings import notification_settings
+from app.utils.helpers import escape_ds_markdown
+
+from . import colors, messages
+from .utils import escape_md_core
+
+
+async def create_core(core: CoreResponse, by: str):
+ name, exclude_inbound_tags, fallbacks_inbound_tags, by = escape_md_core(core, by)
+ message = copy.deepcopy(messages.CREATE_CORE)
+ message["description"] = message["description"].format(
+ name=name,
+ exclude_inbound_tags=exclude_inbound_tags,
+ fallbacks_inbound_tags=fallbacks_inbound_tags,
+ )
+ message["footer"]["text"] = message["footer"]["text"].format(id=core.id, by=by)
+ data = {
+ "content": "",
+ "embeds": [message],
+ }
+ data["embeds"][0]["color"] = colors.GREEN
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_discord:
+ await send_discord_webhook(data, settings.discord_webhook_url)
+
+
+async def modify_core(core: CoreResponse, by: str):
+ name, exclude_inbound_tags, fallbacks_inbound_tags, by = escape_md_core(core, by)
+ message = copy.deepcopy(messages.MODIFY_CORE)
+ message["description"] = message["description"].format(
+ name=name,
+ exclude_inbound_tags=exclude_inbound_tags,
+ fallbacks_inbound_tags=fallbacks_inbound_tags,
+ )
+ message["footer"]["text"] = message["footer"]["text"].format(id=core.id, by=by)
+ data = {
+ "content": "",
+ "embeds": [message],
+ }
+ data["embeds"][0]["color"] = colors.YELLOW
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_discord:
+ await send_discord_webhook(data, settings.discord_webhook_url)
+
+
+async def remove_core(core_id: int, by: str):
+ by = escape_ds_markdown(by)
+ message = copy.deepcopy(messages.REMOVE_CORE)
+ message["description"] = message["description"].format(id=core_id)
+ message["footer"]["text"] = message["footer"]["text"].format(by=by)
+ data = {
+ "content": "",
+ "embeds": [message],
+ }
+ data["embeds"][0]["color"] = colors.RED
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_discord:
+ await send_discord_webhook(data, settings.discord_webhook_url)
diff --git a/app/notification/discord/group.py b/app/notification/discord/group.py
new file mode 100644
index 000000000..796fba2e4
--- /dev/null
+++ b/app/notification/discord/group.py
@@ -0,0 +1,62 @@
+import copy
+
+from app.notification.client import send_discord_webhook
+from app.models.group import GroupResponse
+from app.models.settings import NotificationSettings
+from app.settings import notification_settings
+from app.utils.helpers import escape_ds_markdown_list, escape_ds_markdown
+
+from . import colors, messages
+
+
+async def create_group(group: GroupResponse, by: str):
+ name, by = escape_ds_markdown_list((group.name, by))
+ message = copy.deepcopy(messages.CREATE_GROUP)
+ message["description"] = message["description"].format(
+ name=name,
+ inbound_tags=group.inbound_tags,
+ is_disabled=group.is_disabled,
+ )
+ message["footer"]["text"] = message["footer"]["text"].format(id=group.id, by=by)
+ data = {
+ "content": "",
+ "embeds": [message],
+ }
+ data["embeds"][0]["color"] = colors.GREEN
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_discord:
+ await send_discord_webhook(data, settings.discord_webhook_url)
+
+
+async def modify_group(group: GroupResponse, by: str):
+ name, by = escape_ds_markdown_list((group.name, by))
+ message = copy.deepcopy(messages.MODIFY_GROUP)
+ message["description"] = message["description"].format(
+ name=name,
+ inbound_tags=group.inbound_tags,
+ is_disabled=group.is_disabled,
+ )
+ message["footer"]["text"] = message["footer"]["text"].format(id=group.id, by=by)
+ data = {
+ "content": "",
+ "embeds": [message],
+ }
+ data["embeds"][0]["color"] = colors.YELLOW
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_discord:
+ await send_discord_webhook(data, settings.discord_webhook_url)
+
+
+async def remove_group(group_id: int, by: str):
+ by = escape_ds_markdown(by)
+ message = copy.deepcopy(messages.REMOVE_GROUP)
+ message["description"] = message["description"].format(id=group_id)
+ message["footer"]["text"] = message["footer"]["text"].format(by=by)
+ data = {
+ "content": "",
+ "embeds": [message],
+ }
+ data["embeds"][0]["color"] = colors.RED
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_discord:
+ await send_discord_webhook(data, settings.discord_webhook_url)
diff --git a/app/notification/discord/host.py b/app/notification/discord/host.py
new file mode 100644
index 000000000..788baadb5
--- /dev/null
+++ b/app/notification/discord/host.py
@@ -0,0 +1,73 @@
+import copy
+
+from app.notification.client import send_discord_webhook
+from app.models.host import BaseHost
+from app.models.settings import NotificationSettings
+from app.settings import notification_settings
+from app.utils.helpers import escape_ds_markdown_list, escape_ds_markdown
+
+from .utils import escape_md_host
+from . import colors, messages
+
+
+async def create_host(host: BaseHost, by: str):
+ remark, address, inbound_tag, by = escape_md_host(host, by)
+ message = copy.deepcopy(messages.CREATE_HOST)
+ message["description"] = message["description"].format(
+ remark=remark, address=address, inbound_tag=inbound_tag, port=host.port
+ )
+ message["footer"]["text"] = message["footer"]["text"].format(id=host.id, by=by)
+ data = {
+ "content": "",
+ "embeds": [message],
+ }
+ data["embeds"][0]["color"] = colors.GREEN
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_discord:
+ await send_discord_webhook(data, settings.discord_webhook_url)
+
+
+async def modify_host(host: BaseHost, by: str):
+ remark, address, inbound_tag, by = escape_md_host(host, by)
+ message = copy.deepcopy(messages.MODIFY_HOST)
+ message["description"] = message["description"].format(
+ remark=remark, address=address, inbound_tag=inbound_tag, port=host.port
+ )
+ message["footer"]["text"] = message["footer"]["text"].format(id=host.id, by=by)
+ data = {
+ "content": "",
+ "embeds": [message],
+ }
+ data["embeds"][0]["color"] = colors.YELLOW
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_discord:
+ await send_discord_webhook(data, settings.discord_webhook_url)
+
+
+async def remove_host(host: BaseHost, by: str):
+ remark, by = escape_ds_markdown_list((host.remark, by))
+ message = copy.deepcopy(messages.REMOVE_HOST)
+ message["description"] = message["description"].format(remark=remark)
+ message["footer"]["text"] = message["footer"]["text"].format(id=host.id, by=by)
+ data = {
+ "content": "",
+ "embeds": [message],
+ }
+ data["embeds"][0]["color"] = colors.RED
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_discord:
+ await send_discord_webhook(data, settings.discord_webhook_url)
+
+
+async def modify_hosts(by: str):
+ by = escape_ds_markdown(by)
+ message = copy.deepcopy(messages.MODIFY_HOSTS)
+ message["description"] = message["description"].format(by=by)
+ data = {
+ "content": "",
+ "embeds": [message],
+ }
+ data["embeds"][0]["color"] = colors.CYAN
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_discord:
+ await send_discord_webhook(data, settings.discord_webhook_url)
diff --git a/app/notification/discord/messages.py b/app/notification/discord/messages.py
new file mode 100644
index 000000000..a27af4761
--- /dev/null
+++ b/app/notification/discord/messages.py
@@ -0,0 +1,252 @@
+# In this file, we define message templates for Discord notifications.
+# Using templates helps to avoid string concatenation and improves code readability.
+
+USER_CREATED = {
+ "title": "New User Created",
+ "fields": [
+ {"name": "Username", "value": "{username}", "inline": True},
+ {"name": "Data Limit", "value": "{data_limit}", "inline": True},
+ {"name": "Expire Date", "value": "{expire_date}", "inline": True},
+ ],
+}
+
+USER_UPDATED = {
+ "title": "User Updated",
+ "fields": [
+ {"name": "Username", "value": "{username}", "inline": True},
+ {"name": "Data Limit", "value": "{data_limit}", "inline": True},
+ {"name": "Expire Date", "value": "{expire_date}", "inline": True},
+ ],
+}
+
+USER_DELETED = {
+ "title": "User Deleted",
+ "fields": [
+ {"name": "Username", "value": "{username}", "inline": True},
+ ],
+}
+
+USER_EXPIRED = {
+ "title": "User Expired",
+ "fields": [
+ {"name": "Username", "value": "{username}", "inline": True},
+ ],
+}
+
+USER_LIMITED = {
+ "title": "User Data Usage Limited",
+ "fields": [
+ {"name": "Username", "value": "{username}", "inline": True},
+ ],
+}
+
+USER_STATUS_CHANGE = {
+ "title": "{status}",
+ "description": "**Username:** {username}\n",
+ "footer": {"text": "Belongs To:{admin_username}\nBy: {by}"},
+}
+
+CREATE_USER = {
+ "title": "🆕 Create User",
+ "description": "**Username:** {username}\n"
+ + "**Data Limit**: {data_limit}\n"
+ + "**Expire Date:** {expire_date}\n"
+ + "**Data Limit Reset Strategy:** {data_limit_reset_strategy}\n"
+ + "**Has Next Plan**: {has_next_plan}",
+ "footer": {"text": "Belongs To:{admin_username}\nBy: {by}"},
+}
+
+MODIFY_USER = {
+ "title": "✏️ Modify User",
+ "description": "**Username:** {username}\n"
+ + "**Data Limit**: {data_limit}\n"
+ + "**Expire Date:** {expire_date}\n"
+ + "**Data Limit Reset Strategy:** {data_limit_reset_strategy}\n"
+ + "**Has Next Plan**: {has_next_plan}",
+ "footer": {"text": "Belongs To:{admin_username}\nBy: {by}"},
+}
+
+REMOVE_USER = {
+ "title": "🗑️ Remove User",
+ "description": "**Username:** {username}\n",
+ "footer": {"text": "ID: {id}\nBelongs To:{admin_username}\nBy: {by}"},
+}
+
+RESET_USER_DATA_USAGE = {
+ "title": "🔁 Reset User Data Usage",
+ "description": "**Username:** {username}\n" + "**Data Limit**: {data_limit}\n",
+ "footer": {"text": "ID: {id}\nBelongs To:{admin_username}\nBy: {by}"},
+}
+
+USER_DATA_RESET_BY_NEXT = {
+ "title": "🔁 Reset User",
+ "description": "**Username:** {username}\n" + "**Data Limit**: {data_limit}\n" + "**Expire Date:** {expire_date}",
+ "footer": {"text": "ID: {id}\nBelongs To:{admin_username}\nBy: {by}"},
+}
+
+USER_SUBSCRIPTION_REVOKED = {
+ "title": "🛑 Revoke User Subscribtion",
+ "description": "**Username:** {username}\n",
+ "footer": {"text": "ID: {id}\nBelongs To:{admin_username}\nBy: {by}"},
+}
+
+CREATE_ADMIN = {
+ "title": "Create Admin",
+ "description": "**Username:** {username}\n"
+ + "**Is Sudo:** {is_sudo}\n"
+ + "**Is Disabled:** {is_disabled}\n"
+ + "**Used Traffic:** {used_traffic}\n",
+ "footer": {"text": "By: {by}"},
+}
+
+MODIFY_ADMIN = {
+ "title": "Modify Admin",
+ "description": "**Username:** {username}\n"
+ + "**Is Sudo:** {is_sudo}\n"
+ + "**Is Disabled:** {is_disabled}\n"
+ + "**Used Traffic:** {used_traffic}\n",
+ "footer": {"text": "By: {by}"},
+}
+
+REMOVE_ADMIN = {
+ "title": "Remove Admin",
+ "description": "**Username:** {username}\n",
+ "footer": {"text": "By: {by}"},
+}
+
+ADMIN_RESET_USAGE = {
+ "title": "Admin Reset Usage",
+ "description": "**Username:** {username}\n",
+ "footer": {"text": "By: {by}"},
+}
+
+ADMIN_LOGIN = {
+ "title": "Login Attempt",
+ "description": "**Username:** {username}\n**Password:** {password}\n**IP:** {client_ip}",
+ "footer": {"text": "{status}"},
+}
+
+CREATE_HOST = {
+ "title": "Create Host",
+ "description": "**Remark:** {remark}\n"
+ + "**Address:** {address}\n"
+ + "**Inbound Tag:** {inbound_tag}\n"
+ + "**Port:** {port}",
+ "footer": {"text": "ID: {id}\nBy: {by}"},
+}
+
+MODIFY_HOST = {
+ "title": "Modify Host",
+ "description": "**Remark:** {remark}\n"
+ + "**Address:** {address}\n"
+ + "**Inbound Tag:** {inbound_tag}\n"
+ + "**Port:** {port}",
+ "footer": {"text": "ID: {id}\nBy: {by}"},
+}
+
+REMOVE_HOST = {
+ "title": "Remove Host",
+ "description": "**Remark:** {remark}",
+ "footer": {"text": "ID: {id}\nBy: {by}"},
+}
+
+MODIFY_HOSTS = {
+ "title": "Modify Hosts",
+ "description": "All hosts has been updated by **{by}**",
+}
+
+CREATE_NODE = {
+ "title": "Create Node",
+ "description": "**Name:** {name}\n**Address:** {address}\n**Port:** {port}",
+ "footer": {"text": "ID: {id}\nBy: {by}"},
+}
+
+MODIFY_NODE = {
+ "title": "Modify Node",
+ "description": "**Name:** {name}\n**Address:** {address}\n**Port:** {port}",
+ "footer": {"text": "ID: {id}\nBy: {by}"},
+}
+
+REMOVE_NODE = {
+ "title": "Remove Node",
+ "description": "**Name:** {name}\n**Address:** {address}\n**Port:** {port}",
+ "footer": {"text": "ID: {id}\nBy: {by}"},
+}
+
+CONNECT_NODE = {
+ "title": "Connect Node",
+ "description": "**Name:** {name}\n" + "**Node Version:** {node_version}\n" + "**Core Version:** {core_version}",
+ "footer": {"text": "ID: {id}"},
+}
+
+ERROR_NODE = {
+ "title": "Error Node",
+ "description": "**Name:** {name}\n**Error:** {error}",
+ "footer": {"text": "ID: {id}"},
+}
+
+CREATE_USER_TEMPLATE = {
+ "title": "Create User Template",
+ "description": "**Name:** {name}\n"
+ + "**Data Limit**: {data_limit}\n"
+ + "**Expire Duration**: {expire_duration}\n"
+ + "**Username Prefix**: {username_prefix}\n"
+ + "**Username Suffix**: {username_suffix}\n",
+ "footer": {"text": "By: {by}"},
+}
+
+MODIFY_USER_TEMPLATE = {
+ "title": "Modify User Template",
+ "description": "**Name:** {name}\n"
+ + "**Data Limit**: {data_limit}\n"
+ + "**Expire Duration**: {expire_duration}\n"
+ + "**Username Prefix**: {username_prefix}\n"
+ + "**Username Suffix**: {username_suffix}\n",
+ "footer": {"text": "By: {by}"},
+}
+
+REMOVE_USER_TEMPLATE = {
+ "title": "Remove User Template",
+ "description": "**Name:** {name}\n",
+ "footer": {"text": "By: {by}"},
+}
+
+CREATE_CORE = {
+ "title": "Create core",
+ "description": "**Name:** {name}\n"
+ + "**Exclude inbound tags:** {exclude_inbound_tags}\n"
+ + "**Fallbacks inbound tags:** {fallbacks_inbound_tags}\n",
+ "footer": {"text": "ID: {id}\nBy: {by}"},
+}
+
+MODIFY_CORE = {
+ "title": "Modify core",
+ "description": "**Name:** {name}\n"
+ + "**Exclude inbound tags:** {exclude_inbound_tags}\n"
+ + "**Fallbacks inbound tags:** {fallbacks_inbound_tags}\n",
+ "footer": {"text": "ID: {id}\nBy: {by}"},
+}
+
+REMOVE_CORE = {
+ "title": "Remove core",
+ "description": "**ID:** {id}",
+ "footer": {"text": "By: {by}"},
+}
+
+CREATE_GROUP = {
+ "title": "Create Group",
+ "description": "**Name:** {name}\n" + "**Inbound Tags:** {inbound_tags}\n" + "**Is Disabled:** {is_disabled}\n",
+ "footer": {"text": "ID: {id}\nBy: {by}"},
+}
+
+MODIFY_GROUP = {
+ "title": "Modify Group",
+ "description": "**Name:** {name}\n" + "**Inbound Tags:** {inbound_tags}\n" + "**Is Disabled:** {is_disabled}\n",
+ "footer": {"text": "ID: {id}\nBy: {by}"},
+}
+
+REMOVE_GROUP = {
+ "title": "Remove Group",
+ "description": "**ID:** {id}",
+ "footer": {"text": "By: {by}"},
+}
diff --git a/app/notification/discord/node.py b/app/notification/discord/node.py
new file mode 100644
index 000000000..2fbed9356
--- /dev/null
+++ b/app/notification/discord/node.py
@@ -0,0 +1,86 @@
+import copy
+
+from app.notification.client import send_discord_webhook
+from app.models.node import NodeResponse
+from app.models.settings import NotificationSettings
+from app.settings import notification_settings
+from app.utils.helpers import escape_ds_markdown_list, escape_ds_markdown
+
+from . import colors, messages
+
+
+async def create_node(node: NodeResponse, by: str):
+ name, by = escape_ds_markdown_list((node.name, by))
+ message = copy.deepcopy(messages.CREATE_NODE)
+ message["description"] = message["description"].format(name=name, address=node.address, port=node.port)
+ message["footer"]["text"] = message["footer"]["text"].format(id=node.id, by=by)
+ data = {
+ "content": "",
+ "embeds": [message],
+ }
+ data["embeds"][0]["color"] = colors.GREEN
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_discord:
+ await send_discord_webhook(data, settings.discord_webhook_url)
+
+
+async def modify_node(node: NodeResponse, by: str):
+ name, by = escape_ds_markdown_list((node.name, by))
+ message = copy.deepcopy(messages.MODIFY_NODE)
+ message["description"] = message["description"].format(name=name, address=node.address, port=node.port)
+ message["footer"]["text"] = message["footer"]["text"].format(id=node.id, by=by)
+ data = {
+ "content": "",
+ "embeds": [message],
+ }
+ data["embeds"][0]["color"] = colors.YELLOW
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_discord:
+ await send_discord_webhook(data, settings.discord_webhook_url)
+
+
+async def remove_node(node: NodeResponse, by: str):
+ name, by = escape_ds_markdown_list((node.name, by))
+ message = copy.deepcopy(messages.REMOVE_NODE)
+ message["description"] = message["description"].format(name=name, address=node.address, port=node.port)
+ message["footer"]["text"] = message["footer"]["text"].format(id=node.id, by=by)
+ data = {
+ "content": "",
+ "embeds": [message],
+ }
+ data["embeds"][0]["color"] = colors.RED
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_discord:
+ await send_discord_webhook(data, settings.discord_webhook_url)
+
+
+async def connect_node(node: NodeResponse):
+ name = escape_ds_markdown(node.name)
+ message = copy.deepcopy(messages.CONNECT_NODE)
+ message["description"] = message["description"].format(
+ name=name, node_version=node.node_version, core_version=node.xray_version
+ )
+ message["footer"]["text"] = message["footer"]["text"].format(id=node.id)
+ data = {
+ "content": "",
+ "embeds": [message],
+ }
+ data["embeds"][0]["color"] = colors.GREEN
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_discord:
+ await send_discord_webhook(data, settings.discord_webhook_url)
+
+
+async def error_node(node: NodeResponse):
+ name, node_message = escape_ds_markdown_list((node.name, node.message))
+ message = copy.deepcopy(messages.ERROR_NODE)
+ message["description"] = message["description"].format(name=name, error=node_message)
+ message["footer"]["text"] = message["footer"]["text"].format(id=node.id)
+ data = {
+ "content": "",
+ "embeds": [message],
+ }
+ data["embeds"][0]["color"] = colors.RED
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_discord:
+ await send_discord_webhook(data, settings.discord_webhook_url)
diff --git a/app/notification/discord/user.py b/app/notification/discord/user.py
new file mode 100644
index 000000000..374c56fa6
--- /dev/null
+++ b/app/notification/discord/user.py
@@ -0,0 +1,164 @@
+import copy
+
+from app.notification.client import send_discord_webhook
+from app.models.user import UserNotificationResponse
+from app.utils.system import readable_size
+from app.models.settings import NotificationSettings
+from app.settings import notification_settings
+
+from . import colors, messages
+from .utils import escape_md_user
+
+_status = {
+ "active": "**✅ Activated**",
+ "on_hold": "**🕔 On Hold**",
+ "disabled": "**❌ Disabled**",
+ "limited": "**🪫 Limited**",
+ "expired": "**📅 Expired**",
+}
+_status_color = {
+ "active": colors.GREEN,
+ "on_hold": colors.PURPLE,
+ "disabled": colors.WHITE,
+ "limited": colors.RED,
+ "expired": colors.YELLOW,
+}
+
+
+async def user_status_change(user: UserNotificationResponse, by: str):
+ username, admin_username, by = escape_md_user(user, by)
+ message = copy.deepcopy(messages.USER_STATUS_CHANGE)
+ message["title"] = message["title"].format(status=_status[user.status.value])
+ message["description"] = message["description"].format(username=username)
+ message["footer"]["text"] = message["footer"]["text"].format(admin_username=admin_username, by=by)
+ data = {
+ "content": "",
+ "embeds": [message],
+ }
+ data["embeds"][0]["color"] = _status_color[user.status.value]
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_discord:
+ await send_discord_webhook(data, settings.discord_webhook_url)
+ if user.admin and user.admin.discord_webhook:
+ await send_discord_webhook(data, user.admin.discord_webhook)
+
+
+async def create_user(user: UserNotificationResponse, by: str):
+ username, admin_username, by = escape_md_user(user, by)
+ message = copy.deepcopy(messages.CREATE_USER)
+ message["description"] = message["description"].format(
+ username=username,
+ data_limit=readable_size(user.data_limit) if user.data_limit else "Unlimited",
+ expire_date=user.expire if user.expire else "Never",
+ data_limit_reset_strategy=user.data_limit_reset_strategy.value,
+ has_next_plan=bool(user.next_plan),
+ )
+ message["footer"]["text"] = message["footer"]["text"].format(admin_username=admin_username, by=by)
+ data = {
+ "content": "",
+ "embeds": [message],
+ }
+ data["embeds"][0]["color"] = colors.GREEN
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_discord:
+ await send_discord_webhook(data, settings.discord_webhook_url)
+ if user.admin and user.admin.discord_webhook:
+ await send_discord_webhook(data, user.admin.discord_webhook)
+
+
+async def modify_user(user: UserNotificationResponse, by: str):
+ username, admin_username, by = escape_md_user(user, by)
+ message = copy.deepcopy(messages.MODIFY_USER)
+ message["description"] = message["description"].format(
+ username=username,
+ data_limit=readable_size(user.data_limit) if user.data_limit else "Unlimited",
+ expire_date=user.expire if user.expire else "Never",
+ data_limit_reset_strategy=user.data_limit_reset_strategy.value,
+ has_next_plan=bool(user.next_plan),
+ )
+ message["footer"]["text"] = message["footer"]["text"].format(admin_username=admin_username, by=by)
+ data = {
+ "content": "",
+ "embeds": [message],
+ }
+ data["embeds"][0]["color"] = colors.YELLOW
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_discord:
+ await send_discord_webhook(data, settings.discord_webhook_url)
+ if user.admin and user.admin.discord_webhook:
+ await send_discord_webhook(data, user.admin.discord_webhook)
+
+
+async def remove_user(user: UserNotificationResponse, by: str):
+ username, admin_username, by = escape_md_user(user, by)
+ message = copy.deepcopy(messages.REMOVE_USER)
+ message["description"] = message["description"].format(username=username)
+ message["footer"]["text"] = message["footer"]["text"].format(id=user.id, admin_username=admin_username, by=by)
+ data = {
+ "content": "",
+ "embeds": [message],
+ }
+ data["embeds"][0]["color"] = colors.RED
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_discord:
+ await send_discord_webhook(data, settings.discord_webhook_url)
+ if user.admin and user.admin.discord_webhook:
+ await send_discord_webhook(data, user.admin.discord_webhook)
+
+
+async def reset_user_data_usage(user: UserNotificationResponse, by: str):
+ username, admin_username, by = escape_md_user(user, by)
+ message = copy.deepcopy(messages.RESET_USER_DATA_USAGE)
+ message["description"] = message["description"].format(
+ username=username,
+ data_limit=readable_size(user.data_limit) if user.data_limit else "Unlimited",
+ )
+ message["footer"]["text"] = message["footer"]["text"].format(id=user.id, admin_username=admin_username, by=by)
+ data = {
+ "content": "",
+ "embeds": [message],
+ }
+ data["embeds"][0]["color"] = colors.CYAN
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_discord:
+ await send_discord_webhook(data, settings.discord_webhook_url)
+ if user.admin and user.admin.discord_webhook:
+ await send_discord_webhook(data, user.admin.discord_webhook)
+
+
+async def user_data_reset_by_next(user: UserNotificationResponse, by: str):
+ username, admin_username, by = escape_md_user(user, by)
+ message = copy.deepcopy(messages.USER_DATA_RESET_BY_NEXT)
+ message["description"] = message["description"].format(
+ username=username,
+ data_limit=readable_size(user.data_limit) if user.data_limit else "Unlimited",
+ expire_date=user.expire if user.expire else "Never",
+ )
+ message["footer"]["text"] = message["footer"]["text"].format(id=user.id, admin_username=admin_username, by=by)
+ data = {
+ "content": "",
+ "embeds": [message],
+ }
+ data["embeds"][0]["color"] = colors.CYAN
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_discord:
+ await send_discord_webhook(data, settings.discord_webhook_url)
+ if user.admin and user.admin.discord_webhook:
+ await send_discord_webhook(data, user.admin.discord_webhook)
+
+
+async def user_subscription_revoked(user: UserNotificationResponse, by: str):
+ username, admin_username, by = escape_md_user(user, by)
+ message = copy.deepcopy(messages.USER_SUBSCRIPTION_REVOKED)
+ message["description"] = message["description"].format(username=username)
+ message["footer"]["text"] = message["footer"]["text"].format(id=user.id, admin_username=admin_username, by=by)
+ data = {
+ "content": "",
+ "embeds": [message],
+ }
+ data["embeds"][0]["color"] = colors.RED
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_discord:
+ await send_discord_webhook(data, settings.discord_webhook_url)
+ if user.admin and user.admin.discord_webhook:
+ await send_discord_webhook(data, user.admin.discord_webhook)
diff --git a/app/notification/discord/user_template.py b/app/notification/discord/user_template.py
new file mode 100644
index 000000000..c7ee9aed0
--- /dev/null
+++ b/app/notification/discord/user_template.py
@@ -0,0 +1,67 @@
+import copy
+
+from app.notification.client import send_discord_webhook
+from app.models.user_template import UserTemplateResponse
+from app.models.settings import NotificationSettings
+from app.settings import notification_settings
+from app.utils.helpers import escape_ds_markdown_list
+
+from . import colors, messages
+from .utils import escape_md_template
+
+
+async def create_user_template(user_tempelate: UserTemplateResponse, by: str):
+ name, username_prefix, username_suffix, by = escape_md_template(user_tempelate, by)
+ message = copy.deepcopy(messages.CREATE_USER_TEMPLATE)
+ message["description"] = message["description"].format(
+ name=name,
+ data_limit=user_tempelate.data_limit,
+ expire_duration=user_tempelate.expire_duration,
+ username_prefix=username_prefix,
+ username_suffix=username_suffix,
+ )
+ message["footer"]["text"] = message["footer"]["text"].format(by=by)
+ data = {
+ "content": "",
+ "embeds": [message],
+ }
+ data["embeds"][0]["color"] = colors.GREEN
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_discord:
+ await send_discord_webhook(data, settings.discord_webhook_url)
+
+
+async def modify_user_template(user_template: UserTemplateResponse, by: str):
+ name, username_prefix, username_suffix, by = escape_md_template(user_template, by)
+ message = copy.deepcopy(messages.MODIFY_USER_TEMPLATE)
+ message["description"] = message["description"].format(
+ name=name,
+ data_limit=user_template.data_limit,
+ expire_duration=user_template.expire_duration,
+ username_prefix=username_prefix,
+ username_suffix=username_suffix,
+ )
+ message["footer"]["text"] = message["footer"]["text"].format(by=by)
+ data = {
+ "content": "",
+ "embeds": [message],
+ }
+ data["embeds"][0]["color"] = colors.YELLOW
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_discord:
+ await send_discord_webhook(data, settings.discord_webhook_url)
+
+
+async def remove_user_template(name: str, by: str):
+ name, by = escape_ds_markdown_list((name, by))
+ message = copy.deepcopy(messages.REMOVE_USER_TEMPLATE)
+ message["description"] = message["description"].format(name=name)
+ message["footer"]["text"] = message["footer"]["text"].format(by=by)
+ data = {
+ "content": "",
+ "embeds": [message],
+ }
+ data["embeds"][0]["color"] = colors.RED
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_discord:
+ await send_discord_webhook(data, settings.discord_webhook_url)
diff --git a/app/notification/discord/utils.py b/app/notification/discord/utils.py
new file mode 100644
index 000000000..86ee3b5a0
--- /dev/null
+++ b/app/notification/discord/utils.py
@@ -0,0 +1,32 @@
+from app.utils.helpers import escape_ds_markdown_list
+from app.models.user import UserNotificationResponse
+from app.models.host import BaseHost
+from app.models.user_template import UserTemplateResponse
+from app.models.core import CoreResponse
+
+
+def escape_md_user(user: UserNotificationResponse, by: str) -> tuple[str, str, str]:
+ """Escapes markdown special characters in user and by strings for Discord."""
+ return escape_ds_markdown_list((user.username, user.admin.username if user.admin else "None", by))
+
+
+def escape_md_host(host: BaseHost, by: str) -> tuple[str, str, str, str]:
+ """Escapes markdown special characters in host and by strings for Discord."""
+ return escape_ds_markdown_list((host.remark, host.address_str, host.inbound_tag, by))
+
+
+def escape_md_template(template: UserTemplateResponse, by: str) -> tuple[str, str, str, str]:
+ """Escapes markdown special characters in template and by strings for Discord."""
+ return escape_ds_markdown_list(
+ (
+ template.name,
+ template.username_prefix if template.username_prefix else "",
+ template.username_suffix if template.username_suffix else "",
+ by,
+ )
+ )
+
+
+def escape_md_core(core: CoreResponse, by: str) -> tuple[str, str, str, str]:
+ """Escapes markdown special characters in core and by strings for Discord."""
+ return escape_ds_markdown_list((core.name, core.exclude_tags, core.fallback_tags, by))
diff --git a/app/notification/telegram/__init__.py b/app/notification/telegram/__init__.py
new file mode 100644
index 000000000..94f5c9bd7
--- /dev/null
+++ b/app/notification/telegram/__init__.py
@@ -0,0 +1,48 @@
+from .host import create_host, modify_host, remove_host, modify_hosts
+from .user_template import create_user_template, modify_user_template, remove_user_template
+from .node import create_node, modify_node, remove_node, connect_node, error_node
+from .group import create_group, modify_group, remove_group
+from .core import create_core, modify_core, remove_core
+from .admin import create_admin, modify_admin, remove_admin, admin_reset_usage, admin_login
+from .user import (
+ user_status_change,
+ create_user,
+ modify_user,
+ remove_user,
+ reset_user_data_usage,
+ user_data_reset_by_next,
+ user_subscription_revoked,
+)
+
+__all__ = [
+ "create_host",
+ "modify_host",
+ "remove_host",
+ "modify_hosts",
+ "create_user_template",
+ "modify_user_template",
+ "remove_user_template",
+ "create_node",
+ "modify_node",
+ "remove_node",
+ "connect_node",
+ "error_node",
+ "create_group",
+ "modify_group",
+ "remove_group",
+ "create_core",
+ "modify_core",
+ "remove_core",
+ "create_admin",
+ "modify_admin",
+ "remove_admin",
+ "admin_reset_usage",
+ "admin_login",
+ "user_status_change",
+ "create_user",
+ "modify_user",
+ "remove_user",
+ "reset_user_data_usage",
+ "user_data_reset_by_next",
+ "user_subscription_revoked",
+]
diff --git a/app/notification/telegram/admin.py b/app/notification/telegram/admin.py
new file mode 100644
index 000000000..10a594593
--- /dev/null
+++ b/app/notification/telegram/admin.py
@@ -0,0 +1,73 @@
+from app.notification.client import send_telegram_message
+from app.models.admin import AdminDetails
+from app.models.settings import NotificationSettings
+from app.settings import notification_settings
+from app.utils.helpers import escape_tg_html
+from . import messages
+
+
+async def create_admin(admin: AdminDetails, by: str):
+ username, by = escape_tg_html((admin.username, by))
+ data = messages.CREATE_ADMIN.format(
+ username=username,
+ is_sudo=admin.is_sudo,
+ is_disabled=admin.is_disabled,
+ used_traffic=admin.used_traffic,
+ by=by,
+ )
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_telegram:
+ await send_telegram_message(
+ data, settings.telegram_admin_id, settings.telegram_channel_id, settings.telegram_topic_id
+ )
+
+
+async def modify_admin(admin: AdminDetails, by: str):
+ username, by = escape_tg_html((admin.username, by))
+ data = messages.MODIFY_ADMIN.format(
+ username=username,
+ is_sudo=admin.is_sudo,
+ is_disabled=admin.is_disabled,
+ used_traffic=admin.used_traffic,
+ by=by,
+ )
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_telegram:
+ await send_telegram_message(
+ data, settings.telegram_admin_id, settings.telegram_channel_id, settings.telegram_topic_id
+ )
+
+
+async def remove_admin(username: str, by: str):
+ username, by = escape_tg_html((username, by))
+ data = messages.REMOVE_ADMIN.format(username=username, by=by)
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_telegram:
+ await send_telegram_message(
+ data, settings.telegram_admin_id, settings.telegram_channel_id, settings.telegram_topic_id
+ )
+
+
+async def admin_reset_usage(admin: AdminDetails, by: str):
+ username, by = escape_tg_html((admin.username, by))
+ data = messages.ADMIN_RESET_USAGE.format(username=username, by=by)
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_telegram:
+ await send_telegram_message(
+ data, settings.telegram_admin_id, settings.telegram_channel_id, settings.telegram_topic_id
+ )
+
+
+async def admin_login(username: str, password: str, client_ip: str, success: bool):
+ username, password = escape_tg_html((username, password))
+ data = messages.ADMIN_LOGIN.format(
+ status="Successful" if success else "Failed",
+ username=username,
+ password="🔒" if success else password,
+ client_ip=client_ip,
+ )
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_telegram:
+ await send_telegram_message(
+ data, settings.telegram_admin_id, settings.telegram_channel_id, settings.telegram_topic_id
+ )
diff --git a/app/notification/telegram/core.py b/app/notification/telegram/core.py
new file mode 100644
index 000000000..e9f5af290
--- /dev/null
+++ b/app/notification/telegram/core.py
@@ -0,0 +1,46 @@
+from html import escape
+
+from app.notification.client import send_telegram_message
+from app.models.core import CoreResponse
+from app.models.settings import NotificationSettings
+from app.settings import notification_settings
+
+from .utils import escape_html_core
+from . import messages
+
+
+async def create_core(core: CoreResponse, by: str):
+ name, exclude_tags, fallback_tags, by = escape_html_core(core, by)
+ data = messages.CREATE_CORE.format(
+ name=name, exclude_inbound_tags=exclude_tags, fallbacks_inbound_tags=fallback_tags, id=core.id, by=by
+ )
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_telegram:
+ await send_telegram_message(
+ data, settings.telegram_admin_id, settings.telegram_channel_id, settings.telegram_topic_id
+ )
+
+
+async def modify_core(core: CoreResponse, by: str):
+ name, exclude_tags, fallback_tags, by = escape_html_core(core, by)
+ data = messages.MODIFY_CORE.format(
+ name=name,
+ exclude_inbound_tags=exclude_tags,
+ fallbacks_inbound_tags=fallback_tags,
+ id=core.id,
+ by=by,
+ )
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_telegram:
+ await send_telegram_message(
+ data, settings.telegram_admin_id, settings.telegram_channel_id, settings.telegram_topic_id
+ )
+
+
+async def remove_core(core_id: int, by: str):
+ data = messages.REMOVE_CORE.format(id=core_id, by=escape(by))
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_telegram:
+ await send_telegram_message(
+ data, settings.telegram_admin_id, settings.telegram_channel_id, settings.telegram_topic_id
+ )
diff --git a/app/notification/telegram/group.py b/app/notification/telegram/group.py
new file mode 100644
index 000000000..a7dc716a3
--- /dev/null
+++ b/app/notification/telegram/group.py
@@ -0,0 +1,41 @@
+from html import escape
+
+from app.notification.client import send_telegram_message
+from app.models.group import GroupResponse
+from app.models.settings import NotificationSettings
+from app.settings import notification_settings
+from app.utils.helpers import escape_tg_html
+from . import messages
+
+
+async def create_group(group: GroupResponse, by: str):
+ name, by = escape_tg_html((group.name, by))
+ data = messages.CREATE_GROUP.format(
+ name=name, inbound_tags=group.inbound_tags, is_disabled=group.is_disabled, id=group.id, by=by
+ )
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_telegram:
+ await send_telegram_message(
+ data, settings.telegram_admin_id, settings.telegram_channel_id, settings.telegram_topic_id
+ )
+
+
+async def modify_group(group: GroupResponse, by: str):
+ name, by = escape_tg_html((group.name, by))
+ data = messages.MODIFY_GROUP.format(
+ name=name, inbound_tags=group.inbound_tags, is_disabled=group.is_disabled, id=group.id, by=by
+ )
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_telegram:
+ await send_telegram_message(
+ data, settings.telegram_admin_id, settings.telegram_channel_id, settings.telegram_topic_id
+ )
+
+
+async def remove_group(group_id: int, by: str):
+ data = messages.REMOVE_GROUP.format(id=group_id, by=escape(by))
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_telegram:
+ await send_telegram_message(
+ data, settings.telegram_admin_id, settings.telegram_channel_id, settings.telegram_topic_id
+ )
diff --git a/app/notification/telegram/host.py b/app/notification/telegram/host.py
new file mode 100644
index 000000000..fcd0b7919
--- /dev/null
+++ b/app/notification/telegram/host.py
@@ -0,0 +1,49 @@
+from html import escape
+
+from app.notification.client import send_telegram_message
+from app.models.host import BaseHost
+from app.models.settings import NotificationSettings
+from app.settings import notification_settings
+from app.utils.helpers import escape_tg_html
+
+from .utils import escape_html_host
+from . import messages
+
+
+async def create_host(host: BaseHost, by: str):
+ remark, address, tag, by = escape_html_host(host, by)
+ data = messages.CREATE_HOST.format(remark=remark, address=address, tag=tag, port=host.port, id=host.id, by=by)
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_telegram:
+ await send_telegram_message(
+ data, settings.telegram_admin_id, settings.telegram_channel_id, settings.telegram_topic_id
+ )
+
+
+async def modify_host(host: BaseHost, by: str):
+ remark, address, tag, by = escape_html_host(host, by)
+ data = messages.MODIFY_HOST.format(remark=remark, address=address, tag=tag, port=host.port, id=host.id, by=by)
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_telegram:
+ await send_telegram_message(
+ data, settings.telegram_admin_id, settings.telegram_channel_id, settings.telegram_topic_id
+ )
+
+
+async def remove_host(host: BaseHost, by: str):
+ remark, by = escape_tg_html((host.remark, by))
+ data = messages.REMOVE_HOST.format(remark=remark, id=host.id, by=by)
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_telegram:
+ await send_telegram_message(
+ data, settings.telegram_admin_id, settings.telegram_channel_id, settings.telegram_topic_id
+ )
+
+
+async def modify_hosts(by: str):
+ data = messages.MODIFY_HOSTS.format(by=escape(by))
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_telegram:
+ await send_telegram_message(
+ data, settings.telegram_admin_id, settings.telegram_channel_id, settings.telegram_topic_id
+ )
diff --git a/app/notification/telegram/messages.py b/app/notification/telegram/messages.py
new file mode 100644
index 000000000..134b041e6
--- /dev/null
+++ b/app/notification/telegram/messages.py
@@ -0,0 +1,301 @@
+# In this file, we define message templates for Telegram notifications.
+# Using templates helps to avoid string concatenation and improves code readability.
+
+USER_STATUS_CHANGE = """
+{status}
+➖➖➖➖➖➖➖➖➖
+Username:{username}
+➖➖➖➖➖➖➖➖➖
+Belongs To: {admin_username}
+By: #{by}
+"""
+
+CREATE_USER = """
+🆕 #Create_User
+➖➖➖➖➖➖➖➖➖
+Username:{username}
+Data Limit: {data_limit}
+Expire Date:{expire_date}
+Data Limit Reset Strategy:{data_limit_reset_strategy}
+Has Next Plan: {has_next_plan}
+➖➖➖➖➖➖➖➖➖
+Belongs To: {admin_username}
+By: #{by}
+"""
+
+MODIFY_USER = """
+✏️ #Modify_User
+➖➖➖➖➖➖➖➖➖
+Username:{username}
+Data Limit: {data_limit}
+Expire Date:{expire_date}
+Data Limit Reset Strategy:{data_limit_reset_strategy}
+Has Next Plan: {has_next_plan}
+➖➖➖➖➖➖➖➖➖
+Belongs To: {admin_username}
+By: #{by}
+"""
+
+REMOVE_USER = """
+🗑️ #Remove_User
+➖➖➖➖➖➖➖➖➖
+Username:{username}
+➖➖➖➖➖➖➖➖➖
+Belongs To: {admin_username}
+By: #{by}
+"""
+
+RESET_USER_DATA_USAGE = """
+🔁 #Reset_User_Data_Usage
+➖➖➖➖➖➖➖➖➖
+Username:{username}
+Data Limit: {data_limit}
+➖➖➖➖➖➖➖➖➖
+Belongs To: {admin_username}
+By: #{by}
+"""
+
+USER_DATA_RESET_BY_NEXT = """
+🔁 #Reset_User_By_Next
+➖➖➖➖➖➖➖➖➖
+Username:{username}
+Data Limit: {data_limit}
+Expire Date:{expire_date}
+➖➖➖➖➖➖➖➖➖
+Belongs To: {admin_username}
+By: #{by}
+"""
+
+USER_SUBSCRIPTION_REVOKED = """
+🛑 #Revoke_User_Subscribtion
+➖➖➖➖➖➖➖➖➖
+Username:{username}
+➖➖➖➖➖➖➖➖➖
+Belongs To: {admin_username}
+By: #{by}
+"""
+
+CREATE_ADMIN = """
+#Create_Admin
+➖➖➖➖➖➖➖➖➖
+Username:{username}
+Is Sudo:{is_sudo}
+Is Disabled:{is_disabled}
+Used Traffic:{used_traffic}
+➖➖➖➖➖➖➖➖➖
+By: #{by}
+"""
+
+MODIFY_ADMIN = """
+#Modify_Admin
+➖➖➖➖➖➖➖➖➖
+Username:{username}
+Is Sudo:{is_sudo}
+Is Disabled:{is_disabled}
+Used Traffic:{used_traffic}
+➖➖➖➖➖➖➖➖➖
+By: #{by}
+"""
+
+REMOVE_ADMIN = """
+#Remove_Admin
+Username:{username}
+➖➖➖➖➖➖➖➖➖
+By: #{by}
+"""
+
+ADMIN_RESET_USAGE = """
+#Admin_Usage_Reset
+Username:{username}
+➖➖➖➖➖➖➖➖➖
+By: #{by}
+"""
+
+ADMIN_LOGIN = """
+#Login_Attempt
+Status: {status}
+➖➖➖➖➖➖➖➖➖
+Username:{username}
+Password:{password}
+IP:{client_ip}
+"""
+
+CREATE_HOST = """
+#Create_Host
+➖➖➖➖➖➖➖➖➖
+Remark:{remark}
+Address:{address}
+Inbound Tag:{tag}
+Port:{port}
+➖➖➖➖➖➖➖➖➖
+ID: {id}
+By: #{by}
+"""
+
+MODIFY_HOST = """
+#Modify_Host
+➖➖➖➖➖➖➖➖➖
+Remark:{remark}
+Address:{address}
+Inbound Tag:{tag}
+Port:{port}
+➖➖➖➖➖➖➖➖➖
+ID: {id}
+By: #{by}
+"""
+
+REMOVE_HOST = """
+#Remove_Host
+➖➖➖➖➖➖➖➖➖
+Remark:{remark}
+➖➖➖➖➖➖➖➖➖
+ID: {id}
+By: #{by}
+"""
+
+MODIFY_HOSTS = """
+#Modify_Hosts
+➖➖➖➖➖➖➖➖➖
+All hosts has been updated by #{by}
+"""
+
+CREATE_NODE = """
+#Create_Node
+➖➖➖➖➖➖➖➖➖
+ID:{id}
+Name:{name}
+Address:{address}
+Port:{port}
+➖➖➖➖➖➖➖➖➖
+By: #{by}
+"""
+
+MODIFY_NODE = """
+#Modify_Node
+➖➖➖➖➖➖➖➖➖
+ID:{id}
+Name:{name}
+Address:{address}
+Port:{port}
+➖➖➖➖➖➖➖➖➖
+By: #{by}
+"""
+
+REMOVE_NODE = """
+#Remove_Node
+➖➖➖➖➖➖➖➖➖
+ID:{id}
+Name:{name}
+➖➖➖➖➖➖➖➖➖
+By: #{by}
+"""
+
+CONNECT_NODE = """
+#Connect_Node
+➖➖➖➖➖➖➖➖➖
+Name:{name}
+Node Version:{node_version}
+Core Version:{core_version}
+➖➖➖➖➖➖➖➖➖
+ID: {id}
+"""
+
+ERROR_NODE = """
+#Error_Node
+➖➖➖➖➖➖➖➖➖
+Name:{name}
+Error: {error}
+➖➖➖➖➖➖➖➖➖
+ID: {id}
+"""
+
+CREATE_USER_TEMPLATE = """
+#Create_User_Template
+➖➖➖➖➖➖➖➖➖
+Name:{name}
+Data Limit:{data_limit}
+Expire Duration:{expire_duration}
+Username Prefix:{username_prefix}
+Username Suffix:{username_suffix}
+➖➖➖➖➖➖➖➖➖
+By: #{by}
+"""
+
+MODIFY_USER_TEMPLATE = """
+#Modify_User_Template
+➖➖➖➖➖➖➖➖➖
+Name:{name}
+Data Limit:{data_limit}
+Expire Duration:{expire_duration}
+Username Prefix:{username_prefix}
+Username Suffix:{username_suffix}
+➖➖➖➖➖➖➖➖➖
+By: #{by}
+"""
+
+REMOVE_USER_TEMPLATE = """
+#Remove_User_Template
+Name:{name}
+➖➖➖➖➖➖➖➖➖
+By: #{by}
+"""
+
+CREATE_CORE = """
+#Create_core
+➖➖➖➖➖➖➖➖➖
+Name:{name}
+Exclude inbound tags:{exclude_inbound_tags}
+Fallbacks inbound tags:{fallbacks_inbound_tags}
+➖➖➖➖➖➖➖➖➖
+ID: {id}
+By: #{by}
+"""
+
+MODIFY_CORE = """
+#Modify_core
+➖➖➖➖➖➖➖➖➖
+Name:{name}
+Exclude inbound tags:{exclude_inbound_tags}
+Fallbacks inbound tags:{fallbacks_inbound_tags}
+➖➖➖➖➖➖➖➖➖
+ID: {id}
+By: #{by}
+"""
+
+REMOVE_CORE = """
+#Remove_core
+➖➖➖➖➖➖➖➖➖
+ID:{id}
+➖➖➖➖➖➖➖➖➖
+By: #{by}
+"""
+
+CREATE_GROUP = """
+#Create_Group
+➖➖➖➖➖➖➖➖➖
+Name:{name}
+Inbound Tags:{inbound_tags}
+Is Disabled:{is_disabled}
+➖➖➖➖➖➖➖➖➖
+ID: {id}
+By: #{by}
+"""
+
+MODIFY_GROUP = """
+#Modify_Group
+➖➖➖➖➖➖➖➖➖
+Name:{name}
+Inbound Tags:{inbound_tags}
+Is Disabled:{is_disabled}
+➖➖➖➖➖➖➖➖➖
+ID: {id}
+By: #{by}
+"""
+
+REMOVE_GROUP = """
+#Remove_Group
+➖➖➖➖➖➖➖➖➖
+ID:{id}
+➖➖➖➖➖➖➖➖➖
+By: #{by}
+"""
diff --git a/app/notification/telegram/node.py b/app/notification/telegram/node.py
new file mode 100644
index 000000000..d082064c7
--- /dev/null
+++ b/app/notification/telegram/node.py
@@ -0,0 +1,59 @@
+from html import escape
+
+from app.notification.client import send_telegram_message
+from app.models.node import NodeResponse
+from app.models.settings import NotificationSettings
+from app.settings import notification_settings
+from app.utils.helpers import escape_tg_html
+from . import messages
+
+
+async def create_node(node: NodeResponse, by: str):
+ name, by = escape_tg_html((node.name, by))
+ data = messages.CREATE_NODE.format(id=node.id, name=name, address=node.address, port=node.port, by=by)
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_telegram:
+ await send_telegram_message(
+ data, settings.telegram_admin_id, settings.telegram_channel_id, settings.telegram_topic_id
+ )
+
+
+async def modify_node(node: NodeResponse, by: str):
+ name, by = escape_tg_html((node.name, by))
+ data = messages.MODIFY_NODE.format(id=node.id, name=name, address=node.address, port=node.port, by=by)
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_telegram:
+ await send_telegram_message(
+ data, settings.telegram_admin_id, settings.telegram_channel_id, settings.telegram_topic_id
+ )
+
+
+async def remove_node(node: NodeResponse, by: str):
+ name, by = escape_tg_html((node.name, by))
+ data = messages.REMOVE_NODE.format(id=node.id, name=name, by=by)
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_telegram:
+ await send_telegram_message(
+ data, settings.telegram_admin_id, settings.telegram_channel_id, settings.telegram_topic_id
+ )
+
+
+async def connect_node(node: NodeResponse):
+ data = messages.CONNECT_NODE.format(
+ name=escape(node.name), node_version=node.node_version, core_version=node.xray_version, id=node.id
+ )
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_telegram:
+ await send_telegram_message(
+ data, settings.telegram_admin_id, settings.telegram_channel_id, settings.telegram_topic_id
+ )
+
+
+async def error_node(node: NodeResponse):
+ name, message = escape_tg_html((node.name, node.message))
+ data = messages.ERROR_NODE.format(name=name, error=message, id=node.id)
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_telegram:
+ await send_telegram_message(
+ data, settings.telegram_admin_id, settings.telegram_channel_id, settings.telegram_topic_id
+ )
diff --git a/app/notification/telegram/user.py b/app/notification/telegram/user.py
new file mode 100644
index 000000000..6153bb3a7
--- /dev/null
+++ b/app/notification/telegram/user.py
@@ -0,0 +1,140 @@
+from app.notification.client import send_telegram_message
+from app.models.user import UserNotificationResponse
+from app.utils.system import readable_size
+from app.models.settings import NotificationSettings
+from app.settings import notification_settings
+
+from .utils import escape_html_user
+from . import messages
+
+_status = {
+ "active": "✅ #Activated",
+ "on_hold": "🕔 #On_Hold",
+ "disabled": "❌ #Disabled",
+ "limited": "🪫 #Limited",
+ "expired": "📅 #Expired",
+}
+
+
+async def user_status_change(user: UserNotificationResponse, by: str):
+ username, admin_username, by = escape_html_user(user, by)
+ data = messages.USER_STATUS_CHANGE.format(
+ status=_status[user.status.value],
+ username=username,
+ admin_username=admin_username,
+ by=by,
+ )
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_telegram:
+ await send_telegram_message(
+ data, settings.telegram_admin_id, settings.telegram_channel_id, settings.telegram_topic_id
+ )
+ if user.admin and user.admin.telegram_id:
+ await send_telegram_message(data, chat_id=user.admin.telegram_id)
+
+
+async def create_user(user: UserNotificationResponse, by: str):
+ username, admin_username, by = escape_html_user(user, by)
+ data = messages.CREATE_USER.format(
+ username=username,
+ data_limit=readable_size(user.data_limit) if user.data_limit else "Unlimited",
+ expire_date=user.expire if user.expire else "Never",
+ data_limit_reset_strategy=user.data_limit_reset_strategy.value,
+ has_next_plan=bool(user.next_plan),
+ admin_username=admin_username,
+ by=by,
+ )
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_telegram:
+ await send_telegram_message(
+ data, settings.telegram_admin_id, settings.telegram_channel_id, settings.telegram_topic_id
+ )
+ if user.admin and user.admin.telegram_id:
+ await send_telegram_message(data, chat_id=user.admin.telegram_id)
+
+
+async def modify_user(user: UserNotificationResponse, by: str):
+ username, admin_username, by = escape_html_user(user, by)
+ data = messages.MODIFY_USER.format(
+ username=username,
+ data_limit=readable_size(user.data_limit) if user.data_limit else "Unlimited",
+ expire_date=user.expire if user.expire else "Never",
+ data_limit_reset_strategy=user.data_limit_reset_strategy.value,
+ has_next_plan=bool(user.next_plan),
+ admin_username=admin_username,
+ by=by,
+ )
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_telegram:
+ await send_telegram_message(
+ data, settings.telegram_admin_id, settings.telegram_channel_id, settings.telegram_topic_id
+ )
+ if user.admin and user.admin.telegram_id:
+ await send_telegram_message(data, chat_id=user.admin.telegram_id)
+
+
+async def remove_user(user: UserNotificationResponse, by: str):
+ username, admin_username, by = escape_html_user(user, by)
+ data = messages.REMOVE_USER.format(
+ username=username,
+ admin_username=admin_username,
+ by=by,
+ )
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_telegram:
+ await send_telegram_message(
+ data, settings.telegram_admin_id, settings.telegram_channel_id, settings.telegram_topic_id
+ )
+ if user.admin and user.admin.telegram_id:
+ await send_telegram_message(data, chat_id=user.admin.telegram_id)
+
+
+async def reset_user_data_usage(user: UserNotificationResponse, by: str):
+ username, admin_username, by = escape_html_user(user, by)
+ data = messages.RESET_USER_DATA_USAGE.format(
+ username=username,
+ data_limit=readable_size(user.data_limit) if user.data_limit else "Unlimited",
+ admin_username=admin_username,
+ by=by,
+ )
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_telegram:
+ await send_telegram_message(
+ data, settings.telegram_admin_id, settings.telegram_channel_id, settings.telegram_topic_id
+ )
+ if user.admin and user.admin.telegram_id:
+ await send_telegram_message(data, chat_id=user.admin.telegram_id)
+
+
+async def user_data_reset_by_next(user: UserNotificationResponse, by: str):
+ username, admin_username, by = escape_html_user(user, by)
+ data = messages.USER_DATA_RESET_BY_NEXT.format(
+ username=username,
+ data_limit=readable_size(user.data_limit) if user.data_limit else "Unlimited",
+ expire_date=user.expire if user.expire else "Never",
+ admin_username=admin_username,
+ by=by,
+ )
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_telegram:
+ await send_telegram_message(
+ data, settings.telegram_admin_id, settings.telegram_channel_id, settings.telegram_topic_id
+ )
+ if user.admin and user.admin.telegram_id:
+ await send_telegram_message(data, chat_id=user.admin.telegram_id)
+
+
+async def user_subscription_revoked(user: UserNotificationResponse, by: str):
+ username, admin_username, by = escape_html_user(user, by)
+ data = messages.USER_SUBSCRIPTION_REVOKED.format(
+ username=username,
+ admin_username=admin_username,
+ by=by,
+ )
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_telegram:
+ await send_telegram_message(
+ data, settings.telegram_admin_id, settings.telegram_channel_id, settings.telegram_topic_id
+ )
+ if user.admin and user.admin.telegram_id:
+ await send_telegram_message(data, chat_id=user.admin.telegram_id)
diff --git a/app/notification/telegram/user_template.py b/app/notification/telegram/user_template.py
new file mode 100644
index 000000000..dddc8c2b9
--- /dev/null
+++ b/app/notification/telegram/user_template.py
@@ -0,0 +1,52 @@
+from app.notification.client import send_telegram_message
+from app.models.user_template import UserTemplateResponse
+from app.models.settings import NotificationSettings
+from app.settings import notification_settings
+from app.utils.helpers import escape_tg_html
+
+from .utils import escape_html_template
+from . import messages
+
+
+async def create_user_template(user_template: UserTemplateResponse, by: str):
+ name, prefix, suffix, by = escape_html_template(user_template, by)
+ data = messages.CREATE_USER_TEMPLATE.format(
+ name=name,
+ data_limit=user_template.data_limit,
+ expire_duration=user_template.expire_duration,
+ username_prefix=prefix,
+ username_suffix=suffix,
+ by=by,
+ )
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_telegram:
+ await send_telegram_message(
+ data, settings.telegram_admin_id, settings.telegram_channel_id, settings.telegram_topic_id
+ )
+
+
+async def modify_user_template(user_template: UserTemplateResponse, by: str):
+ name, prefix, suffix, by = escape_html_template(user_template, by)
+ data = messages.MODIFY_USER_TEMPLATE.format(
+ name=name,
+ data_limit=user_template.data_limit,
+ expire_duration=user_template.expire_duration,
+ username_prefix=prefix,
+ username_suffix=suffix,
+ by=by,
+ )
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_telegram:
+ await send_telegram_message(
+ data, settings.telegram_admin_id, settings.telegram_channel_id, settings.telegram_topic_id
+ )
+
+
+async def remove_user_template(name: str, by: str):
+ name, by = escape_tg_html((name, by))
+ data = messages.REMOVE_USER_TEMPLATE.format(name=name, by=by)
+ settings: NotificationSettings = await notification_settings()
+ if settings.notify_telegram:
+ await send_telegram_message(
+ data, settings.telegram_admin_id, settings.telegram_channel_id, settings.telegram_topic_id
+ )
diff --git a/app/notification/telegram/utils.py b/app/notification/telegram/utils.py
new file mode 100644
index 000000000..1d3cb6ed4
--- /dev/null
+++ b/app/notification/telegram/utils.py
@@ -0,0 +1,32 @@
+from app.utils.helpers import escape_tg_html
+from app.models.user import UserNotificationResponse
+from app.models.host import BaseHost
+from app.models.user_template import UserTemplateResponse
+from app.models.core import CoreResponse
+
+
+def escape_html_user(user: UserNotificationResponse, by: str) -> tuple[str, str, str]:
+ """Escapes HTML special characters in user and by strings."""
+ return escape_tg_html((user.username, user.admin.username if user.admin else "None", by))
+
+
+def escape_html_host(host: BaseHost, by: str) -> tuple[str, str, str, str]:
+ """Escapes HTML special characters in host and by strings."""
+ return escape_tg_html((host.remark, host.address_str, host.inbound_tag, by))
+
+
+def escape_html_template(template: UserTemplateResponse, by: str) -> tuple[str, str, str, str]:
+ """Escapes HTML special characters in template and by strings."""
+ return escape_tg_html(
+ (
+ template.name,
+ template.username_prefix if template.username_prefix else "",
+ template.username_suffix if template.username_suffix else "",
+ by,
+ )
+ )
+
+
+def escape_html_core(core: CoreResponse, by: str) -> tuple[str, str, str, str]:
+ """Escapes HTML special characters in core and by strings."""
+ return escape_tg_html((core.name, core.exclude_tags, core.fallback_tags, by))
diff --git a/app/notification/webhook/__init__.py b/app/notification/webhook/__init__.py
new file mode 100644
index 000000000..11edcec8f
--- /dev/null
+++ b/app/notification/webhook/__init__.py
@@ -0,0 +1,129 @@
+from datetime import datetime as dt, timezone as tz
+from enum import Enum
+from typing import Type
+import asyncio
+
+from pydantic import BaseModel, Field
+
+from app.settings import webhook_settings
+from app.models.admin import AdminDetails
+from app.models.user import UserNotificationResponse, UserStatus
+
+queue = asyncio.Queue()
+
+
+def get_current_timestamp() -> float:
+ """Factory function to get current timestamp"""
+ return dt.now(tz.utc).timestamp()
+
+
+class Notification(BaseModel):
+ class Type(str, Enum):
+ user_created = "user_created"
+ user_updated = "user_updated"
+ user_deleted = "user_deleted"
+ user_limited = "user_limited"
+ user_expired = "user_expired"
+ user_enabled = "user_enabled"
+ user_disabled = "user_disabled"
+ data_usage_reset = "data_usage_reset"
+ data_reset_by_next = "data_reset_by_next"
+ subscription_revoked = "subscription_revoked"
+
+ reached_usage_percent = "reached_usage_percent"
+ reached_days_left = "reached_days_left"
+
+ enqueued_at: float = Field(default_factory=get_current_timestamp)
+ send_at: float = Field(default_factory=get_current_timestamp)
+ tries: int = Field(default=0)
+
+
+class UserNotification(Notification):
+ username: str
+
+
+class ReachedUsagePercent(UserNotification):
+ action: Notification.Type = Notification.Type.reached_usage_percent
+ user: UserNotificationResponse
+ used_percent: float
+
+
+class ReachedDaysLeft(UserNotification):
+ action: Notification.Type = Notification.Type.reached_days_left
+ user: UserNotificationResponse
+ days_left: int
+
+
+class UserCreated(UserNotification):
+ action: Notification.Type = Notification.Type.user_created
+ by: AdminDetails
+ user: UserNotificationResponse
+
+
+class UserUpdated(UserNotification):
+ action: Notification.Type = Notification.Type.user_updated
+ by: AdminDetails
+ user: UserNotificationResponse
+
+
+class UserDeleted(UserNotification):
+ action: Notification.Type = Notification.Type.user_deleted
+ by: AdminDetails
+
+
+class UserLimited(UserNotification):
+ action: Notification.Type = Notification.Type.user_limited
+ user: UserNotificationResponse
+
+
+class UserExpired(UserNotification):
+ action: Notification.Type = Notification.Type.user_expired
+ user: UserNotificationResponse
+
+
+class UserEnabled(UserNotification):
+ action: Notification.Type = Notification.Type.user_enabled
+ by: AdminDetails | None = None
+ user: UserNotificationResponse
+
+
+class UserDisabled(UserNotification):
+ action: Notification.Type = Notification.Type.user_disabled
+ by: AdminDetails | None = None
+ user: UserNotificationResponse
+ reason: str | None = None
+
+
+class UserDataUsageReset(UserNotification):
+ action: Notification.Type = Notification.Type.data_usage_reset
+ by: AdminDetails
+ user: UserNotificationResponse
+
+
+class UserDataResetByNext(UserNotification):
+ action: Notification.Type = Notification.Type.data_usage_reset
+ user: UserNotificationResponse
+
+
+class UserSubscriptionRevoked(UserNotification):
+ action: Notification.Type = Notification.Type.subscription_revoked
+ by: AdminDetails
+ user: UserNotificationResponse
+
+
+async def status_change(user: UserNotificationResponse):
+ if user.status == UserStatus.limited:
+ await notify(UserLimited(username=user.username, user=user))
+ elif user.status == UserStatus.expired:
+ await notify(UserExpired(username=user.username, user=user))
+ elif user.status == UserStatus.disabled:
+ await notify(UserDisabled(username=user.username, user=user))
+ elif user.status == UserStatus.active:
+ await notify(UserEnabled(username=user.username, user=user))
+ elif user.status == UserStatus.on_hold:
+ await notify(UserEnabled(username=user.username, user=user))
+
+
+async def notify(message: Type[Notification]) -> None:
+ if (await webhook_settings()).enable:
+ await queue.put(message)
diff --git a/app/operation/__init__.py b/app/operation/__init__.py
new file mode 100644
index 000000000..08125f087
--- /dev/null
+++ b/app/operation/__init__.py
@@ -0,0 +1,158 @@
+from datetime import datetime as dt, timedelta as td, timezone as tz
+from enum import IntEnum
+
+from fastapi import HTTPException
+
+from app.core.manager import core_manager
+from app.db import AsyncSession
+from app.db.crud import (
+ get_admin,
+ get_core_config_by_id,
+ get_group_by_id,
+ get_host_by_id,
+ get_node_by_id,
+ get_user,
+ get_user_template,
+)
+from app.db.crud.user import get_user_by_id
+from app.db.models import Admin as DBAdmin, CoreConfig, Group, Node, ProxyHost, User, UserTemplate
+from app.models.admin import AdminDetails
+from app.models.group import BulkGroup
+from app.models.user import UserCreate, UserModify
+from app.utils.helpers import fix_datetime_timezone
+from app.utils.jwt import get_subscription_payload
+
+
+class OperatorType(IntEnum):
+ SYSTEM = 0
+ API = 1
+ WEB = 2
+ CLI = 3
+ TELEGRAM = 4
+ DISCORD = 5
+
+
+class BaseOperation:
+ def __init__(self, operator_type: OperatorType):
+ self.operator_type = operator_type
+
+ async def raise_error(self, message: str, code: int, db: AsyncSession | None = None):
+ """Raise an error based on the operator type."""
+ if db:
+ await db.rollback()
+ if code <= 0:
+ code = 408
+ if self.operator_type in [OperatorType.API, OperatorType.WEB]:
+ raise HTTPException(status_code=code, detail=str(message))
+ else:
+ raise ValueError(message)
+
+ async def validate_dates(self, start: dt | None, end: dt | None) -> tuple[dt, dt]:
+ """Validate if start and end dates are correct and if end is after start."""
+ try:
+ if start:
+ start_date = fix_datetime_timezone(start)
+ else:
+ start_date = dt.now(tz.utc) - td(days=30)
+
+ if end:
+ end_date = fix_datetime_timezone(end)
+ else:
+ end_date = dt.now(tz.utc)
+
+ # Compare dates only after both are set
+ if end_date < start_date:
+ await self.raise_error(message="Start date must be before end date", code=400)
+
+ return start_date, end_date
+ except ValueError:
+ await self.raise_error(message="Invalid date range or format", code=400)
+
+ async def get_validated_host(self, db: AsyncSession, host_id: int) -> ProxyHost:
+ db_host = await get_host_by_id(db, host_id)
+ if db_host is None:
+ await self.raise_error(message="Host not found", code=404)
+ return db_host
+
+ async def get_validated_sub(self, db: AsyncSession, token: str) -> User:
+ sub = await get_subscription_payload(token)
+ if not sub:
+ await self.raise_error(message="Not Found", code=404)
+
+ db_user = await get_user(db, sub["username"])
+ if not db_user or db_user.created_at.astimezone(tz.utc) > sub["created_at"]:
+ await self.raise_error(message="Not Found", code=404)
+
+ if db_user.sub_revoked_at and db_user.sub_revoked_at.astimezone(tz.utc) > sub["created_at"]:
+ await self.raise_error(message="Not Found", code=404)
+
+ return db_user
+
+ async def get_validated_user(self, db: AsyncSession, username: str, admin: AdminDetails) -> User:
+ db_user = await get_user(db, username)
+ if not db_user:
+ await self.raise_error(message="User not found", code=404)
+
+ if not (admin.is_sudo or (db_user.admin and db_user.admin.username == admin.username)):
+ await self.raise_error(message="You're not allowed", code=403)
+
+ return db_user
+
+ async def get_validated_user_by_id(self, db: AsyncSession, user_id: int, admin: AdminDetails) -> User:
+ db_user = await get_user_by_id(db, user_id)
+ if not db_user:
+ await self.raise_error(message="User not found", code=404)
+
+ if not (admin.is_sudo or (db_user.admin and db_user.admin.username == admin.username)):
+ await self.raise_error(message="You're not allowed", code=403)
+
+ return db_user
+
+ async def get_validated_admin(self, db: AsyncSession, username: str) -> DBAdmin:
+ db_admin = await get_admin(db, username)
+ if not db_admin:
+ await self.raise_error(message="Admin not found", code=404)
+ return db_admin
+
+ async def get_validated_group(self, db: AsyncSession, group_id: int) -> Group:
+ db_group = await get_group_by_id(db, group_id)
+ if not db_group:
+ await self.raise_error("Group not found", 404)
+ return db_group
+
+ async def validate_all_groups(self, db, model: UserCreate | UserModify | UserTemplate | BulkGroup) -> list[Group]:
+ all_groups: list[Group] = []
+ if model.group_ids:
+ for group_id in model.group_ids:
+ db_group = await self.get_validated_group(db, group_id)
+ all_groups.append(db_group)
+ if hasattr(model, "has_group_ids") and model.has_group_ids:
+ for group_id in model.has_group_ids:
+ db_group = await self.get_validated_group(db, group_id)
+ all_groups.append(db_group)
+ return all_groups
+
+ async def get_validated_user_template(self, db: AsyncSession, template_id: int) -> UserTemplate:
+ dbuser_template = await get_user_template(db, template_id)
+ if not dbuser_template:
+ await self.raise_error("User Template not found", 404)
+ return dbuser_template
+
+ async def get_validated_node(self, db: AsyncSession, node_id) -> Node:
+ """Dependency: Fetch node or return not found error."""
+ db_node = await get_node_by_id(db, node_id)
+ if not db_node:
+ await self.raise_error(message="Node not found", code=404)
+ return db_node
+
+ async def check_inbound_tags(self, tags: list[str]) -> None:
+ for tag in tags:
+ if tag not in await core_manager.get_inbounds():
+ await self.raise_error(f"{tag} not found", 400)
+
+ async def get_validated_core_config(self, db: AsyncSession, core_id) -> CoreConfig:
+ """Dependency: Fetch core config or return not found error."""
+ db_core_config = await get_core_config_by_id(db, core_id)
+ if not db_core_config:
+ await self.raise_error(message="Core config not found", code=404)
+ return db_core_config
diff --git a/app/operation/admin.py b/app/operation/admin.py
new file mode 100644
index 000000000..42e546db9
--- /dev/null
+++ b/app/operation/admin.py
@@ -0,0 +1,127 @@
+import asyncio
+
+from sqlalchemy.exc import IntegrityError
+
+from app import notification
+from app.db import AsyncSession
+from app.db.crud.admin import (
+ create_admin,
+ get_admins,
+ get_admins_count,
+ remove_admin,
+ reset_admin_usage,
+ update_admin,
+)
+from app.db.crud.bulk import activate_all_disabled_users, disable_all_active_users
+from app.db.crud.user import get_users
+from app.db.models import Admin as DBAdmin
+from app.models.admin import AdminCreate, AdminDetails, AdminModify
+from app.node import node_manager
+from app.operation import BaseOperation, OperatorType
+from app.utils.logger import get_logger
+
+logger = get_logger("admin-operation")
+
+
+class AdminOperation(BaseOperation):
+ async def create_admin(self, db: AsyncSession, new_admin: AdminCreate, admin: AdminDetails) -> AdminDetails:
+ """Create a new admin if the current admin has sudo privileges."""
+ try:
+ db_admin = await create_admin(db, new_admin)
+ except IntegrityError:
+ await self.raise_error(message="Admin already exists", code=409, db=db)
+
+ if self.operator_type != OperatorType.CLI:
+ logger.info(f'New admin "{db_admin.username}" with id "{db_admin.id}" added by admin "{admin.username}"')
+ new_admin = AdminDetails.model_validate(db_admin)
+ asyncio.create_task(notification.create_admin(new_admin, admin.username))
+
+ return db_admin
+
+ async def modify_admin(
+ self, db: AsyncSession, username: str, modified_admin: AdminModify, current_admin: AdminDetails
+ ) -> AdminDetails:
+ """Modify an existing admin's details."""
+ db_admin = await self.get_validated_admin(db, username=username)
+ if self.operator_type != OperatorType.CLI and db_admin.username == current_admin.username and db_admin.is_sudo:
+ await self.raise_error(
+ message="You're not allowed to edit another sudoer's account. Use pasarguard-cli instead.", code=403
+ )
+
+ db_admin = await update_admin(db, db_admin, modified_admin)
+
+ if self.operator_type != OperatorType.CLI:
+ logger.info(
+ f'Admin "{db_admin.username}" with id "{db_admin.id}" modified by admin "{current_admin.username}"'
+ )
+
+ modified_admin = AdminDetails.model_validate(db_admin)
+ asyncio.create_task(notification.modify_admin(modified_admin, current_admin.username))
+ return modified_admin
+
+ async def remove_admin(self, db: AsyncSession, username: str, current_admin: AdminDetails | None = None):
+ """Remove an admin from the database."""
+ db_admin = await self.get_validated_admin(db, username=username)
+ if (
+ self.operator_type != OperatorType.CLI
+ and (db_admin.username == current_admin.username)
+ and db_admin.is_sudo
+ ):
+ await self.raise_error(
+ message="You're not allowed to delete sudoer's account. Use pasarguard-cli instead.", code=403
+ )
+
+ await remove_admin(db, db_admin)
+ if self.operator_type != OperatorType.CLI:
+ logger.info(
+ f'Admin "{db_admin.username}" with id "{db_admin.id}" deleted by admin "{current_admin.username}"'
+ )
+ asyncio.create_task(notification.remove_admin(username, current_admin.username))
+
+ async def get_admins(
+ self, db: AsyncSession, username: str | None = None, offset: int | None = None, limit: int | None = None
+ ) -> list[DBAdmin]:
+ return await get_admins(db, offset, limit, username)
+
+ async def get_admins_count(self, db: AsyncSession) -> int:
+ return await get_admins_count(db)
+
+ async def disable_all_active_users(self, db: AsyncSession, username: str, admin: AdminDetails):
+ """Disable all active users under a specific admin"""
+ db_admin = await self.get_validated_admin(db, username=username)
+
+ if db_admin.is_sudo:
+ await self.raise_error(message="You're not allowed to disable sudo admin users.", code=403)
+
+ await disable_all_active_users(db=db, admin=db_admin)
+
+ users = await get_users(db, admin=db_admin)
+ await node_manager.update_users(users)
+
+ logger.info(f'Admin "{username}" users has been disabled by admin "{admin.username}"')
+
+ async def activate_all_disabled_users(self, db: AsyncSession, username: str, admin: AdminDetails):
+ """Enable all active users under a specific admin"""
+ db_admin = await self.get_validated_admin(db, username=username)
+
+ if db_admin.is_sudo:
+ await self.raise_error(message="You're not allowed to enable sudo admin users.", code=403)
+
+ await activate_all_disabled_users(db=db, admin=db_admin)
+
+ users = await get_users(db, admin=db_admin)
+ await node_manager.update_users(users)
+
+ logger.info(f'Admin "{username}" users has been activated by admin "{admin.username}"')
+
+ async def reset_admin_usage(self, db: AsyncSession, username: str, admin: AdminDetails) -> AdminDetails:
+ db_admin = await self.get_validated_admin(db, username=username)
+
+ db_admin = await reset_admin_usage(db, db_admin=db_admin)
+ if self.operator_type != OperatorType.CLI:
+ logger.info(f'Admin "{username}" usage has been reset by admin "{admin.username}"')
+
+ reseted_admin = AdminDetails.model_validate(db_admin)
+ asyncio.create_task(notification.admin_usage_reset(reseted_admin, admin.username))
+
+ return reseted_admin
diff --git a/app/operation/core.py b/app/operation/core.py
new file mode 100644
index 000000000..2e3ec7675
--- /dev/null
+++ b/app/operation/core.py
@@ -0,0 +1,75 @@
+import asyncio
+
+from app.db import AsyncSession
+from app.db.crud.core import create_core_config, modify_core_config, remove_core_config, get_core_configs
+from app.models.admin import AdminDetails
+from app.models.core import CoreCreate, CoreResponseList, CoreResponse
+from app.core.manager import core_manager
+from app.operation import BaseOperation
+from app import notification
+from app.core.hosts import hosts as hosts_storage
+from app.utils.logger import get_logger
+
+
+logger = get_logger("core-operation")
+
+
+class CoreOperation(BaseOperation):
+ async def create_core(self, db: AsyncSession, new_core: CoreCreate, admin: AdminDetails) -> CoreResponse:
+ try:
+ core_manager.validate_core(new_core.config, new_core.exclude_inbound_tags, new_core.fallbacks_inbound_tags)
+ db_core = await create_core_config(db, new_core)
+ except Exception as e:
+ await self.raise_error(message=e, code=400, db=db)
+
+ await core_manager.update_core(db_core)
+ logger.info(f'Core config "{db_core.id}" created by admin "{admin.username}"')
+
+ core = CoreResponse.model_validate(db_core)
+ asyncio.create_task(notification.create_core(core, admin.username))
+
+ await hosts_storage.update(db)
+
+ return core
+
+ async def get_all_cores(self, db: AsyncSession, offset: int, limit: int) -> CoreResponseList:
+ db_cores, count = await get_core_configs(db, offset, limit)
+ return CoreResponseList(cores=db_cores, count=count)
+
+ async def modify_core(
+ self, db: AsyncSession, core_id: int, modified_core: CoreCreate, admin: AdminDetails
+ ) -> CoreResponse:
+ db_core = await self.get_validated_core_config(db, core_id)
+ try:
+ core_manager.validate_core(
+ modified_core.config, modified_core.exclude_inbound_tags, modified_core.fallbacks_inbound_tags
+ )
+ db_core = await modify_core_config(db, db_core, modified_core)
+ except Exception as e:
+ await self.raise_error(message=e, code=400, db=db)
+
+ await core_manager.update_core(db_core)
+
+ logger.info(f'Core config "{db_core.name}" modified by admin "{admin.username}"')
+
+ core = CoreResponse.model_validate(db_core)
+ asyncio.create_task(notification.modify_core(core, admin.username))
+
+ await hosts_storage.update(db)
+
+ return core
+
+ async def delete_core(self, db: AsyncSession, core_id: int, admin: AdminDetails) -> None:
+ if core_id == 1:
+ return await self.raise_error(message="Cannot delete default core config", code=403)
+
+ db_core = await self.get_validated_core_config(db, core_id)
+
+ await remove_core_config(db, db_core)
+ await core_manager.remove_core(db_core.id)
+
+ asyncio.create_task(notification.remove_core(db_core.id, admin.username))
+
+ logger.info(f'core config "{db_core.name}" deleted by admin "{admin.username}"')
+
+ await hosts_storage.update(db)
diff --git a/app/operation/group.py b/app/operation/group.py
new file mode 100644
index 000000000..8ad4ae807
--- /dev/null
+++ b/app/operation/group.py
@@ -0,0 +1,87 @@
+import asyncio
+
+from app import notification
+from app.db import AsyncSession
+from app.db.crud.bulk import add_groups_to_users, remove_groups_from_users
+from app.db.crud.group import create_group, get_group, modify_group, remove_group
+from app.db.crud.user import get_users
+from app.db.models import Admin
+from app.models.group import BulkGroup, Group, GroupCreate, GroupModify, GroupResponse, GroupsResponse
+from app.node import node_manager
+from app.operation import BaseOperation, OperatorType
+from app.utils.logger import get_logger
+
+logger = get_logger("group-operation")
+
+
+class GroupOperation(BaseOperation):
+ async def create_group(self, db: AsyncSession, new_group: GroupCreate, admin: Admin) -> Group:
+ await self.check_inbound_tags(new_group.inbound_tags)
+
+ db_group = await create_group(db, new_group)
+
+ group = GroupResponse.model_validate(db_group)
+
+ asyncio.create_task(notification.create_group(group, admin.username))
+
+ logger.info(f'Group "{group.name}" created by admin "{admin.username}"')
+ return group
+
+ async def get_all_groups(
+ self, db: AsyncSession, offset: int | None = None, limit: int | None = None
+ ) -> GroupsResponse:
+ db_groups, count = await get_group(db, offset, limit)
+ return GroupsResponse(groups=db_groups, total=count)
+
+ async def modify_group(self, db: AsyncSession, group_id: int, modified_group: GroupModify, admin: Admin) -> Group:
+ db_group = await self.get_validated_group(db, group_id)
+ if modified_group.inbound_tags:
+ await self.check_inbound_tags(modified_group.inbound_tags)
+ db_group = await modify_group(db, db_group, modified_group)
+
+ users = await get_users(db, group_ids=[db_group.id])
+ await node_manager.update_users(users)
+
+ group = GroupResponse.model_validate(db_group)
+
+ asyncio.create_task(notification.modify_group(group, admin.username))
+
+ logger.info(f'Group "{group.name}" modified by admin "{admin.username}"')
+ return group
+
+ async def remove_group(self, db: AsyncSession, group_id: int, admin: Admin) -> None:
+ db_group = await self.get_validated_group(db, group_id)
+
+ users = await get_users(db, group_ids=[db_group.id])
+ username_list = [user.username for user in users]
+
+ await remove_group(db, db_group)
+ users = await get_users(db, usernames=username_list)
+
+ await node_manager.update_users(users)
+
+ logger.info(f'Group "{db_group.name}" deleted by admin "{admin.username}"')
+
+ asyncio.create_task(notification.remove_group(db_group.id, admin.username))
+
+ async def bulk_add_groups(self, db: AsyncSession, bulk_model: BulkGroup):
+ await self.validate_all_groups(db, bulk_model)
+
+ users, users_count = await add_groups_to_users(db, bulk_model)
+
+ await node_manager.update_users(users)
+
+ if self.operator_type in (OperatorType.API, OperatorType.WEB):
+ return {"detail": f"operation has been successfuly done on {users_count} users"}
+ return users_count
+
+ async def bulk_remove_groups(self, db: AsyncSession, bulk_model: BulkGroup):
+ await self.validate_all_groups(db, bulk_model)
+
+ users, users_count = await remove_groups_from_users(db, bulk_model)
+
+ await node_manager.update_users(users)
+
+ if self.operator_type in (OperatorType.API, OperatorType.WEB):
+ return {"detail": f"operation has been successfuly done on {users_count} users"}
+ return users_count
diff --git a/app/operation/host.py b/app/operation/host.py
new file mode 100644
index 000000000..8b8a6d4ec
--- /dev/null
+++ b/app/operation/host.py
@@ -0,0 +1,109 @@
+import asyncio
+
+from app.db import AsyncSession
+from app.db.models import ProxyHost
+from app.models.host import CreateHost, BaseHost
+from app.models.admin import AdminDetails
+from app.operation import BaseOperation
+from app.db.crud.host import create_host, get_host_by_id, remove_host, get_hosts, modify_host
+from app.core.hosts import hosts as hosts_storage
+from app.utils.logger import get_logger
+
+from app import notification
+
+
+logger = get_logger("host-operation")
+
+
+class HostOperation(BaseOperation):
+ async def get_hosts(self, db: AsyncSession, offset: int = 0, limit: int = 0) -> list[BaseHost]:
+ return await get_hosts(db=db, offset=offset, limit=limit)
+
+ async def validate_ds_host(self, db: AsyncSession, host: CreateHost, host_id: int | None = None) -> ProxyHost:
+ if (
+ host.transport_settings
+ and host.transport_settings.xhttp_settings
+ and (nested_host := host.transport_settings.xhttp_settings.download_settings)
+ ):
+ if host_id and nested_host == host_id:
+ return await self.raise_error("download host cannot be the same as the host", 400, db=db)
+ ds_host = await get_host_by_id(db, nested_host)
+ if not ds_host:
+ return await self.raise_error("download host not found", 404, db=db)
+ if (
+ ds_host.transport_settings
+ and ds_host.transport_settings.get("xhttp_settings")
+ and ds_host.transport_settings.get("xhttp_settings").get("download_settings")
+ ):
+ return await self.raise_error("download host cannot have a download host", 400, db=db)
+
+ async def create_host(self, db: AsyncSession, new_host: CreateHost, admin: AdminDetails) -> BaseHost:
+ await self.validate_ds_host(db, new_host)
+
+ await self.check_inbound_tags([new_host.inbound_tag])
+
+ db_host = await create_host(db, new_host)
+
+ logger.info(f'Host "{db_host.id}" added by admin "{admin.username}"')
+
+ host = BaseHost.model_validate(db_host)
+ asyncio.create_task(notification.create_host(host, admin.username))
+
+ await hosts_storage.update(db)
+
+ return host
+
+ async def modify_host(
+ self, db: AsyncSession, host_id: int, modified_host: CreateHost, admin: AdminDetails
+ ) -> BaseHost:
+ await self.validate_ds_host(db, modified_host, host_id)
+
+ if modified_host.inbound_tag:
+ await self.check_inbound_tags([modified_host.inbound_tag])
+
+ db_host = await self.get_validated_host(db, host_id)
+
+ db_host = await modify_host(db=db, db_host=db_host, modified_host=modified_host)
+
+ logger.info(f'Host "{db_host.id}" modified by admin "{admin.username}"')
+
+ host = BaseHost.model_validate(db_host)
+ asyncio.create_task(notification.modify_host(host, admin.username))
+
+ await hosts_storage.update(db)
+
+ return host
+
+ async def remove_host(self, db: AsyncSession, host_id: int, admin: AdminDetails):
+ db_host = await self.get_validated_host(db, host_id)
+ await remove_host(db, db_host)
+ logger.info(f'Host "{db_host.id}" deleted by admin "{admin.username}"')
+
+ host = BaseHost.model_validate(db_host)
+
+ asyncio.create_task(notification.remove_host(host, admin.username))
+
+ await hosts_storage.update(db)
+
+ async def modify_hosts(
+ self, db: AsyncSession, modified_hosts: list[CreateHost], admin: AdminDetails
+ ) -> list[BaseHost]:
+ for host in modified_hosts:
+ await self.validate_ds_host(db, host, host.id)
+
+ old_host: ProxyHost | None = None
+ if host.id is not None:
+ old_host = await get_host_by_id(db, host.id)
+
+ if old_host is None:
+ await create_host(db, host)
+ else:
+ await modify_host(db, old_host, host)
+
+ await hosts_storage.update(db)
+
+ logger.info(f'Host\'s has been modified by admin "{admin.username}"')
+
+ asyncio.create_task(notification.modify_hosts(admin.username))
+
+ return await get_hosts(db=db)
diff --git a/app/operation/node.py b/app/operation/node.py
new file mode 100644
index 000000000..bbb018e67
--- /dev/null
+++ b/app/operation/node.py
@@ -0,0 +1,350 @@
+import asyncio
+from datetime import datetime as dt
+
+from PasarGuardNodeBridge import PasarGuardNode, NodeAPIError
+from sqlalchemy.exc import IntegrityError
+
+from app import notification
+from app.core.manager import core_manager
+from app.db import AsyncSession
+from app.db.base import GetDB
+from app.db.crud.node import (
+ clear_usage_data,
+ create_node,
+ get_node_by_id,
+ get_node_stats,
+ get_nodes,
+ get_nodes_usage,
+ modify_node,
+ remove_node,
+ update_node_status,
+)
+from app.db.crud.user import get_user
+from app.db.models import Node, NodeStatus
+from app.models.admin import AdminDetails
+from app.models.node import NodeCreate, NodeModify, NodeResponse, UsageTable
+from app.models.stats import NodeRealtimeStats, NodeStatsList, NodeUsageStatsList, Period
+from app.node import core_users, node_manager
+from app.operation import BaseOperation
+from app.utils.logger import get_logger
+
+MAX_MESSAGE_LENGTH = 128
+
+logger = get_logger("node-operation")
+
+
+class NodeOperation(BaseOperation):
+ async def get_db_nodes(
+ self, db: AsyncSession, core_id: int | None = None, offset: int | None = None, limit: int | None = None
+ ) -> list[Node]:
+ return await get_nodes(db=db, core_id=core_id, offset=offset, limit=limit)
+
+ @staticmethod
+ async def update_node_status(
+ node_id: int,
+ status: NodeStatus,
+ core_version: str = "",
+ node_version: str = "",
+ err: str = "",
+ notify_err: bool = True,
+ ):
+ async with GetDB() as db:
+ db_node = await get_node_by_id(db, node_id)
+
+ if db_node is None:
+ return
+
+ old_status = db_node.status
+
+ if status == NodeStatus.error:
+ logger.error(f"Failed to connect node {db_node.name} with id {db_node.id}, Error: {err}")
+
+ db_node = await update_node_status(
+ db=db,
+ db_node=db_node,
+ status=status,
+ xray_version=core_version,
+ node_version=node_version,
+ message=err,
+ )
+
+ node_response = NodeResponse.model_validate(db_node)
+ if status is NodeStatus.connected:
+ asyncio.create_task(notification.connect_node(node_response))
+
+ if notify_err and status is NodeStatus.error and old_status is not NodeStatus.error:
+ err = err[: MAX_MESSAGE_LENGTH - 3] + "..."
+ asyncio.create_task(notification.error_node(node_response))
+
+ @staticmethod
+ async def connect_node(node_id: int) -> None:
+ gozargah_node: PasarGuardNode | None = await node_manager.get_node(node_id)
+ if gozargah_node is None:
+ return
+
+ async with GetDB() as db:
+ db_node = await get_node_by_id(db, node_id)
+
+ if db_node is None:
+ return
+
+ notify_err = True if db_node.status is not NodeStatus.error else False
+
+ logger.info(f'Connecting to "{db_node.name}" node')
+ await NodeOperation.update_node_status(db_node.id, NodeStatus.connecting)
+
+ core = await core_manager.get_core(db_node.core_config_id if db_node.core_config_id else 1)
+
+ try:
+ info = await gozargah_node.start(
+ config=core.to_str(),
+ backend_type=0,
+ users=await core_users(db=db),
+ keep_alive=db_node.keep_alive,
+ ghather_logs=db_node.gather_logs,
+ exclude_inbounds=core.exclude_inbound_tags,
+ timeout=10,
+ )
+ await NodeOperation.update_node_status(
+ node_id=db_node.id,
+ status=NodeStatus.connected,
+ core_version=info.core_version,
+ node_version=info.node_version,
+ )
+ logger.info(
+ f'Connected to "{db_node.name}" node v{info.node_version}, xray run on v{info.core_version}'
+ )
+ except NodeAPIError as e:
+ if e.code == -4:
+ return
+
+ detail = e.detail
+
+ if len(detail) > 1024:
+ detail = detail[:1020] + "..."
+ else:
+ detail = detail
+
+ await NodeOperation.update_node_status(
+ node_id=db_node.id, status=NodeStatus.error, err=detail, notify_err=notify_err
+ )
+
+ async def create_node(self, db: AsyncSession, new_node: NodeCreate, admin: AdminDetails) -> NodeResponse:
+ await self.get_validated_core_config(db, new_node.core_config_id)
+ try:
+ db_node = await create_node(db, new_node)
+ except IntegrityError:
+ await self.raise_error(message=f'Node "{new_node.name}" already exists', code=409, db=db)
+
+ try:
+ await node_manager.update_node(db_node)
+ asyncio.create_task(self.connect_node(node_id=db_node.id))
+ except NodeAPIError as e:
+ await self.update_node_status(db_node.id, NodeStatus.error, err=e.detail)
+
+ logger.info(f'New node "{db_node.name}" with id "{db_node.id}" added by admin "{admin.username}"')
+
+ node = NodeResponse.model_validate(db_node)
+
+ asyncio.create_task(notification.create_node(node, admin.username))
+
+ return node
+
+ async def modify_node(
+ self, db: AsyncSession, node_id: Node, modified_node: NodeModify, admin: AdminDetails
+ ) -> Node:
+ db_node = await self.get_validated_node(db=db, node_id=node_id)
+ if modified_node.core_config_id is not None:
+ await self.get_validated_core_config(db, modified_node.core_config_id)
+
+ try:
+ db_node = await modify_node(db, db_node, modified_node)
+ except IntegrityError:
+ await self.raise_error(message=f'Node "{db_node.name}" already exists', code=409, db=db)
+
+ if db_node.status is NodeStatus.disabled:
+ await node_manager.remove_node(db_node.id)
+ else:
+ try:
+ await node_manager.update_node(db_node)
+ asyncio.create_task(self.connect_node(node_id=db_node.id))
+ except NodeAPIError as e:
+ await self.update_node_status(db_node.id, NodeStatus.error, err=e.detail)
+
+ logger.info(f'Node "{db_node.name}" with id "{db_node.id}" modified by admin "{admin.username}"')
+
+ node = NodeResponse.model_validate(db_node)
+
+ asyncio.create_task(notification.modify_node(node, admin.username))
+
+ return node
+
+ async def remove_node(self, db: AsyncSession, node_id: Node, admin: AdminDetails) -> None:
+ db_node: Node = await self.get_validated_node(db=db, node_id=node_id)
+
+ await node_manager.remove_node(db_node.id)
+ await remove_node(db=db, db_node=db_node)
+
+ logger.info(f'Node "{db_node.name}" with id "{db_node.id}" deleted by admin "{admin.username}"')
+
+ asyncio.create_task(notification.remove_node(db_node, admin.username))
+
+ async def restart_node(self, node_id: Node, admin: AdminDetails) -> None:
+ asyncio.create_task(self.connect_node(node_id))
+ logger.info(f'Node "{node_id}" restarted by admin "{admin.username}"')
+
+ async def restart_all_node(self, db: AsyncSession, admin: AdminDetails, core_id: int | None = None) -> None:
+ nodes: list[Node] = await self.get_db_nodes(db, core_id)
+ await asyncio.gather(*[NodeOperation.connect_node(node.id) for node in nodes])
+
+ logger.info(f'All nodes restarted by admin "{admin.username}"')
+
+ async def get_usage(
+ self,
+ db: AsyncSession,
+ start: dt = None,
+ end: dt = None,
+ period: Period = Period.hour,
+ node_id: int | None = None,
+ group_by_node: bool = False,
+ ) -> NodeUsageStatsList:
+ start, end = await self.validate_dates(start, end)
+ return await get_nodes_usage(db, start, end, period=period, node_id=node_id, group_by_node=group_by_node)
+
+ async def get_logs(self, node_id: Node) -> asyncio.Queue:
+ node = await node_manager.get_node(node_id)
+
+ if node is None:
+ await self.raise_error(message="Node not found", code=404)
+
+ try:
+ logs_queue = await node.get_logs()
+ except NodeAPIError as e:
+ await self.raise_error(message=e.detail, code=e.code)
+
+ return logs_queue
+
+ async def get_node_stats_periodic(
+ self, db: AsyncSession, node_id: id, start: dt = None, end: dt = None, period: Period = Period.hour
+ ) -> NodeStatsList:
+ start, end = await self.validate_dates(start, end)
+
+ return await get_node_stats(db, node_id, start, end, period=period)
+
+ async def get_node_system_stats(self, node_id: Node) -> NodeRealtimeStats:
+ node = await node_manager.get_node(node_id)
+
+ if node is None:
+ await self.raise_error(message="Node not found", code=404)
+
+ try:
+ stats = await node.get_system_stats()
+ except NodeAPIError as e:
+ await self.raise_error(message=e.detail, code=e.code)
+
+ if stats is None:
+ await self.raise_error(message="Stats not found", code=404)
+
+ return NodeRealtimeStats(
+ mem_total=stats.mem_total,
+ mem_used=stats.mem_used,
+ cpu_cores=stats.cpu_cores,
+ cpu_usage=stats.cpu_usage,
+ incoming_bandwidth_speed=stats.incoming_bandwidth_speed,
+ outgoing_bandwidth_speed=stats.outgoing_bandwidth_speed,
+ )
+
+ async def get_nodes_system_stats(self) -> dict[int, NodeRealtimeStats | None]:
+ nodes = await node_manager.get_healthy_nodes()
+ stats_tasks = {id: asyncio.create_task(self._get_node_stats_safe(id)) for id, _ in nodes}
+
+ await asyncio.gather(*stats_tasks.values(), return_exceptions=True)
+
+ results = {}
+ for node_id, task in stats_tasks.items():
+ if task.exception():
+ results[node_id] = None
+ else:
+ results[node_id] = task.result()
+
+ return results
+
+ async def _get_node_stats_safe(self, node_id: Node) -> NodeRealtimeStats | None:
+ """Wrapper method that returns None instead of raising exceptions"""
+ try:
+ return await self.get_node_system_stats(node_id)
+ except Exception as e:
+ logger.error(f"Error getting system stats for node {node_id}: {e}")
+ return None
+
+ async def get_user_online_stats_by_node(self, db: AsyncSession, node_id: Node, username: str) -> dict[int, int]:
+ db_user = await get_user(db, username=username)
+ if db_user is None:
+ await self.raise_error(message="User not found", code=404)
+
+ node = await node_manager.get_node(node_id)
+
+ if node is None:
+ await self.raise_error(message="Node not found", code=404)
+
+ try:
+ stats = await node.get_user_online_stats(email=f"{db_user.id}.{db_user.username}")
+ except NodeAPIError as e:
+ await self.raise_error(message=e.detail, code=e.code)
+
+ if stats is None:
+ await self.raise_error(message="Stats not found", code=404)
+
+ return {node_id: stats.value}
+
+ async def get_user_ip_list_by_node(
+ self, db: AsyncSession, node_id: Node, username: str
+ ) -> dict[int, dict[str, int]]:
+ db_user = await get_user(db, username=username)
+ if db_user is None:
+ await self.raise_error(message="User not found", code=404)
+
+ node = await node_manager.get_node(node_id)
+
+ if node is None:
+ await self.raise_error(message="Node not found", code=404)
+
+ try:
+ stats = await node.get_user_online_ip_list(email=f"{db_user.id}.{db_user.username}")
+ except NodeAPIError as e:
+ await self.raise_error(message=e.detail, code=e.code)
+
+ if stats is None:
+ await self.raise_error(message="Stats not found", code=404)
+
+ return {node_id: stats.ips}
+
+ async def sync_node_users(self, db: AsyncSession, node_id: int, flush_users: bool = False) -> NodeResponse:
+ db_node = await self.get_validated_node(db, node_id=node_id)
+
+ if db_node.status != NodeStatus.connected:
+ await self.raise_error(message="Node is not connected", code=406)
+
+ gozargah_node = await node_manager.get_node(node_id)
+ if gozargah_node is None:
+ await self.raise_error(message="Node is not connected", code=409)
+
+ try:
+ await gozargah_node.sync_users(await core_users(db=db), flush_queue=flush_users)
+ except NodeAPIError as e:
+ await update_node_status(db=db, db_node=db_node, status=NodeStatus.error, message=e.detail)
+ await self.raise_error(message=e.detail, code=e.code)
+
+ return NodeResponse.model_validate(db_node)
+
+ async def clear_usage_data(
+ self, db: AsyncSession, table: UsageTable, start: dt | None = None, end: dt | None = None
+ ):
+ if start and end and start >= end:
+ await self.raise_error(code=400, message="Start time must be before end time.")
+
+ try:
+ await clear_usage_data(db, table, start, end)
+ return {"detail": f"All data from '{table}' has been deleted successfully."}
+ except Exception as e:
+ await self.raise_error(code=400, message=f"Deletion failed due to server error: {str(e)}")
diff --git a/app/operation/settings.py b/app/operation/settings.py
new file mode 100644
index 000000000..16e6fe395
--- /dev/null
+++ b/app/operation/settings.py
@@ -0,0 +1,44 @@
+import asyncio
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.db.models import Settings
+from app.db.crud.settings import get_settings, modify_settings
+from app.models.settings import SettingsSchema
+from app.settings import refresh_caches
+from app.notification.client import define_client
+from app.notification.webhook import queue as webhook_queue
+from app.telegram import startup_telegram_bot
+from . import BaseOperation
+
+
+class SettingsOperation(BaseOperation):
+ @staticmethod
+ async def reset_services(old_settings: SettingsSchema, new_settings: SettingsSchema):
+ if new_settings.telegram != old_settings.telegram:
+ await startup_telegram_bot()
+ if new_settings.discord != old_settings.discord:
+ pass
+ if old_settings.webhook and new_settings.webhook is None or not new_settings.webhook.enable:
+ webhook_queue.empty()
+ if old_settings.notification_settings.proxy_url != new_settings.notification_settings.proxy_url:
+ await define_client()
+
+ async def get_settings(self, db: AsyncSession) -> Settings:
+ return await get_settings(db)
+
+ async def modify_settings(self, db: AsyncSession, modify: SettingsSchema) -> SettingsSchema:
+ db_settings = await get_settings(db)
+ old_settings = SettingsSchema.model_validate(db_settings)
+
+ db_settings = await modify_settings(db, db_settings, modify)
+ new_settings = SettingsSchema.model_validate(db_settings)
+
+ await refresh_caches()
+ asyncio.create_task(self.reset_services(old_settings, new_settings))
+
+ return new_settings
+
+ async def get_general_settings(self, db: AsyncSession):
+ settings = await self.get_settings(db)
+ return settings.general
diff --git a/app/operation/subscription.py b/app/operation/subscription.py
new file mode 100644
index 000000000..a32d603aa
--- /dev/null
+++ b/app/operation/subscription.py
@@ -0,0 +1,157 @@
+import re
+from datetime import datetime as dt
+
+from fastapi import Response
+from fastapi.responses import HTMLResponse
+
+from app.db import AsyncSession
+from app.db.crud.user import get_user_usages, user_sub_update
+from app.db.models import User
+from app.models.settings import ConfigFormat, SubRule, Subscription as SubSettings
+from app.models.stats import Period, UserUsageStatsList
+from app.models.user import SubscriptionUserResponse, UsersResponseWithInbounds
+from app.settings import subscription_settings
+from app.subscription.share import encode_title, generate_subscription
+from app.templates import render_template
+from config import SUBSCRIPTION_PAGE_TEMPLATE
+
+from . import BaseOperation
+
+client_config = {
+ ConfigFormat.clash_meta: {"config_format": "clash-meta", "media_type": "text/yaml", "as_base64": False},
+ ConfigFormat.clash: {"config_format": "clash", "media_type": "text/yaml", "as_base64": False},
+ ConfigFormat.sing_box: {"config_format": "sing-box", "media_type": "application/json", "as_base64": False},
+ ConfigFormat.links_base64: {"config_format": "links", "media_type": "text/plain", "as_base64": True},
+ ConfigFormat.links: {"config_format": "links", "media_type": "text/plain", "as_base64": False},
+ ConfigFormat.outline: {"config_format": "outline", "media_type": "application/json", "as_base64": False},
+ ConfigFormat.xray: {"config_format": "xray", "media_type": "application/json", "as_base64": False},
+}
+
+
+class SubscriptionOperation(BaseOperation):
+ @staticmethod
+ async def validated_user(db_user: User) -> UsersResponseWithInbounds:
+ user = UsersResponseWithInbounds.model_validate(db_user.__dict__)
+ user.inbounds = await db_user.inbounds()
+ user.expire = db_user.expire
+
+ return user
+
+ @staticmethod
+ async def detect_client_type(user_agent: str, rules: list[SubRule]) -> ConfigFormat | None:
+ """Detect the appropriate client configuration based on the user agent."""
+ for rule in rules:
+ if re.match(rule.pattern, user_agent):
+ return rule.target
+
+ @staticmethod
+ def create_response_headers(user: UsersResponseWithInbounds, request_url: str, sub_settings: SubSettings) -> dict:
+ """Create response headers for subscription responses, including user subscription info."""
+ # Generate user subscription info
+ user_info = {"upload": 0, "download": user.used_traffic}
+
+ if user.data_limit:
+ user_info["total"] = user.data_limit
+ if user.expire:
+ user_info["expire"] = int(user.expire.timestamp())
+
+ # Create and return headers
+ return {
+ "content-disposition": f'attachment; filename="{user.username}"',
+ "profile-web-page-url": request_url,
+ "support-url": user.admin.support_url
+ if user.admin and user.admin.support_url
+ else sub_settings.support_url,
+ "profile-title": encode_title(user.admin.profile_title)
+ if user.admin and user.admin.profile_title
+ else encode_title(sub_settings.profile_title),
+ "profile-update-interval": str(sub_settings.update_interval),
+ "subscription-userinfo": "; ".join(f"{key}={val}" for key, val in user_info.items()),
+ }
+
+ async def fetch_config(self, user: UsersResponseWithInbounds, client_type: ConfigFormat) -> tuple[str, str]:
+ # Get client configuration
+ config = client_config.get(client_type)
+
+ # Generate subscription content
+ return (
+ await generate_subscription(
+ user=user,
+ config_format=config["config_format"],
+ as_base64=config["as_base64"],
+ ),
+ config["media_type"],
+ )
+
+ async def user_subscription(
+ self,
+ db: AsyncSession,
+ token: str,
+ accept_header: str = "",
+ user_agent: str = "",
+ request_url: str = "",
+ ):
+ """Provides a subscription link based on the user agent (Clash, V2Ray, etc.)."""
+ # Handle HTML request (subscription page)
+ sub_settings: SubSettings = await subscription_settings()
+ db_user = await self.get_validated_sub(db, token)
+ response_headers = self.create_response_headers(db_user, request_url, sub_settings)
+
+ user = await self.validated_user(db_user)
+
+ if "text/html" in accept_header:
+ template = (
+ db_user.admin.sub_template
+ if db_user.admin and db_user.admin.sub_template
+ else SUBSCRIPTION_PAGE_TEMPLATE
+ )
+ conf, media_type = await self.fetch_config(user, ConfigFormat.links)
+
+ return HTMLResponse(render_template(template, {"user": user, "links": conf.split("\n")}))
+ else:
+ client_type = await self.detect_client_type(user_agent, sub_settings.rules)
+ if client_type == ConfigFormat.block or not client_type:
+ await self.raise_error(message="Client not supported", code=406)
+
+ # Update user subscription info
+ await user_sub_update(db, db_user.id, user_agent)
+ conf, media_type = await self.fetch_config(user, client_type)
+
+ # Create response with appropriate headers
+ return Response(content=conf, media_type=media_type, headers=response_headers)
+
+ async def user_subscription_with_client_type(
+ self, db: AsyncSession, token: str, client_type: ConfigFormat, request_url: str = ""
+ ):
+ """Provides a subscription link based on the specified client type (e.g., Clash, V2Ray)."""
+ sub_settings: SubSettings = await subscription_settings()
+
+ if client_type == ConfigFormat.block or not getattr(sub_settings.manual_sub_request, client_type):
+ await self.raise_error(message="Client not supported", code=406)
+ db_user = await self.get_validated_sub(db, token=token)
+ response_headers = self.create_response_headers(db_user, request_url, sub_settings)
+
+ user = await self.validated_user(db_user)
+ conf, media_type = await self.fetch_config(user, client_type)
+
+ # Create response headers
+ return Response(content=conf, media_type=media_type, headers=response_headers)
+
+ async def user_subscription_info(self, db: AsyncSession, token: str) -> SubscriptionUserResponse:
+ """Retrieves detailed information about the user's subscription."""
+ return await self.get_validated_sub(db, token=token)
+
+ async def get_user_usage(
+ self,
+ db: AsyncSession,
+ token: str,
+ start: dt = None,
+ end: dt = None,
+ period: Period = Period.hour,
+ ) -> UserUsageStatsList:
+ """Fetches the usage statistics for the user within a specified date range."""
+ start, end = await self.validate_dates(start, end)
+
+ db_user = await self.get_validated_sub(db, token=token)
+
+ return await get_user_usages(db, db_user.id, start, end, period)
diff --git a/app/operation/system.py b/app/operation/system.py
new file mode 100644
index 000000000..e9d8d2727
--- /dev/null
+++ b/app/operation/system.py
@@ -0,0 +1,65 @@
+from datetime import timedelta
+
+from app import __version__
+from app.core.manager import core_manager
+from app.db import AsyncSession
+from app.db.crud.admin import get_admin
+from app.db.crud.general import get_system_usage
+from app.db.crud.user import count_online_users, get_users_count_by_status
+from app.db.models import UserStatus
+from app.models.admin import AdminDetails
+from app.models.system import SystemStats
+from app.utils.system import cpu_usage, memory_usage
+
+from . import BaseOperation
+
+
+class SystemOperation(BaseOperation):
+ @staticmethod
+ async def get_system_stats(db: AsyncSession, admin: AdminDetails, admin_username: str | None = None) -> SystemStats:
+ """Fetch system stats including memory, CPU, and user metrics."""
+ # Run sync functions
+ mem = memory_usage()
+ cpu = cpu_usage()
+
+ admin_param = None
+ if admin.is_sudo and admin_username:
+ admin_param = await get_admin(db, admin_username)
+ elif not admin.is_sudo:
+ admin_param = admin
+
+ if not admin_param:
+ system = await get_system_usage(db)
+ uplink = system.uplink
+ downlink = system.downlink
+ else:
+ uplink = 0
+ downlink = admin_param.used_traffic
+
+ admin_id = admin_param.id if admin_param else None
+
+ # Get user counts by status in a single query and online users count
+ statuses = [UserStatus.active, UserStatus.disabled, UserStatus.on_hold, UserStatus.expired, UserStatus.limited]
+ user_counts = await get_users_count_by_status(db, statuses, admin_id)
+ online_users = await count_online_users(db, timedelta(minutes=2), admin_id)
+
+ return SystemStats(
+ version=__version__,
+ mem_total=mem.total,
+ mem_used=mem.used,
+ cpu_cores=cpu.cores,
+ cpu_usage=cpu.percent,
+ total_user=user_counts["total"],
+ online_users=online_users,
+ active_users=user_counts[UserStatus.active.value],
+ disabled_users=user_counts[UserStatus.disabled.value],
+ expired_users=user_counts[UserStatus.expired.value],
+ limited_users=user_counts[UserStatus.limited.value],
+ on_hold_users=user_counts[UserStatus.on_hold.value],
+ incoming_bandwidth=uplink,
+ outgoing_bandwidth=downlink,
+ )
+
+ @staticmethod
+ async def get_inbounds() -> list[str]:
+ return await core_manager.get_inbounds()
diff --git a/app/operation/user.py b/app/operation/user.py
new file mode 100644
index 000000000..c14f8461c
--- /dev/null
+++ b/app/operation/user.py
@@ -0,0 +1,509 @@
+import asyncio
+import secrets
+from datetime import datetime as dt, timedelta as td, timezone as tz
+
+from pydantic import ValidationError
+from sqlalchemy.exc import IntegrityError
+
+from app import notification
+from app.db import AsyncSession
+from app.db.crud.admin import get_admin
+from app.db.crud.bulk import (
+ reset_all_users_data_usage,
+ update_users_datalimit,
+ update_users_expire,
+ update_users_proxy_settings,
+)
+from app.db.crud.user import (
+ UsersSortingOptions,
+ create_user,
+ get_all_users_usages,
+ get_expired_users,
+ get_user_sub_update_list,
+ get_user_usages,
+ get_users,
+ modify_user,
+ remove_user,
+ remove_users,
+ reset_user_by_next,
+ reset_user_data_usage,
+ revoke_user_sub,
+ set_owner,
+)
+from app.db.models import User, UserStatus, UserTemplate
+from app.models.admin import AdminDetails
+from app.models.stats import Period, UserUsageStatsList
+from app.models.user import (
+ BulkUser,
+ BulkUsersProxy,
+ CreateUserFromTemplate,
+ ModifyUserByTemplate,
+ RemoveUsersResponse,
+ UserCreate,
+ UserModify,
+ UserNotificationResponse,
+ UserResponse,
+ UsersResponse,
+ UserSubscriptionUpdateList,
+)
+from app.node import node_manager
+from app.operation import BaseOperation, OperatorType
+from app.utils.logger import get_logger
+from app.utils.jwt import create_subscription_token
+from app.settings import subscription_settings
+from config import SUBSCRIPTION_PATH
+
+
+logger = get_logger("user-operation")
+
+
+class UserOperation(BaseOperation):
+ @staticmethod
+ async def generate_subscription_url(user: UserNotificationResponse):
+ salt = secrets.token_hex(8)
+ settings = await subscription_settings()
+ url_prefix = (
+ user.admin.sub_domain.replace("*", salt)
+ if user.admin and user.admin.sub_domain
+ else (settings.url_prefix).replace("*", salt)
+ )
+ token = await create_subscription_token(user.username)
+ return f"{url_prefix}/{SUBSCRIPTION_PATH}/{token}"
+
+ async def validate_user(self, db_user: User) -> UserNotificationResponse:
+ user = UserNotificationResponse.model_validate(db_user)
+ user.subscription_url = await self.generate_subscription_url(user)
+ return user
+
+ async def update_user(self, db_user: User) -> UserNotificationResponse:
+ user = await self.validate_user(db_user)
+
+ if db_user.status in (UserStatus.active, UserStatus.on_hold):
+ user_inbounds = await db_user.inbounds()
+ await node_manager.update_user(user, inbounds=user_inbounds)
+ else:
+ await node_manager.remove_user(user)
+
+ return user
+
+ async def create_user(self, db: AsyncSession, new_user: UserCreate, admin: AdminDetails) -> UserResponse:
+ if new_user.next_plan is not None and new_user.next_plan.user_template_id is not None:
+ await self.get_validated_user_template(db, new_user.next_plan.user_template_id)
+
+ all_groups = await self.validate_all_groups(db, new_user)
+ db_admin = await get_admin(db, admin.username)
+
+ try:
+ db_user = await create_user(db, new_user, all_groups, db_admin)
+ except IntegrityError:
+ await self.raise_error(message="User already exists", code=409, db=db)
+
+ user = await self.update_user(db_user)
+
+ logger.info(f'New user "{db_user.username}" with id "{db_user.id}" added by admin "{admin.username}"')
+
+ asyncio.create_task(notification.create_user(user, admin))
+
+ return user
+
+ async def _modify_user(
+ self, db: AsyncSession, db_user: User, modified_user: UserModify, admin: AdminDetails
+ ) -> UserResponse:
+ if modified_user.group_ids:
+ await self.validate_all_groups(db, modified_user)
+
+ if modified_user.next_plan is not None and modified_user.next_plan.user_template_id is not None:
+ await self.get_validated_user_template(db, modified_user.next_plan.user_template_id)
+
+ old_status = db_user.status
+
+ db_user = await modify_user(db, db_user, modified_user)
+ user = await self.update_user(db_user)
+
+ logger.info(f'User "{user.username}" with id "{db_user.id}" modified by admin "{admin.username}"')
+
+ asyncio.create_task(notification.modify_user(user, admin))
+
+ if user.status != old_status:
+ asyncio.create_task(notification.user_status_change(user, admin))
+
+ logger.info(f'User "{db_user.username}" status changed from "{old_status.value}" to "{user.status.value}"')
+
+ return user
+
+ async def modify_user(
+ self, db: AsyncSession, username: str, modified_user: UserModify, admin: AdminDetails
+ ) -> UserResponse:
+ db_user = await self.get_validated_user(db, username, admin)
+
+ return await self._modify_user(db, db_user, modified_user, admin)
+
+ async def remove_user(self, db: AsyncSession, username: str, admin: AdminDetails):
+ db_user = await self.get_validated_user(db, username, admin)
+
+ user = await self.validate_user(db_user)
+ await remove_user(db, db_user)
+ node_manager.remove_user(user)
+
+ asyncio.create_task(notification.remove_user(user, admin))
+
+ logger.info(f'User "{db_user.username}" with id "{db_user.id}" deleted by admin "{admin.username}"')
+ return {}
+
+ async def _reset_user_data_usage(self, db: AsyncSession, db_user: User, admin: AdminDetails):
+ old_status = db_user.status
+
+ db_user = await reset_user_data_usage(db=db, db_user=db_user)
+ user = await self.update_user(db_user)
+
+ if user.status != old_status:
+ asyncio.create_task(notification.user_status_change(user, admin))
+
+ asyncio.create_task(notification.reset_user_data_usage(user, admin))
+
+ logger.info(f'User "{db_user.username}" usage was reset by admin "{admin.username}"')
+
+ return user
+
+ async def reset_user_data_usage(self, db: AsyncSession, username: str, admin: AdminDetails):
+ db_user = await self.get_validated_user(db, username, admin)
+
+ return await self._reset_user_data_usage(db, db_user, admin)
+
+ async def revoke_user_sub(self, db: AsyncSession, username: str, admin: AdminDetails) -> UserResponse:
+ db_user = await self.get_validated_user(db, username, admin)
+
+ db_user = await revoke_user_sub(db=db, db_user=db_user)
+ user = await self.update_user(db_user)
+
+ asyncio.create_task(notification.user_subscription_revoked(user, admin))
+
+ logger.info(f'User "{db_user.username}" subscription was revoked by admin "{admin.username}"')
+
+ return user
+
+ async def reset_users_data_usage(self, db: AsyncSession, admin: AdminDetails):
+ """Reset all users data usage"""
+ db_admin = await self.get_validated_admin(db, admin.username)
+ await reset_all_users_data_usage(db=db, admin=db_admin)
+
+ async def active_next_plan(self, db: AsyncSession, username: str, admin: AdminDetails) -> UserResponse:
+ """Reset user by next plan"""
+ db_user = await self.get_validated_user(db, username, admin)
+
+ if db_user is None or db_user.next_plan is None:
+ await self.raise_error(message="User doesn't have next plan", code=404)
+
+ old_status = db_user.status
+
+ db_user = await reset_user_by_next(db=db, db_user=db_user)
+
+ user = await self.update_user(db_user)
+
+ if user.status != old_status:
+ asyncio.create_task(notification.user_status_change(user, admin))
+
+ asyncio.create_task(notification.user_data_reset_by_next(user, admin))
+
+ logger.info(f'User "{db_user.username}"\'s usage was reset by next plan by admin "{admin.username}"')
+
+ return user
+
+ async def set_owner(
+ self, db: AsyncSession, username: str, admin_username: str, admin: AdminDetails
+ ) -> UserResponse:
+ """Set a new owner (admin) for a user."""
+ new_admin = await self.get_validated_admin(db, username=admin_username)
+ db_user = await self.get_validated_user(db, username, admin)
+
+ db_user = await set_owner(db, db_user, new_admin)
+ user = await self.validate_user(db_user)
+ logger.info(f'{user.username}"owner successfully set to{new_admin.username} by admin "{admin.username}"')
+
+ return user
+
+ async def get_user_usage(
+ self,
+ db: AsyncSession,
+ username: str,
+ admin: AdminDetails,
+ start: dt = None,
+ end: dt = None,
+ period: Period = Period.hour,
+ node_id: int | None = None,
+ group_by_node: bool = False,
+ ) -> UserUsageStatsList:
+ start, end = await self.validate_dates(start, end)
+ db_user = await self.get_validated_user(db, username, admin)
+
+ if not admin.is_sudo:
+ node_id = None
+ group_by_node = False
+
+ return await get_user_usages(db, db_user.id, start, end, period, node_id=node_id, group_by_node=group_by_node)
+
+ async def get_user(self, db: AsyncSession, username: str, admin: AdminDetails) -> UserNotificationResponse:
+ db_user = await self.get_validated_user(db, username, admin)
+ return await self.validate_user(db_user)
+
+ async def get_user_by_id(self, db: AsyncSession, user_id: int, admin: AdminDetails) -> UserNotificationResponse:
+ db_user = await self.get_validated_user_by_id(db, user_id, admin)
+ return await self.validate_user(db_user)
+
+ async def get_users(
+ self,
+ db: AsyncSession,
+ admin: AdminDetails,
+ offset: int = None,
+ limit: int = None,
+ username: list[str] = None,
+ search: str | None = None,
+ owner: list[str] | None = None,
+ status: UserStatus | None = None,
+ sort: str | None = None,
+ proxy_id: str | None = None,
+ load_sub: bool = False,
+ group_ids: list[int] | None = None,
+ ) -> UsersResponse:
+ """Get all users"""
+ sort_list = []
+ if sort is not None:
+ opts = sort.strip(",").split(",")
+ for opt in opts:
+ try:
+ enum_member = UsersSortingOptions[opt]
+ sort_list.append(enum_member.value)
+ except KeyError:
+ await self.raise_error(message=f'"{opt}" is not a valid sort option', code=400)
+
+ users, count = await get_users(
+ db=db,
+ offset=offset,
+ limit=limit,
+ search=search,
+ usernames=username,
+ status=status,
+ sort=sort_list,
+ proxy_id=proxy_id,
+ admins=owner if admin.is_sudo else [admin.username],
+ return_with_count=True,
+ group_ids=group_ids,
+ )
+
+ if load_sub:
+ tasks = [self.generate_subscription_url(user) for user in users]
+ urls = await asyncio.gather(*tasks)
+
+ for user, url in zip(users, urls):
+ user.subscription_url = url
+
+ response = UsersResponse(users=users, total=count)
+
+ return response
+
+ async def get_users_usage(
+ self,
+ db: AsyncSession,
+ admin: AdminDetails,
+ start: dt = None,
+ end: dt = None,
+ owner: list[str] | None = None,
+ period: Period = Period.hour,
+ node_id: int | None = None,
+ group_by_node: bool = False,
+ ) -> UserUsageStatsList:
+ """Get all users usage"""
+ start, end = await self.validate_dates(start, end)
+
+ if not admin.is_sudo:
+ node_id = None
+ group_by_node = False
+
+ return await get_all_users_usages(
+ db=db,
+ start=start,
+ end=end,
+ period=period,
+ node_id=node_id,
+ admin=owner if admin.is_sudo else [admin.username],
+ group_by_node=group_by_node,
+ )
+
+ @staticmethod
+ async def remove_users_logger(users: list[str], by: str):
+ for user in users:
+ logger.info(f'User "{user}" deleted by admin "{by}"')
+
+ async def get_expired_users(
+ self,
+ db: AsyncSession,
+ expired_after: dt = None,
+ expired_before: dt = None,
+ admin_username: str = None,
+ ) -> list[str]:
+ """
+ Get users who have expired within the specified date range.
+
+ - **expired_after** UTC datetime (optional)
+ - **expired_before** UTC datetime (optional)
+ - At least one of expired_after or expired_before must be provided for filtering
+ - If both are omitted, returns all expired users
+ """
+
+ expired_after, expired_before = await self.validate_dates(expired_after, expired_before)
+ if admin_username:
+ admin_id = (await self.get_validated_admin(db, admin_username)).id
+ else:
+ admin_id = None
+ users = await get_expired_users(db, expired_after, expired_before, admin_id)
+ return [row.username for row in users]
+
+ async def delete_expired_users(
+ self,
+ db: AsyncSession,
+ admin: AdminDetails,
+ expired_after: dt = None,
+ expired_before: dt = None,
+ admin_username: str = None,
+ ) -> RemoveUsersResponse:
+ """
+ Delete users who have expired within the specified date range.
+
+ - **expired_after** UTC datetime (optional)
+ - **expired_before** UTC datetime (optional)
+ - At least one of expired_after or expired_before must be provided
+ """
+
+ expired_after, expired_before = await self.validate_dates(expired_after, expired_before)
+ if admin_username:
+ admin_id = (await self.get_validated_admin(db, admin_username)).id
+ else:
+ admin_id = None
+ users = await get_expired_users(db, expired_after, expired_before, admin_id)
+ await remove_users(db, users)
+
+ username_list = [row.username for row in users]
+ self.remove_users_logger(users=username_list, by=admin.username)
+
+ return RemoveUsersResponse(users=username_list, count=len(username_list))
+
+ @staticmethod
+ def load_base_user_args(template: UserTemplate) -> dict:
+ user_args = {
+ "data_limit": template.data_limit,
+ "group_ids": template.group_ids,
+ "data_limit_reset_strategy": template.data_limit_reset_strategy,
+ "status": template.status,
+ }
+
+ if template.status == UserStatus.active:
+ if template.expire_duration:
+ user_args["expire"] = dt.now(tz.utc) + td(seconds=template.expire_duration)
+ else:
+ user_args["expire"] = None
+ else:
+ user_args["expire"] = 0
+ user_args["on_hold_expire_duration"] = template.expire_duration
+ if template.on_hold_timeout:
+ user_args["on_hold_timeout"] = dt.now(tz.utc) + td(seconds=template.on_hold_timeout)
+ else:
+ user_args["on_hold_timeout"] = None
+
+ return user_args
+
+ @staticmethod
+ def apply_settings(user_args: UserCreate | UserModify, template: UserTemplate) -> dict:
+ if template.extra_settings:
+ flow = template.extra_settings.get("flow", None)
+ method = template.extra_settings.get("method", None)
+
+ if flow is not None:
+ user_args.proxy_settings.vless.flow = flow
+
+ if method is not None:
+ user_args.proxy_settings.shadowsocks.method = method
+
+ return user_args
+
+ async def create_user_from_template(
+ self, db: AsyncSession, new_template_user: CreateUserFromTemplate, admin: AdminDetails
+ ) -> UserResponse:
+ user_template = await self.get_validated_user_template(db, new_template_user.user_template_id)
+
+ if user_template.is_disabled:
+ await self.raise_error("this template is disabled", 403)
+
+ new_user_args = self.load_base_user_args(user_template)
+ new_user_args["username"] = (
+ f"{user_template.username_prefix if user_template.username_prefix else ''}{new_template_user.username}{user_template.username_suffix if user_template.username_suffix else ''}"
+ )
+
+ try:
+ new_user = UserCreate(**new_user_args, note=new_template_user.note)
+ except ValidationError as e:
+ error_messages = "; ".join([f"{err['loc'][0]}: {err['msg']}" for err in e.errors()])
+ await self.raise_error(message=error_messages, code=400)
+
+ new_user = self.apply_settings(new_user, user_template)
+
+ return await self.create_user(db, new_user, admin)
+
+ async def modify_user_with_template(
+ self, db: AsyncSession, username: str, modified_template: ModifyUserByTemplate, admin: AdminDetails
+ ) -> UserResponse:
+ db_user = await self.get_validated_user(db, username, admin)
+ user_template = await self.get_validated_user_template(db, modified_template.user_template_id)
+
+ if user_template.is_disabled:
+ await self.raise_error("this template is disabled", 403)
+
+ user_args = self.load_base_user_args(user_template)
+ user_args["proxy_settings"] = db_user.proxy_settings
+
+ try:
+ modify_user = UserModify(**user_args, note=modified_template.note)
+ except ValidationError as e:
+ error_messages = "; ".join([f"{err['loc'][0]}: {err['msg']}" for err in e.errors()])
+ await self.raise_error(message=error_messages, code=400)
+
+ modify_user = self.apply_settings(modify_user, user_template)
+
+ if user_template.reset_usages:
+ await self._reset_user_data_usage(db, db_user, admin)
+
+ return await self._modify_user(db, db_user, modify_user, admin)
+
+ async def bulk_modify_expire(self, db: AsyncSession, bulk_model: BulkUser):
+ users, users_count = await update_users_expire(db, bulk_model)
+
+ await node_manager.update_users(users)
+
+ if self.operator_type in (OperatorType.API, OperatorType.WEB):
+ return {"detail": f"operation has been successfuly done on {users_count} users"}
+ return users_count
+
+ async def bulk_modify_datalimit(self, db: AsyncSession, bulk_model: BulkUser):
+ users, users_count = await update_users_datalimit(db, bulk_model)
+
+ await node_manager.update_users(users)
+
+ if self.operator_type in (OperatorType.API, OperatorType.WEB):
+ return {"detail": f"operation has been successfuly done on {users_count} users"}
+ return users_count
+
+ async def bulk_modify_proxy_settings(self, db: AsyncSession, bulk_model: BulkUsersProxy):
+ users, users_count = await update_users_proxy_settings(db, bulk_model)
+
+ await node_manager.update_users(users)
+
+ if self.operator_type in (OperatorType.API, OperatorType.WEB):
+ return {"detail": f"operation has been successfuly done on {users_count} users"}
+ return users_count
+
+ async def get_user_sub_update_list(
+ self, db: AsyncSession, username: str, admin: AdminDetails, offset: int = 0, limit: int = 10
+ ) -> UserSubscriptionUpdateList:
+ db_user = await self.get_validated_user(db, username, admin)
+ user_sub_data, count = await get_user_sub_update_list(db, user_id=db_user.id, offset=offset, limit=limit)
+
+ return UserSubscriptionUpdateList(updates=user_sub_data, count=count)
diff --git a/app/operation/user_template.py b/app/operation/user_template.py
new file mode 100644
index 000000000..da959b49e
--- /dev/null
+++ b/app/operation/user_template.py
@@ -0,0 +1,68 @@
+from sqlalchemy.exc import IntegrityError
+
+from app.db import AsyncSession
+import asyncio
+
+from app.db.models import Admin
+from app.db.crud.user_template import (
+ create_user_template,
+ modify_user_template,
+ remove_user_template,
+ get_user_templates,
+)
+from app.operation import BaseOperation
+from app.models.user_template import UserTemplateCreate, UserTemplateModify, UserTemplateResponse
+from app.utils.logger import get_logger
+from app import notification
+
+logger = get_logger("user-template-operation")
+
+
+class UserTemplateOperation(BaseOperation):
+ async def create_user_template(
+ self, db: AsyncSession, new_user_template: UserTemplateCreate, admin: Admin
+ ) -> UserTemplateResponse:
+ for group_id in new_user_template.group_ids:
+ await self.get_validated_group(db, group_id)
+ try:
+ db_user_template = await create_user_template(db, new_user_template)
+ except IntegrityError:
+ await self.raise_error("Template by this name already exists", 409, db=db)
+
+ user_template = UserTemplateResponse.model_validate(db_user_template)
+
+ asyncio.create_task(notification.create_user_template(user_template, admin.username))
+
+ logger.info(f'User template "{db_user_template.name}" created by admin "{admin.username}"')
+ return db_user_template
+
+ async def modify_user_template(
+ self, db: AsyncSession, template_id: int, modified_user_template: UserTemplateModify, admin: Admin
+ ) -> UserTemplateResponse:
+ db_user_template = await self.get_validated_user_template(db, template_id)
+ if modified_user_template.group_ids:
+ for group_id in modified_user_template.group_ids:
+ await self.get_validated_group(db, group_id)
+ try:
+ db_user_template = await modify_user_template(db, db_user_template, modified_user_template)
+ except IntegrityError:
+ await self.raise_error("Template by this name already exists", 409, db=db)
+
+ user_template = UserTemplateResponse.model_validate(db_user_template)
+
+ asyncio.create_task(notification.modify_user_template(user_template, admin.username))
+
+ logger.info(f'User template "{db_user_template.name}" modified by admin "{admin.username}"')
+ return db_user_template
+
+ async def remove_user_template(self, db: AsyncSession, template_id: int, admin: Admin) -> None:
+ db_user_template = await self.get_validated_user_template(db, template_id)
+ await remove_user_template(db, db_user_template)
+ logger.info(f'User template "{db_user_template.name}" deleted by admin "{admin.username}"')
+
+ asyncio.create_task(notification.remove_user_template(db_user_template.name, admin.username))
+
+ async def get_user_templates(
+ self, db: AsyncSession, offset: int = None, limit: int = None
+ ) -> list[UserTemplateResponse]:
+ return await get_user_templates(db, offset, limit)
diff --git a/app/routers/__init__.py b/app/routers/__init__.py
new file mode 100644
index 000000000..ccf06c431
--- /dev/null
+++ b/app/routers/__init__.py
@@ -0,0 +1,24 @@
+from fastapi import APIRouter
+
+from . import admin, core, group, home, host, node, settings, subscription, system, user, user_template
+
+api_router = APIRouter()
+
+routers = [
+ home.router,
+ admin.router,
+ system.router,
+ settings.router,
+ group.router,
+ core.router,
+ host.router,
+ node.router,
+ user.router,
+ subscription.router,
+ user_template.router,
+]
+
+for router in routers:
+ api_router.include_router(router)
+
+__all__ = ["api_router"]
diff --git a/app/routers/admin.py b/app/routers/admin.py
new file mode 100644
index 000000000..c2a5a7f27
--- /dev/null
+++ b/app/routers/admin.py
@@ -0,0 +1,154 @@
+import asyncio
+from fastapi import APIRouter, Depends, HTTPException, Request, status, Header
+from fastapi.security import OAuth2PasswordRequestForm
+from app import notification
+from app.db import AsyncSession, get_db
+from app.models.admin import AdminCreate, AdminDetails, AdminModify, Token
+from app.operation import OperatorType
+from app.operation.admin import AdminOperation
+from app.utils import responses
+from app.utils.jwt import create_admin_token
+from .authentication import check_sudo_admin, get_current, validate_admin, validate_mini_app_admin
+
+router = APIRouter(tags=["Admin"], prefix="/api/admin", responses={401: responses._401, 403: responses._403})
+admin_operator = AdminOperation(operator_type=OperatorType.API)
+
+
+def get_client_ip(request: Request) -> str:
+ """Extract the client's IP address from the request headers or client."""
+ forwarded_for = request.headers.get("X-Forwarded-For")
+ if forwarded_for:
+ return forwarded_for.split(",")[0].strip()
+ if request.client:
+ return request.client.host
+ return "Unknown"
+
+
+@router.post("/token", response_model=Token)
+async def admin_token(
+ request: Request, form_data: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)
+):
+ """Authenticate an admin and issue a token."""
+ client_ip = get_client_ip(request)
+
+ db_admin = await validate_admin(db, form_data.username, form_data.password)
+ if not db_admin:
+ asyncio.create_task(notification.admin_login(form_data.username, form_data.password, client_ip, False))
+ raise HTTPException(
+ status_code=401,
+ detail="Incorrect username or password",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+ if db_admin.is_disabled:
+ asyncio.create_task(notification.admin_login(form_data.username, form_data.password, client_ip, False))
+ raise HTTPException(
+ status_code=403,
+ detail="your account has been disabled",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+ asyncio.create_task(notification.admin_login(db_admin.username, "", client_ip, True))
+ return Token(access_token=await create_admin_token(form_data.username, db_admin.is_sudo))
+
+
+@router.post("/miniapp/token")
+async def admin_mini_app_token(
+ request: Request, x_telegram_authorization: str = Header(), db: AsyncSession = Depends(get_db)
+):
+ """Authenticate an admin and issue a token."""
+
+ client_ip = get_client_ip(request)
+
+ db_admin = await validate_mini_app_admin(db, x_telegram_authorization)
+ if not db_admin:
+ raise HTTPException(
+ status_code=401,
+ detail="admin not found.",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+ if db_admin.is_disabled:
+ raise HTTPException(
+ status_code=403,
+ detail="your account has been disabled",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+ asyncio.create_task(notification.admin_login(db_admin.username, "", client_ip, True))
+ return Token(access_token=await create_admin_token(db_admin.username, db_admin.is_sudo))
+
+
+@router.post(
+ "",
+ response_model=AdminDetails,
+ responses={201: {"description": "Admin created successfully"}, 409: responses._409},
+ status_code=status.HTTP_201_CREATED,
+)
+async def create_admin(
+ new_admin: AdminCreate, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin)
+):
+ """Create a new admin if the current admin has sudo privileges."""
+ return await admin_operator.create_admin(db, new_admin=new_admin, admin=admin)
+
+
+@router.put("/{username}", response_model=AdminDetails, responses={403: responses._403, 404: responses._404})
+async def modify_admin(
+ username: str,
+ modified_admin: AdminModify,
+ db: AsyncSession = Depends(get_db),
+ current_admin: AdminDetails = Depends(check_sudo_admin),
+):
+ """Modify an existing admin's details."""
+ return await admin_operator.modify_admin(
+ db, username=username, modified_admin=modified_admin, current_admin=current_admin
+ )
+
+
+@router.delete("/{username}", status_code=status.HTTP_204_NO_CONTENT)
+async def remove_admin(
+ username: str, db: AsyncSession = Depends(get_db), current_admin: AdminDetails = Depends(check_sudo_admin)
+):
+ """Remove an admin from the database."""
+ await admin_operator.remove_admin(db, username=username, current_admin=current_admin)
+ return {}
+
+
+@router.get("", response_model=AdminDetails)
+def get_current_admin(admin: AdminDetails = Depends(get_current)):
+ """Retrieve the current authenticated admin."""
+ return admin
+
+
+@router.get("s", response_model=list[AdminDetails])
+async def get_admins(
+ username: str | None = None,
+ offset: int | None = None,
+ limit: int | None = None,
+ db: AsyncSession = Depends(get_db),
+ _: AdminDetails = Depends(check_sudo_admin),
+):
+ """Fetch a list of admins with optional filters for pagination and username."""
+ return await admin_operator.get_admins(db, username=username, offset=offset, limit=limit)
+
+
+@router.post("/{username}/users/disable", responses={404: responses._404})
+async def disable_all_active_users(
+ username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin)
+):
+ """Disable all active users under a specific admin"""
+ await admin_operator.disable_all_active_users(db, username=username, admin=admin)
+ return {}
+
+
+@router.post("/{username}/users/activate", responses={404: responses._404})
+async def activate_all_disabled_users(
+ username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin)
+):
+ """Activate all disabled users under a specific admin"""
+ await admin_operator.activate_all_disabled_users(db, username=username, admin=admin)
+ return {}
+
+
+@router.post("/{username}/reset", response_model=AdminDetails, responses={404: responses._404})
+async def reset_admin_usage(
+ username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin)
+):
+ """Resets usage of admin."""
+ return await admin_operator.reset_admin_usage(db, username=username, admin=admin)
diff --git a/app/routers/authentication.py b/app/routers/authentication.py
new file mode 100644
index 000000000..a6236f659
--- /dev/null
+++ b/app/routers/authentication.py
@@ -0,0 +1,100 @@
+from datetime import timezone as tz
+
+from aiogram.utils.web_app import WebAppInitData, safe_parse_webapp_init_data
+from fastapi import Depends, HTTPException, status
+from fastapi.security import OAuth2PasswordBearer
+
+from app.db import AsyncSession, get_db
+from app.db.crud.admin import get_admin as get_admin_by_username, get_admin_by_telegram_id
+from app.models.admin import AdminDetails, AdminInDB, AdminValidationResult
+from app.models.settings import Telegram
+from app.settings import telegram_settings
+from app.utils.jwt import get_admin_payload
+from config import DEBUG, SUDOERS
+
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/admin/token")
+
+
+async def get_admin(db: AsyncSession, token: str) -> AdminDetails | None:
+ payload = await get_admin_payload(token)
+ if not payload:
+ return
+
+ db_admin = await get_admin_by_username(db, payload["username"])
+ if db_admin:
+ if db_admin.password_reset_at:
+ if not payload.get("created_at"):
+ return
+ if db_admin.password_reset_at.astimezone(tz.utc) > payload.get("created_at"):
+ return
+
+ return AdminDetails.model_validate(db_admin)
+
+ elif payload["username"] in SUDOERS and payload["is_sudo"] is True:
+ return AdminDetails(username=payload["username"], is_sudo=True)
+
+
+async def get_current(db: AsyncSession = Depends(get_db), token: str = Depends(oauth2_scheme)):
+ admin: AdminDetails | None = await get_admin(db, token)
+ if not admin:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Could not validate credentials",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+ if admin.is_disabled:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="your account has been disabled",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+ return admin
+
+
+async def check_sudo_admin(admin: AdminDetails = Depends(get_current)):
+ if not admin.is_sudo:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You're not allowed")
+ return admin
+
+
+async def validate_admin(db: AsyncSession, username: str, password: str) -> AdminValidationResult | None:
+ """Validate admin credentials with environment variables or database."""
+
+ db_admin = await get_admin_by_username(db, username)
+ if db_admin and AdminInDB.model_validate(db_admin).verify_password(password):
+ return AdminValidationResult(
+ username=db_admin.username, is_sudo=db_admin.is_sudo, is_disabled=db_admin.is_disabled
+ )
+
+ if not db_admin and SUDOERS.get(username) == password:
+ if not DEBUG:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="env admin not allowed in production")
+
+ return AdminValidationResult(username=username, is_sudo=True, is_disabled=False)
+
+
+async def validate_mini_app_admin(db: AsyncSession, token: str) -> AdminValidationResult | None:
+ """Validate raw MiniApp init data and return it as AdminValidationResult object"""
+ settings: Telegram = await telegram_settings()
+
+ if not settings.mini_app_login or not settings.enable:
+ raise HTTPException(
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+ detail="service unavailable",
+ )
+
+ try:
+ data: WebAppInitData = safe_parse_webapp_init_data(token=settings.token, init_data=token)
+ except ValueError:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="invalid token",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+ db_admin = await get_admin_by_telegram_id(db, data.user.id)
+ if db_admin:
+ return AdminValidationResult(
+ username=db_admin.username, is_sudo=db_admin.is_sudo, is_disabled=db_admin.is_disabled
+ )
diff --git a/app/routers/core.py b/app/routers/core.py
new file mode 100644
index 000000000..acbf89c30
--- /dev/null
+++ b/app/routers/core.py
@@ -0,0 +1,85 @@
+from fastapi import APIRouter, Depends, status
+
+from app.db import AsyncSession, get_db
+from app.models.admin import AdminDetails
+from app.models.core import CoreCreate, CoreResponse, CoreResponseList
+from app.operation import OperatorType
+from app.operation.core import CoreOperation
+from app.operation.node import NodeOperation
+from app.utils import responses
+
+from .authentication import check_sudo_admin
+
+core_operator = CoreOperation(operator_type=OperatorType.API)
+node_operator = NodeOperation(operator_type=OperatorType.API)
+router = APIRouter(tags=["Core"], prefix="/api/core", responses={401: responses._401, 403: responses._403})
+
+
+@router.post("", response_model=CoreResponse, status_code=status.HTTP_201_CREATED)
+async def create_core_config(
+ new_core: CoreCreate, admin: AdminDetails = Depends(check_sudo_admin), db: AsyncSession = Depends(get_db)
+):
+ """Create a new core configuration."""
+ return await core_operator.create_core(db, new_core, admin)
+
+
+@router.get("/{core_id}", response_model=CoreResponse)
+async def get_core_config(
+ core_id: int, _: AdminDetails = Depends(check_sudo_admin), db: AsyncSession = Depends(get_db)
+) -> dict:
+ """Get a core configuration by its ID."""
+ return await core_operator.get_validated_core_config(db, core_id)
+
+
+@router.put("/{core_id}", response_model=CoreResponse)
+async def modify_core_config(
+ core_id: int,
+ restart_nodes: bool,
+ modified_core: CoreCreate,
+ admin: AdminDetails = Depends(check_sudo_admin),
+ db: AsyncSession = Depends(get_db),
+):
+ """Update an existing core configuration."""
+ response = await core_operator.modify_core(db, core_id, modified_core, admin)
+
+ if restart_nodes:
+ await node_operator.restart_all_node(db=db, core_id=core_id, admin=admin)
+
+ return response
+
+
+@router.delete("/{core_id}", status_code=status.HTTP_204_NO_CONTENT)
+async def delete_core_config(
+ core_id: int,
+ restart_nodes: bool = False,
+ admin: AdminDetails = Depends(check_sudo_admin),
+ db: AsyncSession = Depends(get_db),
+):
+ """Delete a core configuration."""
+ await core_operator.delete_core(db, core_id, admin)
+
+ if restart_nodes:
+ await node_operator.restart_all_node(db=db, core_id=core_id, admin=admin)
+
+ return {}
+
+
+@router.get("s", response_model=CoreResponseList)
+async def get_all_cores(
+ offset: int | None = None,
+ limit: int | None = None,
+ _: AdminDetails = Depends(check_sudo_admin),
+ db: AsyncSession = Depends(get_db),
+):
+ """Get a list of all core configurations."""
+ return await core_operator.get_all_cores(db, offset, limit)
+
+
+@router.post("/{core_id}/restart", status_code=status.HTTP_204_NO_CONTENT)
+async def restart_core(
+ core_id: int, admin: AdminDetails = Depends(check_sudo_admin), db: AsyncSession = Depends(get_db)
+):
+ """restart nodes related to the core config"""
+
+ await node_operator.restart_all_node(db=db, core_id=core_id, admin=admin)
+ return {}
diff --git a/app/routers/group.py b/app/routers/group.py
new file mode 100644
index 000000000..8343aad75
--- /dev/null
+++ b/app/routers/group.py
@@ -0,0 +1,205 @@
+from fastapi import APIRouter, Depends, status
+
+from app.db import AsyncSession, get_db
+from app.models.admin import AdminDetails
+from app.models.group import BulkGroup, GroupCreate, GroupModify, GroupResponse, GroupsResponse
+from app.operation import OperatorType
+from app.operation.group import GroupOperation
+from app.utils import responses
+
+from .authentication import check_sudo_admin, get_current
+
+router = APIRouter(prefix="/api/group", tags=["Groups"], responses={401: responses._401, 403: responses._403})
+group_operator = GroupOperation(OperatorType.API)
+
+
+@router.post(
+ "",
+ response_model=GroupResponse,
+ status_code=status.HTTP_201_CREATED,
+ summary="Create a new group",
+ description="Creates a new group in the system. Only sudo administrators can create groups.",
+)
+async def create_group(
+ new_group: GroupCreate, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin)
+):
+ """
+ Create a new group in the system.
+
+ The group model has the following properties:
+ - **name**: String (3-64 chars) containing only a-z and 0-9
+ - **inbound_tags**: List of inbound tags that this group can access
+ - **is_disabled**: Boolean flag to disable/enable the group
+
+ Returns:
+ GroupResponse: The created group data with additional fields:
+ - **id**: Unique identifier for the group
+ - **total_users**: Number of users in this group
+
+ Raises:
+ 401: Unauthorized - If not authenticated
+ 403: Forbidden - If not sudo admin
+ """
+ return await group_operator.create_group(db, new_group, admin)
+
+
+@router.get(
+ "s",
+ response_model=GroupsResponse,
+ summary="List all groups",
+ description="Retrieves a paginated list of all groups in the system. Requires admin authentication.",
+)
+async def get_all_groups(
+ offset: int = None, limit: int = None, db: AsyncSession = Depends(get_db), _: AdminDetails = Depends(get_current)
+):
+ """
+ Retrieve a list of all groups with optional pagination.
+
+ The response includes:
+ - **groups**: List of GroupResponse objects containing:
+ - **id**: Unique identifier
+ - **name**: Group name
+ - **inbound_tags**: List of allowed inbound tags
+ - **is_disabled**: Group status
+ - **total_users**: Number of users in group
+ - **total**: Total count of groups
+
+ Returns:
+ GroupsResponse: List of groups and total count
+
+ Raises:
+ 401: Unauthorized - If not authenticated
+ """
+ return await group_operator.get_all_groups(db, offset, limit)
+
+
+@router.get(
+ "/{group_id}",
+ response_model=GroupResponse,
+ summary="Get group details",
+ description="Retrieves detailed information about a specific group by its ID.",
+ responses={404: responses._404},
+)
+async def get_group(group_id: int, db: AsyncSession = Depends(get_db), _: AdminDetails = Depends(get_current)):
+ """
+ Get a specific group by its **ID**.
+
+ The response includes:
+ - **id**: Unique identifier
+ - **name**: Group name (3-64 chars, a-z, 0-9)
+ - **inbound_tags**: List of allowed inbound tags
+ - **is_disabled**: Group status
+ - **total_users**: Number of users in group
+
+ Returns:
+ GroupResponse: The requested group data
+
+ Raises:
+ 404: Not Found - If group doesn't exist
+ """
+ return await group_operator.get_validated_group(db, group_id)
+
+
+@router.put(
+ "/{group_id}",
+ response_model=GroupResponse,
+ summary="Modify group",
+ description="Updates an existing group's information. Only sudo administrators can modify groups.",
+ responses={404: responses._404},
+)
+async def modify_group(
+ group_id: int,
+ modified_group: GroupModify,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(check_sudo_admin),
+):
+ """
+ Modify an existing group's information.
+
+ The group model can be modified with:
+ - **name**: String (3-64 chars) containing only a-z and 0-9
+ - **inbound_tags**: List of inbound tags that this group can access
+ - **is_disabled**: Boolean flag to disable/enable the group
+
+ Returns:
+ GroupResponse: The modified group data with additional fields:
+ - **id**: Unique identifier for the group
+ - **total_users**: Number of users in this group
+
+ Raises:
+ 401: Unauthorized - If not authenticated
+ 403: Forbidden - If not sudo admin
+ 404: Not Found - If group doesn't exist
+ """
+ return await group_operator.modify_group(db, group_id, modified_group, admin)
+
+
+@router.delete(
+ "/{group_id}",
+ status_code=status.HTTP_204_NO_CONTENT,
+ summary="Remove group",
+ description="Deletes a group from the system. Only sudo administrators can delete groups.",
+ responses={404: responses._404},
+)
+async def remove_group(
+ group_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin)
+):
+ """
+ Remove a group by its **ID**.
+
+ Returns:
+ dict: Empty dictionary on successful deletion
+
+ Raises:
+ 401: Unauthorized - If not authenticated
+ 403: Forbidden - If not sudo admin
+ 404: Not Found - If group doesn't exist
+ """
+ await group_operator.remove_group(db, group_id, admin)
+ return {}
+
+
+@router.post(
+ "s/bulk/add",
+ summary="Bulk add groups to users",
+ response_description="Success confirmation",
+)
+async def bulk_add_groups_to_users(
+ bulk_group: BulkGroup, db: AsyncSession = Depends(get_db), _: AdminDetails = Depends(get_current)
+):
+ """
+ Bulk assign groups to multiple users, users under specific admins, or all users.
+
+ - **group_ids**: List of group IDs to add (required)
+ - **users**: Optional list of user IDs to assign the groups to
+ - **admins**: Optional list of admin IDs — their users will be targeted
+
+ Notes:
+ - If neither 'users' nor 'admins' are provided, groups will be added to *all users*
+ - Existing user-group associations will be ignored (no duplication)
+ - Returns list of affected users (those who received new group associations)
+ """
+ return await group_operator.bulk_add_groups(db, bulk_group)
+
+
+@router.post(
+ "s/bulk/remove",
+ summary="Bulk remove groups from users",
+ response_description="Success confirmation",
+)
+async def bulk_remove_users_from_groups(
+ bulk_group: BulkGroup, db: AsyncSession = Depends(get_db), _: AdminDetails = Depends(get_current)
+):
+ """
+ Bulk remove groups from multiple users, users under specific admins, or all users.
+
+ - **group_ids**: List of group IDs to remove (required)
+ - **users**: Optional list of user IDs to remove the groups from
+ - **admins**: Optional list of admin IDs — their users will be targeted
+
+ Notes:
+ - If neither 'users' nor 'admins' are provided, groups will be removed from *all users*
+ - Only existing user-group associations will be removed
+ - Returns list of affected users (those who had groups removed)
+ """
+ return await group_operator.bulk_remove_groups(db, bulk_group)
diff --git a/app/routers/home.py b/app/routers/home.py
new file mode 100644
index 000000000..7e07d385f
--- /dev/null
+++ b/app/routers/home.py
@@ -0,0 +1,52 @@
+from typing import Optional
+
+from fastapi import APIRouter, Request
+from fastapi.responses import HTMLResponse, JSONResponse
+
+from app.templates import render_template
+from config import DASHBOARD_PATH, HOME_PAGE_TEMPLATE
+
+DASHBOARD_ROUTE = DASHBOARD_PATH.rstrip("/")
+router = APIRouter()
+
+
+@router.get("/", response_class=HTMLResponse)
+async def base():
+ return render_template(HOME_PAGE_TEMPLATE)
+
+
+@router.get("/manifest.json")
+async def get_manifest(request: Request, start_url: Optional[str] = None):
+ """
+ Dynamic PWA manifest generator
+ """
+ # Get the base URL
+ # base_url = str(request.base_url).rstrip("/")
+
+ # Determine start URL - prioritize query param, then dashboard path, then root
+ if start_url:
+ # Validate that start_url is within the dashboard path for security
+ if start_url.startswith(DASHBOARD_ROUTE):
+ resolved_start_url = start_url
+ else:
+ resolved_start_url = DASHBOARD_ROUTE
+ else:
+ resolved_start_url = DASHBOARD_ROUTE or "/"
+
+ manifest = {
+ "name": "PasarGuard",
+ "short_name": "PasarGuard",
+ "description": "PasarGuard: Modern dashboard for managing proxies and users.",
+ "theme_color": "#1b1b1d",
+ "background_color": "#1b1b1d",
+ "display": "standalone",
+ "start_url": resolved_start_url,
+ "scope": DASHBOARD_ROUTE or "/",
+ "icons": [
+ {"src": "/statics/favicon/logo-pwa.png", "sizes": "192x192", "type": "image/png"},
+ {"src": "/statics/favicon/logo-pwa.png", "sizes": "512x512", "type": "image/png"},
+ {"src": "/statics/favicon/logo-pwa.png", "sizes": "180x180", "type": "image/png"},
+ ],
+ }
+
+ return JSONResponse(content=manifest)
diff --git a/app/routers/host.py b/app/routers/host.py
new file mode 100644
index 000000000..f7a5fcb2e
--- /dev/null
+++ b/app/routers/host.py
@@ -0,0 +1,85 @@
+from fastapi import APIRouter, Depends, status
+
+from app.db import AsyncSession, get_db
+from app.models.admin import AdminDetails
+from app.models.host import BaseHost, CreateHost
+from app.operation import OperatorType
+from app.operation.host import HostOperation
+from app.utils import responses
+
+from .authentication import check_sudo_admin
+
+host_operator = HostOperation(operator_type=OperatorType.API)
+router = APIRouter(tags=["Host"], prefix="/api/host", responses={401: responses._401, 403: responses._403})
+
+
+@router.get("/{host_id}", response_model=BaseHost)
+async def get_host(host_id: int, db: AsyncSession = Depends(get_db), _: AdminDetails = Depends(check_sudo_admin)):
+ """
+ get host by **id**
+ """
+ return await host_operator.get_validated_host(db=db, host_id=host_id)
+
+
+@router.get("s", response_model=list[BaseHost])
+async def get_hosts(
+ offset: int = 0, limit: int = 0, db: AsyncSession = Depends(get_db), _: AdminDetails = Depends(check_sudo_admin)
+):
+ """
+ Get proxy hosts.
+ """
+ return await host_operator.get_hosts(db=db, offset=offset, limit=limit)
+
+
+@router.post("/", response_model=BaseHost, status_code=status.HTTP_201_CREATED)
+async def create_host(
+ new_host: CreateHost, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin)
+):
+ """
+ create a new host
+
+ **inbound_tag** must be available in one of xray config
+ """
+ return await host_operator.create_host(db, new_host=new_host, admin=admin)
+
+
+@router.put("/{host_id}", response_model=BaseHost, responses={404: responses._404})
+async def modify_host(
+ host_id: int,
+ modified_host: CreateHost,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(check_sudo_admin),
+):
+ """
+ modify host by **id**
+
+ **inbound_tag** must be available in one of xray configs
+ """
+ return await host_operator.modify_host(db, host_id=host_id, modified_host=modified_host, admin=admin)
+
+
+@router.delete(
+ "/{host_id}",
+ responses={404: responses._404},
+ status_code=status.HTTP_204_NO_CONTENT,
+)
+async def remove_host(
+ host_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin)
+):
+ """
+ remove host by **id**
+ """
+ await host_operator.remove_host(db, host_id=host_id, admin=admin)
+ return {}
+
+
+@router.put("s", response_model=list[BaseHost])
+async def modify_hosts(
+ modified_hosts: list[CreateHost],
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(check_sudo_admin),
+):
+ """
+ Modify proxy hosts and update the configuration.
+ """
+ return await host_operator.modify_hosts(db=db, modified_hosts=modified_hosts, admin=admin)
diff --git a/app/routers/node.py b/app/routers/node.py
new file mode 100644
index 000000000..e89d61f73
--- /dev/null
+++ b/app/routers/node.py
@@ -0,0 +1,205 @@
+import asyncio
+from datetime import datetime as dt
+from typing import AsyncGenerator
+
+from fastapi import APIRouter, Depends, Query, Request, status
+from sse_starlette.sse import EventSourceResponse
+
+from app.db import AsyncSession, get_db
+from app.models.admin import AdminDetails
+from app.models.node import NodeCreate, NodeModify, NodeResponse, NodeSettings, UsageTable
+from app.models.stats import NodeRealtimeStats, NodeStatsList, NodeUsageStatsList, Period
+from app.operation import OperatorType
+from app.operation.node import NodeOperation
+from app.utils import responses
+
+from .authentication import check_sudo_admin
+
+node_operator = NodeOperation(operator_type=OperatorType.API)
+router = APIRouter(tags=["Node"], prefix="/api/node", responses={401: responses._401, 403: responses._403})
+
+
+@router.get("/settings", response_model=NodeSettings)
+async def get_node_settings(_: AdminDetails = Depends(check_sudo_admin)):
+ """Retrieve the current node settings."""
+ return NodeSettings()
+
+
+@router.get("/usage", response_model=NodeUsageStatsList)
+async def get_usage(
+ db: AsyncSession = Depends(get_db),
+ start: dt | None = Query(None, example="2024-01-01T00:00:00+03:30"),
+ end: dt | None = Query(None, example="2024-01-31T23:59:59+03:30"),
+ period: Period = Period.hour,
+ node_id: int | None = None,
+ group_by_node: bool = False,
+ _: AdminDetails = Depends(check_sudo_admin),
+):
+ """Retrieve usage statistics for nodes within a specified date range."""
+ return await node_operator.get_usage(
+ db=db, start=start, end=end, period=period, node_id=node_id, group_by_node=group_by_node
+ )
+
+
+@router.get("s", response_model=list[NodeResponse])
+async def get_nodes(
+ backend_id: int | None = None,
+ offset: int = None,
+ limit: int = None,
+ db: AsyncSession = Depends(get_db),
+ _: AdminDetails = Depends(check_sudo_admin),
+):
+ """Retrieve a list of all nodes. Accessible only to sudo admins."""
+ return await node_operator.get_db_nodes(db=db, core_id=backend_id, offset=offset, limit=limit)
+
+
+@router.post(
+ "",
+ response_model=NodeResponse,
+ responses={409: responses._409},
+ status_code=status.HTTP_201_CREATED,
+)
+async def create_node(
+ new_node: NodeCreate, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin)
+):
+ """Create a new node to the database."""
+ return await node_operator.create_node(db, new_node, admin)
+
+
+@router.get("/{node_id}", response_model=NodeResponse)
+async def get_node(node_id: int, db: AsyncSession = Depends(get_db), _: AdminDetails = Depends(check_sudo_admin)):
+ """Retrieve details of a specific node by its ID."""
+ return await node_operator.get_validated_node(db=db, node_id=node_id)
+
+
+@router.put("/{node_id}", response_model=NodeResponse)
+async def modify_node(
+ modified_node: NodeModify,
+ node_id: int,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(check_sudo_admin),
+):
+ """Modify a node's details. Only accessible to sudo admins."""
+ return await node_operator.modify_node(db, node_id=node_id, modified_node=modified_node, admin=admin)
+
+
+@router.post("/{node_id}/reconnect")
+async def reconnect_node(node_id: int, admin: AdminDetails = Depends(check_sudo_admin)):
+ """Trigger a reconnection for the specified node. Only accessible to sudo admins."""
+ await node_operator.restart_node(node_id=node_id, admin=admin)
+ return {}
+
+
+@router.put("/{node_id}/sync")
+async def sync_node(
+ node_id: int,
+ flush_users: bool = False,
+ db: AsyncSession = Depends(get_db),
+ _: AdminDetails = Depends(check_sudo_admin),
+):
+ return await node_operator.sync_node_users(db, node_id=node_id, flush_users=flush_users)
+
+
+@router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT)
+async def remove_node(
+ node_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin)
+):
+ """Remove a node and remove it from xray in the background."""
+ await node_operator.remove_node(db=db, node_id=node_id, admin=admin)
+ return {}
+
+
+@router.get("/{node_id}/logs")
+async def node_logs(node_id: int, request: Request, _: AdminDetails = Depends(check_sudo_admin)):
+ """
+ Stream logs for a specific node as Server-Sent Events.
+ """
+ log_queue = await node_operator.get_logs(node_id=node_id)
+
+ async def event_generator() -> AsyncGenerator[str, None]:
+ try:
+ while True:
+ # Check if client disconnected
+ if await request.is_disconnected():
+ break
+
+ try:
+ log = await log_queue.get()
+ if log is None:
+ break
+ yield f"{log}"
+
+ except Exception as e:
+ yield f"Error retrieving logs: {str(e)}\n"
+ break
+ except asyncio.CancelledError:
+ pass
+
+ return EventSourceResponse(event_generator())
+
+
+@router.get("/{node_id}/stats", response_model=NodeStatsList)
+async def get_node_stats_periodic(
+ node_id: int,
+ start: dt | None = Query(None, example="2024-01-01T00:00:00+03:30"),
+ end: dt | None = Query(None, example="2024-01-31T23:59:59+03:30"),
+ period: Period = Period.hour,
+ db: AsyncSession = Depends(get_db),
+ _: AdminDetails = Depends(check_sudo_admin),
+):
+ return await node_operator.get_node_stats_periodic(db, node_id=node_id, start=start, end=end, period=period)
+
+
+@router.get("/{node_id}/realtime_stats", response_model=NodeRealtimeStats)
+async def realtime_node_stats(node_id: int, _: AdminDetails = Depends(check_sudo_admin)):
+ """Retrieve node real-time statistics."""
+ return await node_operator.get_node_system_stats(node_id=node_id)
+
+
+@router.get("s/realtime_stats", response_model=dict[int, NodeRealtimeStats | None])
+async def realtime_nodes_stats(_: AdminDetails = Depends(check_sudo_admin)):
+ """Retrieve nodes real-time statistics."""
+ return await node_operator.get_nodes_system_stats()
+
+
+@router.get("/{node_id}/online_stats/{username}", response_model=dict[int, int])
+async def user_online_stats(
+ node_id: int, username: str, db: AsyncSession = Depends(get_db), _: AdminDetails = Depends(check_sudo_admin)
+):
+ """Retrieve user online stats by node."""
+ return await node_operator.get_user_online_stats_by_node(db=db, node_id=node_id, username=username)
+
+
+@router.get("/{node_id}/online_stats/{username}/ip", response_model=dict[int, dict[str, int]])
+async def user_online_ip_list(
+ node_id: int, username: str, db: AsyncSession = Depends(get_db), _: AdminDetails = Depends(check_sudo_admin)
+):
+ """Retrieve user ips by node."""
+ return await node_operator.get_user_ip_list_by_node(db=db, node_id=node_id, username=username)
+
+
+@router.delete(
+ "s/clear_usage_data/{table}",
+ summary="Clear usage data from a specified table",
+)
+async def clear_usage_data(
+ table: UsageTable,
+ start: dt | None = Query(None),
+ end: dt | None = Query(None),
+ db: AsyncSession = Depends(get_db),
+ _: AdminDetails = Depends(check_sudo_admin),
+):
+ """
+ Deletes **all rows** from the selected usage data table. Use with caution.
+
+ Allowed tables:
+ - `node_user_usages`: Deletes user-specific node usage traffic records.
+ - `node_usages`: Deletes node-level aggregated traffic (uplink/downlink) records.
+
+ **Optional filters:**
+ - `start`: ISO 8601 timestamp to filter from (inclusive)
+ - `end`: ISO 8601 timestamp to filter to (exclusive)
+
+ ⚠️ This operation is irreversible. Ensure correct usage in production environments.
+ """
+ return await node_operator.clear_usage_data(db, table, start, end)
diff --git a/app/routers/settings.py b/app/routers/settings.py
new file mode 100644
index 000000000..57991bcf3
--- /dev/null
+++ b/app/routers/settings.py
@@ -0,0 +1,27 @@
+from fastapi import APIRouter, Depends
+
+from app.db import AsyncSession, get_db
+from app.models.settings import General, SettingsSchema
+from app.operation import OperatorType
+from app.operation.settings import SettingsOperation
+from app.utils import responses
+
+from .authentication import check_sudo_admin, get_current
+
+settings_operator = SettingsOperation(operator_type=OperatorType.API)
+router = APIRouter(tags=["Settings"], prefix="/api/settings", responses={401: responses._401, 403: responses._403})
+
+
+@router.get("", response_model=SettingsSchema)
+async def get_settings(db: AsyncSession = Depends(get_db), _=Depends(check_sudo_admin)):
+ return await settings_operator.get_settings(db)
+
+
+@router.get("/general", response_model=General)
+async def get_general_settings(db: AsyncSession = Depends(get_db), _=Depends(get_current)):
+ return await settings_operator.get_general_settings(db)
+
+
+@router.put("", response_model=SettingsSchema)
+async def modify_settings(modify: SettingsSchema, db: AsyncSession = Depends(get_db), _=Depends(check_sudo_admin)):
+ return await settings_operator.modify_settings(db, modify)
diff --git a/app/routers/subscription.py b/app/routers/subscription.py
new file mode 100644
index 000000000..3afc03b85
--- /dev/null
+++ b/app/routers/subscription.py
@@ -0,0 +1,63 @@
+from datetime import datetime as dt
+
+from fastapi import APIRouter, Depends, Header, Query, Request
+
+from app.db import AsyncSession, get_db
+from app.models.settings import ConfigFormat
+from app.models.stats import Period, UserUsageStatsList
+from app.models.user import SubscriptionUserResponse
+from app.operation import OperatorType
+from app.operation.subscription import SubscriptionOperation
+from config import SUBSCRIPTION_PATH
+
+router = APIRouter(tags=["Subscription"], prefix=f"/{SUBSCRIPTION_PATH}")
+subscription_operator = SubscriptionOperation(operator_type=OperatorType.API)
+
+
+@router.get("/{token}/")
+@router.get("/{token}", include_in_schema=False)
+async def user_subscription(
+ request: Request,
+ token: str,
+ db: AsyncSession = Depends(get_db),
+ user_agent: str = Header(default=""),
+):
+ """Provides a subscription link based on the user agent (Clash, V2Ray, etc.)."""
+ return await subscription_operator.user_subscription(
+ db,
+ token=token,
+ accept_header=request.headers.get("Accept", ""),
+ user_agent=user_agent,
+ request_url=str(request.url),
+ )
+
+
+@router.get("/{token}/info", response_model=SubscriptionUserResponse)
+async def user_subscription_info(token: str, db: AsyncSession = Depends(get_db)):
+ """Retrieves detailed information about the user's subscription."""
+ return await subscription_operator.user_subscription_info(db, token=token)
+
+
+@router.get("/{token}/usage", response_model=UserUsageStatsList)
+async def get_sub_user_usage(
+ token: str,
+ start: dt | None = Query(None, example="2024-01-01T00:00:00+03:30"),
+ end: dt | None = Query(None, example="2024-01-31T23:59:59+03:30"),
+ period: Period = Period.hour,
+ db: AsyncSession = Depends(get_db),
+):
+ """Fetches the usage statistics for the user within a specified date range."""
+ return await subscription_operator.get_user_usage(db, token=token, start=start, end=end, period=period)
+
+
+@router.get("/{token}/{client_type}")
+async def user_subscription_with_client_type(
+ request: Request,
+ token: str,
+ client_type: ConfigFormat,
+ db: AsyncSession = Depends(get_db),
+):
+ """Provides a subscription link based on the specified client type (e.g., Clash, V2Ray)."""
+ return await subscription_operator.user_subscription_with_client_type(
+ db, token=token, client_type=client_type, request_url=str(request.url)
+ )
diff --git a/app/routers/system.py b/app/routers/system.py
new file mode 100644
index 000000000..edf6b3853
--- /dev/null
+++ b/app/routers/system.py
@@ -0,0 +1,61 @@
+import asyncio
+
+from aiogram.types import Update
+from fastapi import APIRouter, Depends, Header, HTTPException, Request
+from fastapi.responses import JSONResponse
+
+from app.db import AsyncSession, get_db
+from app.models.admin import AdminDetails
+from app.models.settings import Telegram
+from app.models.system import SystemStats
+from app.operation import OperatorType
+from app.operation.system import SystemOperation
+from app.settings import telegram_settings
+from app.telegram import get_bot, get_dispatcher
+from app.utils import responses
+from app.utils.logger import EndpointFilter, get_logger
+from config import DO_NOT_LOG_TELEGRAM_BOT
+
+from .authentication import get_current
+
+system_operator = SystemOperation(operator_type=OperatorType.API)
+router = APIRouter(tags=["System"], prefix="/api", responses={401: responses._401})
+
+TELEGRAM_WEBHOOK_PATH = "/tghook"
+if DO_NOT_LOG_TELEGRAM_BOT:
+ uvicorn_access_logger = get_logger("uvicorn.access")
+ uvicorn_access_logger.addFilter(EndpointFilter([f"{router.prefix}{TELEGRAM_WEBHOOK_PATH}"]))
+
+
+@router.get("/system", response_model=SystemStats)
+async def get_system_stats(
+ admin_username: str | None = None, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current)
+):
+ """Fetch system stats including memory, CPU, and user metrics."""
+ return await system_operator.get_system_stats(db, admin=admin, admin_username=admin_username)
+
+
+@router.get("/inbounds", response_model=list[str])
+async def get_inbounds(_: AdminDetails = Depends(get_current)):
+ """Retrieve inbound configurations grouped by protocol."""
+ return await system_operator.get_inbounds()
+
+
+@router.post(TELEGRAM_WEBHOOK_PATH, include_in_schema=False)
+async def webhook_handler(request: Request, X_Telegram_Bot_Api_Secret_Token: str = Header()):
+ """Telegram webhook handler"""
+ settings: Telegram = await telegram_settings()
+
+ if not settings.enable:
+ raise HTTPException(status_code=404, detail="not found")
+
+ if X_Telegram_Bot_Api_Secret_Token != settings.webhook_secret:
+ raise HTTPException(status_code=403, detail="Forbidden: Invalid secret key")
+
+ bot = get_bot()
+ dp = get_dispatcher()
+
+ update_data = await request.json()
+ update = Update.model_validate(update_data, context={"bot": bot})
+ asyncio.create_task(dp.feed_update(bot, update))
+ return JSONResponse(status_code=200, content={"status": "ok"})
diff --git a/app/routers/user.py b/app/routers/user.py
new file mode 100644
index 000000000..3d0f0ba8d
--- /dev/null
+++ b/app/routers/user.py
@@ -0,0 +1,358 @@
+from datetime import datetime as dt
+
+from fastapi import APIRouter, Depends, Query, status
+
+from app.db import AsyncSession, get_db
+from app.db.models import UserStatus
+from app.models.admin import AdminDetails
+from app.models.stats import Period, UserUsageStatsList
+from app.models.user import (
+ BulkUser,
+ BulkUsersProxy,
+ CreateUserFromTemplate,
+ ModifyUserByTemplate,
+ RemoveUsersResponse,
+ UserCreate,
+ UserModify,
+ UserResponse,
+ UsersResponse,
+ UserSubscriptionUpdateList,
+)
+from app.operation import OperatorType
+from app.operation.node import NodeOperation
+from app.operation.user import UserOperation
+from app.utils import responses
+
+from .authentication import check_sudo_admin, get_current
+
+user_operator = UserOperation(operator_type=OperatorType.API)
+node_operator = NodeOperation(operator_type=OperatorType.API)
+router = APIRouter(tags=["User"], prefix="/api/user", responses={401: responses._401})
+
+
+@router.post(
+ "",
+ response_model=UserResponse,
+ responses={400: responses._400, 409: responses._409},
+ status_code=status.HTTP_201_CREATED,
+)
+async def create_user(
+ new_user: UserCreate, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current)
+):
+ """
+ Create a new user
+
+ - **username**: 3 to 32 characters, can include a-z, 0-9, and underscores.
+ - **status**: User's status, defaults to `active`. Special rules if `on_hold`.
+ - **expire**: UTC datetime for account expiration. Use `0` for unlimited.
+ - **data_limit**: Max data usage in bytes (e.g., `1073741824` for 1GB). `0` means unlimited.
+ - **data_limit_reset_strategy**: Defines how/if data limit resets. `no_reset` means it never resets.
+ - **proxy_settings**: Dictionary of protocol settings (e.g., `vmess`, `vless`) will generate data for all protocol by default.
+ - **group_ids**: List of group IDs to assign to the user.
+ - **note**: Optional text field for additional user information or notes.
+ - **on_hold_timeout**: UTC timestamp when `on_hold` status should start or end.
+ - **on_hold_expire_duration**: Duration (in seconds) for how long the user should stay in `on_hold` status.
+ - **next_plan**: Next user plan (resets after use).
+ """
+
+ return await user_operator.create_user(db, new_user=new_user, admin=admin)
+
+
+@router.put(
+ "/{username}",
+ response_model=UserResponse,
+ responses={400: responses._400, 403: responses._403, 404: responses._404},
+)
+async def modify_user(
+ username: str,
+ modified_user: UserModify,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(get_current),
+):
+ """
+ Modify an existing user
+
+ - **username**: Cannot be changed. Used to identify the user.
+ - **status**: User's new status. Can be 'active', 'disabled', 'on_hold', 'limited', or 'expired'.
+ - **expire**: UTC datetime for new account expiration. Set to `0` for unlimited, `null` for no change.
+ - **data_limit**: New max data usage in bytes (e.g., `1073741824` for 1GB). Set to `0` for unlimited, `null` for no change.
+ - **data_limit_reset_strategy**: New strategy for data limit reset. Options include 'daily', 'weekly', 'monthly', or 'no_reset'.
+ - **proxies**: Dictionary of new protocol settings (e.g., `vmess`, `vless`). Empty dictionary means no change.
+ - **group_ids**: List of new group IDs to assign to the user. Empty list means no change.
+ - **note**: New optional text for additional user information or notes. `null` means no change.
+ - **on_hold_timeout**: New UTC timestamp for when `on_hold` status should start or end. Only applicable if status is changed to 'on_hold'.
+ - **on_hold_expire_duration**: New duration (in seconds) for how long the user should stay in `on_hold` status. Only applicable if status is changed to 'on_hold'.
+ - **next_plan**: Next user plan (resets after use).
+
+ Note: Fields set to `null` or omitted will not be modified.
+ """
+ return await user_operator.modify_user(db, username=username, modified_user=modified_user, admin=admin)
+
+
+@router.delete(
+ "/{username}", responses={403: responses._403, 404: responses._404}, status_code=status.HTTP_204_NO_CONTENT
+)
+async def remove_user(username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current)):
+ """Remove a user"""
+ return await user_operator.remove_user(db, username=username, admin=admin)
+
+
+@router.post("/{username}/reset", response_model=UserResponse, responses={403: responses._403, 404: responses._404})
+async def reset_user_data_usage(
+ username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current)
+):
+ """Reset user data usage"""
+ return await user_operator.reset_user_data_usage(db, username=username, admin=admin)
+
+
+@router.post(
+ "/{username}/revoke_sub", response_model=UserResponse, responses={403: responses._403, 404: responses._404}
+)
+async def revoke_user_subscription(
+ username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current)
+):
+ """Revoke users subscription (Subscription link and proxies)"""
+ return await user_operator.revoke_user_sub(db, username=username, admin=admin)
+
+
+@router.post("s/reset", responses={403: responses._403, 404: responses._404})
+async def reset_users_data_usage(db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin)):
+ """Reset all users data usage"""
+ await user_operator.reset_users_data_usage(db, admin)
+ await node_operator.restart_all_node(db, admin)
+ return {}
+
+
+@router.put("/{username}/set_owner", response_model=UserResponse, responses={403: responses._403})
+async def set_owner(
+ username: str,
+ admin_username: str,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(check_sudo_admin),
+):
+ """Set a new owner (admin) for a user."""
+ return await user_operator.set_owner(db, username=username, admin_username=admin_username, admin=admin)
+
+
+@router.post(
+ "/{username}/active_next", response_model=UserResponse, responses={403: responses._403, 404: responses._404}
+)
+async def active_next_plan(
+ username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current)
+):
+ """Reset user by next plan"""
+ return await user_operator.active_next_plan(db, username=username, admin=admin)
+
+
+@router.get("/{username}", response_model=UserResponse, responses={403: responses._403, 404: responses._404})
+async def get_user(username: str, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(get_current)):
+ """Get user information"""
+ return await user_operator.get_user(db=db, username=username, admin=admin)
+
+
+@router.get(
+ "/{username}/sub_update",
+ response_model=UserSubscriptionUpdateList,
+ responses={403: responses._403, 404: responses._404},
+)
+async def get_user_sub_update_list(
+ username: str,
+ offset: int = 0,
+ limit: int = 10,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(get_current),
+):
+ """Get user subscription agent list"""
+ return await user_operator.get_user_sub_update_list(db, username=username, admin=admin, offset=offset, limit=limit)
+
+
+@router.get(
+ "s", response_model=UsersResponse, responses={400: responses._400, 403: responses._403, 404: responses._404}
+)
+async def get_users(
+ offset: int = None,
+ limit: int = None,
+ username: list[str] = Query(None),
+ owner: list[str] | None = Query(None, alias="admin"),
+ group_ids: list[int] | None = Query(None, alias="group"),
+ search: str | None = None,
+ status: UserStatus | None = None,
+ sort: str | None = None,
+ proxy_id: str | None = None,
+ load_sub: bool = False,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(get_current),
+):
+ """Get all users"""
+ return await user_operator.get_users(
+ db=db,
+ admin=admin,
+ offset=offset,
+ limit=limit,
+ username=username,
+ search=search,
+ owner=owner,
+ status=status,
+ sort=sort,
+ load_sub=load_sub,
+ proxy_id=proxy_id,
+ group_ids=group_ids,
+ )
+
+
+@router.get(
+ "/{username}/usage", response_model=UserUsageStatsList, responses={403: responses._403, 404: responses._404}
+)
+async def get_user_usage(
+ username: str,
+ period: Period,
+ node_id: int | None = None,
+ group_by_node: bool = False,
+ start: dt | None = Query(None, example="2024-01-01T00:00:00+03:30"),
+ end: dt | None = Query(None, example="2024-01-31T23:59:59+03:30"),
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(get_current),
+):
+ """Get users usage"""
+ return await user_operator.get_user_usage(
+ db,
+ username=username,
+ admin=admin,
+ start=start,
+ end=end,
+ period=period,
+ node_id=node_id,
+ group_by_node=group_by_node,
+ )
+
+
+@router.get("s/usage", response_model=UserUsageStatsList)
+async def get_users_usage(
+ period: Period,
+ node_id: int | None = None,
+ group_by_node: bool = False,
+ start: dt | None = Query(None, example="2024-01-01T00:00:00+03:30"),
+ end: dt | None = Query(None, example="2024-01-31T23:59:59+03:30"),
+ db: AsyncSession = Depends(get_db),
+ owner: list[str] | None = Query(None, alias="admin"),
+ admin: AdminDetails = Depends(get_current),
+):
+ """Get all users usage"""
+ return await user_operator.get_users_usage(
+ db,
+ admin=admin,
+ start=start,
+ end=end,
+ owner=owner,
+ period=period,
+ node_id=node_id,
+ group_by_node=group_by_node,
+ )
+
+
+@router.get("s/expired", response_model=list[str])
+async def get_expired_users(
+ db: AsyncSession = Depends(get_db),
+ _: AdminDetails = Depends(check_sudo_admin),
+ admin_username: str | None = None,
+ expired_after: dt | None = Query(None, example="2024-01-01T00:00:00+03:30"),
+ expired_before: dt | None = Query(None, example="2024-01-31T23:59:59+03:30"),
+):
+ """
+ Get users who have expired within the specified date range.
+
+ - **expired_after** UTC datetime (optional)
+ - **expired_before** UTC datetime (optional)
+ - At least one of expired_after or expired_before must be provided for filtering
+ - If both are omitted, returns all expired users
+ """
+
+ return await user_operator.get_expired_users(db, expired_after, expired_before, admin_username)
+
+
+@router.delete("s/expired", response_model=RemoveUsersResponse)
+async def delete_expired_users(
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(check_sudo_admin),
+ admin_username: str | None = None,
+ expired_after: dt | None = Query(None, example="2024-01-01T00:00:00+03:30"),
+ expired_before: dt | None = Query(None, example="2024-01-31T23:59:59+03:30"),
+):
+ """
+ Delete users who have expired within the specified date range.
+
+ - **expired_after** UTC datetime (optional)
+ - **expired_before** UTC datetime (optional)
+ - At least one of expired_after or expired_before must be provided
+ """
+ return await user_operator.delete_expired_users(
+ db, admin, expired_after, expired_before, admin_username=admin_username
+ )
+
+
+@router.post("/from_template", status_code=status.HTTP_201_CREATED, response_model=UserResponse)
+async def create_user_from_template(
+ new_template_user: CreateUserFromTemplate,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(get_current),
+):
+ return await user_operator.create_user_from_template(db, new_template_user, admin)
+
+
+@router.put("/from_template/{username}", response_model=UserResponse)
+async def modify_user_with_template(
+ username: str,
+ modify_template_user: ModifyUserByTemplate,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(get_current),
+):
+ return await user_operator.modify_user_with_template(db, username, modify_template_user, admin)
+
+
+@router.post("s/bulk/expire", summary="Bulk sum/sub to expire of users", response_description="Success confirmation")
+async def bulk_modify_users_expire(
+ bulk_model: BulkUser,
+ db: AsyncSession = Depends(get_db),
+ _: AdminDetails = Depends(check_sudo_admin),
+):
+ """
+ Bulk expire users based on the provided criteria.
+
+ - **amount**: amount to adjust the user's quota (in seconds, positive to increase, negative to decrease) required
+ - **user_ids**: Optional list of user IDs to modify
+ - **admins**: Optional list of admin IDs — their users will be targeted
+ - **status**: Optional status to filter users (e.g., "expired", "active"), Empty means no filtering
+ - **group_ids**: Optional list of group IDs to filter users by their group membership
+ """
+ return await user_operator.bulk_modify_expire(db, bulk_model)
+
+
+@router.post(
+ "s/bulk/data_limit", summary="Bulk sum/sub to data limit of users", response_description="Success confirmation"
+)
+async def bulk_modify_users_datalimit(
+ bulk_model: BulkUser,
+ db: AsyncSession = Depends(get_db),
+ _: AdminDetails = Depends(check_sudo_admin),
+):
+ """
+ Bulk modify users' data limit based on the provided criteria.
+
+ - **amount**: amount to adjust the user's quota (positive to increase, negative to decrease) required
+ - **user_ids**: Optional list of user IDs to modify
+ - **admins**: Optional list of admin IDs — their users will be targeted
+ - **status**: Optional status to filter users (e.g., "expired", "active"), Empty means no filtering
+ - **group_ids**: Optional list of group IDs to filter users by their group membership
+ """
+ return await user_operator.bulk_modify_datalimit(db, bulk_model)
+
+
+@router.post(
+ "s/bulk/proxy_settings", summary="Bulk modify users proxy settings", response_description="Success confirmation"
+)
+async def bulk_modify_users_proxy_settings(
+ bulk_model: BulkUsersProxy,
+ db: AsyncSession = Depends(get_db),
+ _: AdminDetails = Depends(check_sudo_admin),
+):
+ return await user_operator.bulk_modify_proxy_settings(db, bulk_model)
diff --git a/app/routers/user_template.py b/app/routers/user_template.py
new file mode 100644
index 000000000..e7293afc3
--- /dev/null
+++ b/app/routers/user_template.py
@@ -0,0 +1,75 @@
+from fastapi import Depends, APIRouter, status
+
+from app.db import AsyncSession, get_db
+from app.models.admin import AdminDetails
+from .authentication import check_sudo_admin, get_current
+from app.models.user_template import UserTemplateCreate, UserTemplateModify, UserTemplateResponse
+from app.operation import OperatorType
+from app.operation.user_template import UserTemplateOperation
+from app.utils import responses
+
+
+router = APIRouter(tags=["User Template"], prefix="/api/user_template")
+template_operator = UserTemplateOperation(OperatorType.API)
+
+
+@router.post(
+ "", response_model=UserTemplateResponse, status_code=status.HTTP_201_CREATED, responses={403: responses._403}
+)
+async def create_user_template(
+ new_user_template: UserTemplateCreate,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(check_sudo_admin),
+):
+ """
+ Create a new user template
+
+ - **name** can be up to 64 characters
+ - **data_limit** must be in bytes and larger or equal to 0
+ - **expire_duration** must be in seconds and larger or equat to 0
+ - **group_ids** list of group ids
+ """
+ return await template_operator.create_user_template(db, new_user_template, admin)
+
+
+@router.get("/{template_id}", response_model=UserTemplateResponse)
+async def get_user_template(
+ template_id: int, db: AsyncSession = Depends(get_db), _: AdminDetails = Depends(get_current)
+):
+ """Get User Template information with id"""
+ return await template_operator.get_validated_user_template(db, template_id)
+
+
+@router.put("/{template_id}", response_model=UserTemplateResponse, responses={403: responses._403})
+async def modify_user_template(
+ template_id: int,
+ modify_user_template: UserTemplateModify,
+ db: AsyncSession = Depends(get_db),
+ admin: AdminDetails = Depends(check_sudo_admin),
+):
+ """
+ Modify User Template
+
+ - **name** can be up to 64 characters
+ - **data_limit** must be in bytes and larger or equal to 0
+ - **expire_duration** must be in seconds and larger or equat to 0
+ - **group_ids** list of group ids
+ """
+ return await template_operator.modify_user_template(db, template_id, modify_user_template, admin)
+
+
+@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT, responses={403: responses._403})
+async def remove_user_template(
+ template_id: int, db: AsyncSession = Depends(get_db), admin: AdminDetails = Depends(check_sudo_admin)
+):
+ """Remove a User Template by its ID"""
+ await template_operator.remove_user_template(db, template_id, admin)
+ return {}
+
+
+@router.get("s", response_model=list[UserTemplateResponse])
+async def get_user_templates(
+ offset: int = None, limit: int = None, db: AsyncSession = Depends(get_db), _: AdminDetails = Depends(get_current)
+):
+ """Get a list of User Templates with optional pagination"""
+ return await template_operator.get_user_templates(db, offset, limit)
diff --git a/app/settings/__init__.py b/app/settings/__init__.py
new file mode 100644
index 000000000..735bce553
--- /dev/null
+++ b/app/settings/__init__.py
@@ -0,0 +1,68 @@
+from aiocache import cached
+
+from app.db import GetDB
+from app.db.crud.settings import get_settings
+from app.models import settings
+
+
+@cached()
+async def telegram_settings() -> settings.Telegram:
+ async with GetDB() as db:
+ db_settings = await get_settings(db)
+
+ validated_settings = settings.Telegram.model_validate(db_settings.telegram)
+ return validated_settings
+
+
+@cached()
+async def discord_settings() -> settings.Discord:
+ async with GetDB() as db:
+ db_settings = await get_settings(db)
+
+ validated_settings = settings.Discord.model_validate(db_settings.discord)
+ return validated_settings
+
+
+@cached()
+async def webhook_settings() -> settings.Webhook:
+ async with GetDB() as db:
+ db_settings = await get_settings(db)
+
+ validated_settings = settings.Webhook.model_validate(db_settings.webhook)
+ return validated_settings
+
+
+@cached()
+async def notification_settings() -> settings.NotificationSettings:
+ async with GetDB() as db:
+ db_settings = await get_settings(db)
+
+ validated_settings = settings.NotificationSettings.model_validate(db_settings.notification_settings)
+ return validated_settings
+
+
+@cached()
+async def notification_enable() -> settings.NotificationEnable:
+ async with GetDB() as db:
+ db_settings = await get_settings(db)
+
+ validated_settings = settings.NotificationEnable.model_validate(db_settings.notification_enable)
+ return validated_settings
+
+
+@cached()
+async def subscription_settings() -> settings.Subscription:
+ async with GetDB() as db:
+ db_settings = await get_settings(db)
+
+ validated_settings = settings.Subscription.model_validate(db_settings.subscription)
+ return validated_settings
+
+
+async def refresh_caches():
+ await telegram_settings.cache.clear()
+ await discord_settings.cache.clear()
+ await webhook_settings.cache.clear()
+ await notification_settings.cache.clear()
+ await notification_enable.cache.clear()
+ await subscription_settings.cache.clear()
diff --git a/app/subscription/__init__.py b/app/subscription/__init__.py
new file mode 100644
index 000000000..18b5115b0
--- /dev/null
+++ b/app/subscription/__init__.py
@@ -0,0 +1,16 @@
+from .base import BaseSubscription
+from .links import StandardLinks
+from .xray import XrayConfiguration
+from .singbox import SingBoxConfiguration
+from .outline import OutlineConfiguration
+from .clash import ClashConfiguration, ClashMetaConfiguration
+
+__all__ = [
+ "BaseSubscription",
+ "XrayConfiguration",
+ "StandardLinks",
+ "SingBoxConfiguration",
+ "OutlineConfiguration",
+ "ClashConfiguration",
+ "ClashMetaConfiguration",
+]
diff --git a/app/subscription/base.py b/app/subscription/base.py
new file mode 100644
index 000000000..d43ac122c
--- /dev/null
+++ b/app/subscription/base.py
@@ -0,0 +1,47 @@
+import json
+
+from app.templates import render_template
+from config import GRPC_USER_AGENT_TEMPLATE, USER_AGENT_TEMPLATE
+
+
+class BaseSubscription:
+ def __init__(self):
+ self.proxy_remarks = []
+ user_agent_data = json.loads(render_template(USER_AGENT_TEMPLATE))
+ if "list" in user_agent_data and isinstance(user_agent_data["list"], list):
+ self.user_agent_list = user_agent_data["list"]
+ else:
+ self.user_agent_list = []
+
+ grpc_user_agent_data = json.loads(render_template(GRPC_USER_AGENT_TEMPLATE))
+
+ if "list" in grpc_user_agent_data and isinstance(grpc_user_agent_data["list"], list):
+ self.grpc_user_agent_data = grpc_user_agent_data["list"]
+ else:
+ self.grpc_user_agent_data = []
+
+ del user_agent_data, grpc_user_agent_data
+
+ def _remark_validation(self, remark):
+ if remark not in self.proxy_remarks:
+ return remark
+ c = 2
+ while True:
+ new = f"{remark} ({c})"
+ if new not in self.proxy_remarks:
+ return new
+ c += 1
+
+ def _remove_none_values(self, data: dict) -> dict:
+ def clean_dict(d: dict) -> dict:
+ new_dict = {}
+ for k, v in d.items():
+ if v not in (None, "", 0):
+ if isinstance(v, dict):
+ if cleaned_dict := clean_dict(v):
+ new_dict[k] = cleaned_dict
+ else:
+ new_dict[k] = v
+ return new_dict
+
+ return clean_dict(data)
diff --git a/app/subscription/clash.py b/app/subscription/clash.py
new file mode 100644
index 000000000..f28437ded
--- /dev/null
+++ b/app/subscription/clash.py
@@ -0,0 +1,392 @@
+from random import choice
+from uuid import UUID
+
+import yaml
+
+from app.subscription.funcs import detect_shadowsocks_2022, get_grpc_gun
+from app.templates import render_template
+from app.utils.helpers import yml_uuid_representer
+from config import (
+ CLASH_SUBSCRIPTION_TEMPLATE,
+)
+
+from . import BaseSubscription
+
+
+class ClashConfiguration(BaseSubscription):
+ def __init__(self):
+ super().__init__()
+ self.data = {
+ "proxies": [],
+ "proxy-groups": [],
+ # Some clients rely on "rules" option and will fail without it.
+ "rules": [],
+ }
+
+ def render(self, reverse=False):
+ if reverse:
+ self.data["proxies"].reverse()
+
+ yaml.add_representer(UUID, yml_uuid_representer)
+ return yaml.dump(
+ yaml.load(
+ render_template(CLASH_SUBSCRIPTION_TEMPLATE, {"conf": self.data, "proxy_remarks": self.proxy_remarks}),
+ Loader=yaml.SafeLoader,
+ ),
+ sort_keys=False,
+ allow_unicode=True,
+ )
+
+ def __str__(self) -> str:
+ return self.render()
+
+ def __repr__(self) -> str:
+ return self.render()
+
+ def http_config(
+ self,
+ path="",
+ host="",
+ random_user_agent: bool = False,
+ request: dict | None = None,
+ ):
+ config = {
+ "path": [path] if path else None,
+ "Host": host,
+ "headers": {},
+ }
+ if request:
+ config.update(request)
+
+ if random_user_agent:
+ config["headers"]["User-Agent"] = choice(self.user_agent_list)
+
+ return self._remove_none_values(config)
+
+ def ws_config(
+ self,
+ path="",
+ host="",
+ max_early_data=None,
+ early_data_header_name="",
+ is_httpupgrade: bool = False,
+ random_user_agent: bool = False,
+ http_headers: dict | None = None,
+ ):
+ config = {
+ "path": path,
+ "headers": {**http_headers, "Host": host} if http_headers else {"Host": host},
+ "v2ray-http-upgrade": is_httpupgrade,
+ "v2ray-http-upgrade-fast-open": is_httpupgrade,
+ "max-early-data": max_early_data if max_early_data and not is_httpupgrade else None,
+ "early-data-header-name": early_data_header_name if max_early_data and not is_httpupgrade else None,
+ }
+ if random_user_agent:
+ config["headers"]["User-Agent"] = choice(self.user_agent_list)
+
+ return self._remove_none_values(config)
+
+ def grpc_config(self, path=""):
+ config = {"grpc-service-name": path}
+ return self._remove_none_values(config)
+
+ def h2_config(self, path="", host=""):
+ config = {
+ "path": path,
+ "host": [host] if host else None,
+ }
+ return self._remove_none_values(config)
+
+ def tcp_config(
+ self,
+ path="",
+ host="",
+ http_headers: dict | None = None,
+ ):
+ config = {
+ "path": [path] if path else None,
+ "headers": {**http_headers, "Host": host} if http_headers else {"Host": host},
+ }
+ return self._remove_none_values(config)
+
+ def make_node(
+ self,
+ remark: str,
+ type: str,
+ server: str,
+ port: int,
+ network: str,
+ tls: bool,
+ sni: str,
+ host: str,
+ path: str,
+ headers: str = "",
+ udp: bool = True,
+ alpn: list | None = None,
+ ais: bool = "",
+ random_user_agent: bool = False,
+ http_headers: dict | None = None,
+ mux_settings: dict | None = None,
+ request: dict | None = None,
+ ):
+ if network in ["grpc", "gun"]:
+ path = get_grpc_gun(path)
+
+ if type == "shadowsocks":
+ type = "ss"
+ if network in ("http", "h2", "h3"):
+ network = "h2"
+ if network in ("tcp", "raw") and headers == "http":
+ network = "http"
+ if network == "httpupgrade":
+ network = "ws"
+ is_httpupgrade = True
+ else:
+ is_httpupgrade = False
+ node = {"name": remark, "type": type, "server": server, "port": port, "network": network, "udp": udp}
+
+ if "?ed=" in path:
+ path, max_early_data = path.split("?ed=")
+ (max_early_data,) = max_early_data.split("/")
+ max_early_data = int(max_early_data)
+ early_data_header_name = "Sec-WebSocket-Protocol"
+ else:
+ max_early_data = None
+ early_data_header_name = ""
+
+ if type == "ss": # shadowsocks
+ return node
+
+ if tls:
+ node["tls"] = True
+ if type == "trojan":
+ node["sni"] = sni
+ else:
+ node["servername"] = sni
+ if alpn:
+ node["alpn"] = alpn
+ if ais:
+ node["skip-cert-verify"] = ais
+
+ if network == "http":
+ net_opts = self.http_config(
+ path=path,
+ host=host,
+ random_user_agent=random_user_agent,
+ request=request,
+ )
+
+ elif network == "ws":
+ net_opts = self.ws_config(
+ path=path,
+ host=host,
+ max_early_data=max_early_data,
+ early_data_header_name=early_data_header_name,
+ is_httpupgrade=is_httpupgrade,
+ random_user_agent=random_user_agent,
+ http_headers=http_headers,
+ )
+
+ elif network == "grpc" or network == "gun":
+ net_opts = self.grpc_config(path=path)
+
+ elif network == "h2":
+ net_opts = self.h2_config(path=path, host=host)
+
+ elif network in ("tcp", "raw"):
+ net_opts = self.tcp_config(
+ path=path,
+ host=host,
+ )
+
+ else:
+ net_opts = {}
+
+ node[f"{network}-opts"] = net_opts
+
+ if mux_settings and (clash_mux := mux_settings.get("clash")):
+ clash_mux = {
+ "enabled": clash_mux.get("enable"),
+ "protocol": clash_mux.get("protocol"),
+ "max-connections": clash_mux.get("max_connections"),
+ "min-streams": clash_mux.get("min_streams"),
+ "max-streams": clash_mux.get("max_streams"),
+ "statistic": clash_mux.get("statistic"),
+ "only-tcp": clash_mux.get("only_tcp"),
+ "padding": clash_mux.get("padding"),
+ "brutal-opts": {
+ "enabled": clash_mux.get("brutal", {}).get("enable"),
+ "up": clash_mux["brutal"]["up_mbps"],
+ "down": clash_mux["brutal"]["down_mbps"],
+ }
+ if clash_mux.get("brutal")
+ else None,
+ }
+ node["smux"] = self._remove_none_values(clash_mux)
+
+ return node
+
+ def add(self, remark: str, address: str, inbound: dict, settings: dict):
+ # not supported by clash
+ if inbound["network"] in ("kcp", "splithttp", "xhttp"):
+ return
+
+ proxy_remark = self._remark_validation(remark)
+
+ node = self.make_node(
+ remark=proxy_remark,
+ type=inbound["protocol"],
+ server=address,
+ port=inbound["port"],
+ network=inbound["network"],
+ tls=(inbound["tls"] == "tls"),
+ sni=inbound["sni"],
+ host=inbound["host"],
+ path=inbound["path"],
+ headers=inbound["header_type"],
+ udp=True,
+ alpn=inbound.get("alpn", None),
+ ais=inbound.get("ais", False),
+ random_user_agent=inbound.get("random_user_agent"),
+ http_headers=inbound.get("http_headers"),
+ request=inbound.get("request"),
+ mux_settings=inbound.get("mux_settings", {}),
+ )
+
+ if inbound["protocol"] == "vmess":
+ node["uuid"] = settings["id"]
+ node["alterId"] = 0
+ node["cipher"] = "auto"
+
+ elif inbound["protocol"] == "trojan":
+ node["password"] = settings["password"]
+
+ elif inbound["protocol"] == "shadowsocks":
+ node["password"] = settings["password"]
+ node["cipher"] = settings["method"]
+
+ else:
+ return
+
+ self.data["proxies"].append(node)
+ self.proxy_remarks.append(proxy_remark)
+
+
+class ClashMetaConfiguration(ClashConfiguration):
+ def make_node(
+ self,
+ remark: str,
+ type: str,
+ server: str,
+ port: int,
+ network: str,
+ tls: bool,
+ sni: str,
+ host: str,
+ path: str,
+ headers: str = "",
+ udp: bool = True,
+ alpn: list | None = None,
+ fp: str = "",
+ pbk: str = "",
+ sid: str = "",
+ ais: bool = "",
+ random_user_agent: bool = False,
+ http_headers: dict | None = None,
+ mux_settings: dict | None = None,
+ request: dict | None = None,
+ ):
+ node = super().make_node(
+ remark=remark,
+ type=type,
+ server=server,
+ port=port,
+ network=network,
+ tls=tls,
+ sni=sni,
+ host=host,
+ path=path,
+ headers=headers,
+ udp=udp,
+ alpn=alpn,
+ ais=ais,
+ random_user_agent=random_user_agent,
+ http_headers=http_headers,
+ request=request,
+ mux_settings=mux_settings,
+ )
+ if fp:
+ node["client-fingerprint"] = fp
+ if pbk:
+ node["reality-opts"] = {"public-key": pbk, "short-id": sid}
+
+ return node
+
+ def add(self, remark: str, address: str, inbound: dict, settings: dict):
+ # not supported by clash-meta
+ if inbound["network"] in ("kcp", "splithttp", "xhttp") or (
+ inbound["network"] == "quic" and inbound["header_type"] != "none"
+ ):
+ return
+
+ proxy_remark = self._remark_validation(remark)
+
+ node = self.make_node(
+ remark=proxy_remark,
+ type=inbound["protocol"],
+ server=address,
+ port=inbound["port"],
+ network=inbound["network"],
+ tls=(inbound["tls"] in ("tls", "reality")),
+ sni=inbound["sni"],
+ host=inbound["host"],
+ path=inbound["path"],
+ headers=inbound["header_type"],
+ udp=True,
+ alpn=inbound.get("alpn", None),
+ fp=inbound.get("fp", ""),
+ pbk=inbound.get("pbk", ""),
+ sid=inbound.get("sid", ""),
+ ais=inbound.get("ais", False),
+ random_user_agent=inbound.get("random_user_agent"),
+ http_headers=inbound.get("http_headers"),
+ request=inbound.get("request"),
+ mux_settings=inbound.get("mux_settings", {}),
+ )
+
+ if inbound["protocol"] == "vmess":
+ node["uuid"] = settings["id"]
+ node["alterId"] = 0
+ node["cipher"] = "auto"
+
+ elif inbound["protocol"] == "vless":
+ node["uuid"] = settings["id"]
+
+ node["encryption"] = (
+ "" if (vless_encryption := inbound.get("encryption", "") == "none") else vless_encryption
+ )
+
+ if (
+ inbound["network"] in ("tcp", "raw", "kcp")
+ and inbound["header_type"] != "http"
+ and inbound["tls"] != "none"
+ ):
+ node["flow"] = settings.get("flow", "")
+
+ elif inbound["protocol"] == "trojan":
+ node["password"] = settings["password"]
+
+ elif inbound["protocol"] == "shadowsocks":
+ node["method"], node["cipher"] = detect_shadowsocks_2022(
+ inbound.get("is_2022", False),
+ inbound.get("method", ""),
+ settings["method"],
+ inbound.get("password"),
+ settings["password"],
+ )
+
+ else:
+ return
+
+ self.data["proxies"].append(node)
+ self.proxy_remarks.append(proxy_remark)
diff --git a/app/subscription/funcs.py b/app/subscription/funcs.py
new file mode 100644
index 000000000..aca664956
--- /dev/null
+++ b/app/subscription/funcs.py
@@ -0,0 +1,76 @@
+import base64
+import hashlib
+
+
+def get_grpc_gun(path: str) -> str:
+ if not path.startswith("/"):
+ return path
+
+ servicename = path.rsplit("/", 1)[0]
+ streamname = path.rsplit("/", 1)[1].split("|")[0]
+
+ if streamname == "Tun":
+ return servicename[1:]
+
+ return "%s%s%s" % (servicename, "/", streamname)
+
+
+def get_grpc_multi(path: str) -> str:
+ if not path.startswith("/"):
+ return path
+
+ servicename = path.rsplit("/", 1)[0]
+ streamname = path.rsplit("/", 1)[1].split("|")[1]
+
+ return "%s%s%s" % (servicename, "/", streamname)
+
+
+def ensure_base64_password(password: str, method: str) -> str:
+ """
+ Ensure password is base64 encoded with correct length for the method:
+ - aes-128-gcm: 16 bytes key (22 chars in base64)
+ - aes-256-gcm and chacha20-poly1305: 32 bytes key (44 chars in base64)
+ """
+ try:
+ # Check if it's already a valid base64 string
+ decoded_bytes = base64.b64decode(password)
+ # Check if length is appropriate
+ if ("aes-128-gcm" in method and len(decoded_bytes) == 16) or (
+ ("aes-256-gcm" in method or "chacha20-poly1305" in method) and len(decoded_bytes) == 32
+ ):
+ # Already correct length
+ return password
+ except Exception:
+ # Not a valid base64 string
+ pass
+
+ # Hash the password to get a consistent byte array
+ hash_bytes = hashlib.sha256(password.encode("utf-8")).digest()
+
+ if "aes-128-gcm" in method:
+ key_bytes = hash_bytes[:16] # First 16 bytes for AES-128
+ else:
+ key_bytes = hash_bytes[:32] # First 32 bytes for AES-256 or ChaCha20
+
+ return base64.b64encode(key_bytes).decode("ascii")
+
+
+def password_to_2022(inbound_password: str, user_password: str, method: str) -> str:
+ """
+ Convert a password to the format required for 2022-blake3 methods,
+ ensuring correct key length.
+ """
+ base64_string = ensure_base64_password(user_password, method)
+ return f"{inbound_password}:{base64_string}"
+
+
+def detect_shadowsocks_2022(
+ is_2022: bool, inbound_method: str, user_method: str, inbound_password: str, user_password: str
+) -> tuple[str, str]:
+ if is_2022:
+ password = password_to_2022(inbound_password, user_password, inbound_method)
+ method = inbound_method
+ else:
+ password = user_password
+ method = user_method
+ return method, password
diff --git a/app/subscription/links.py b/app/subscription/links.py
new file mode 100644
index 000000000..35e0d7eef
--- /dev/null
+++ b/app/subscription/links.py
@@ -0,0 +1,460 @@
+import base64
+import json
+import urllib.parse as urlparse
+from enum import Enum
+from random import choice
+from typing import Union
+from urllib.parse import quote
+from uuid import UUID
+
+from app.subscription.funcs import detect_shadowsocks_2022, get_grpc_gun, get_grpc_multi
+from config import EXTERNAL_CONFIG
+
+from . import BaseSubscription
+
+
+class StandardLinks(BaseSubscription):
+ def __init__(self):
+ super().__init__()
+ self.links = []
+
+ def add_link(self, link):
+ self.links.append(link)
+
+ def render(self, reverse=False):
+ if EXTERNAL_CONFIG:
+ self.links.append(EXTERNAL_CONFIG)
+ if reverse:
+ self.links.reverse()
+ return "\n".join((self.links))
+
+ def add(self, remark: str, address: str, inbound: dict, settings: dict):
+ net = inbound["network"]
+ multi_mode = inbound.get("multi_mode", False)
+ old_path: str = inbound["path"]
+
+ if net in ("grpc", "gun"):
+ if multi_mode:
+ path = get_grpc_multi(old_path)
+ else:
+ path = get_grpc_gun(old_path)
+ if old_path.startswith("/"):
+ path = quote(path, safe="-_.!~*'()")
+
+ else:
+ path = old_path
+ func_args = dict(
+ remark=remark,
+ address=address,
+ port=inbound["port"],
+ net=net,
+ tls=inbound["tls"],
+ sni=inbound.get("sni", ""),
+ fp=inbound.get("fp", ""),
+ alpn=inbound.get("alpn", None),
+ pbk=inbound.get("pbk", ""),
+ sid=inbound.get("sid", ""),
+ spx=inbound.get("spx", ""),
+ host=inbound["host"],
+ path=path,
+ type=inbound["header_type"],
+ ais=inbound.get("ais", ""),
+ fs=inbound.get("fragment_settings", ""),
+ multiMode=multi_mode,
+ sc_max_each_post_bytes=inbound.get("sc_max_each_post_bytes"),
+ sc_max_concurrent_posts=inbound.get("sc_max_concurrent_posts"),
+ sc_min_posts_interval_ms=inbound.get("sc_min_posts_interval_ms"),
+ x_padding_bytes=inbound.get("x_padding_bytes"),
+ mode=inbound.get("mode", ""),
+ noGRPCHeader=inbound.get("no_grpc_header"),
+ heartbeatPeriod=inbound.get("heartbeat_period", 0),
+ scStreamUpServerSecs=inbound.get("sc_stream_up_server_secs"),
+ xmux=inbound.get("xmux"),
+ downloadSettings=inbound.get("downloadSettings"),
+ http_headers=inbound.get("http_headers"),
+ ech_config_list=inbound.get("ech_config_list"),
+ mldsa65_verify=inbound.get("mldsa65Verify"),
+ )
+ if inbound["protocol"] == "vmess":
+ link = self.vmess(
+ id=settings["id"],
+ **func_args,
+ )
+
+ elif inbound["protocol"] == "vless":
+ link = self.vless(
+ id=settings["id"],
+ flow=settings.get("flow", ""),
+ encryption=inbound.get("encryption", "none"),
+ **func_args,
+ )
+
+ elif inbound["protocol"] == "trojan":
+ link = self.trojan(
+ password=settings["password"],
+ flow=settings.get("flow", ""),
+ **func_args,
+ )
+
+ elif inbound["protocol"] == "shadowsocks":
+ method, password = detect_shadowsocks_2022(
+ inbound.get("is_2022", False),
+ inbound.get("method", ""),
+ settings["method"],
+ inbound.get("password"),
+ settings["password"],
+ )
+
+ link = self.shadowsocks(
+ remark=remark,
+ address=address,
+ port=inbound["port"],
+ password=password,
+ method=method,
+ )
+ else:
+ return
+
+ self.add_link(link=link)
+
+ def _make_net_settings(
+ self,
+ payload: dict,
+ protocol: str,
+ net: str,
+ multiMode: bool,
+ path: str,
+ host: str,
+ sc_max_each_post_bytes: int | None = None,
+ sc_max_concurrent_posts: int | None = None,
+ sc_min_posts_interval_ms: int | None = None,
+ x_padding_bytes: str | None = None,
+ mode: str = "",
+ noGRPCHeader: bool | None = None,
+ heartbeatPeriod: int | None = None,
+ scStreamUpServerSecs: int | None = None,
+ xmux: dict | None = None,
+ downloadSettings: dict | None = None,
+ random_user_agent: bool = False,
+ http_headers: dict | None = None,
+ ):
+ if net == "grpc":
+ if protocol == "vmess":
+ if multiMode:
+ payload["type"] = "multi"
+ else:
+ payload["type"] = "gun"
+ else:
+ payload["serviceName"] = path
+ payload["authority"] = host
+ if multiMode:
+ payload["mode"] = "multi"
+ else:
+ payload["mode"] = "gun"
+ elif net in ("splithttp", "xhttp"):
+ payload["path"] = path
+ payload["host"] = host
+ mode = mode.value if isinstance(mode, Enum) else mode
+ if protocol == "vmess":
+ payload["type"] = mode
+ else:
+ payload["mode"] = mode
+ extra = {
+ "scMaxEachPostBytes": sc_max_each_post_bytes,
+ "scMaxConcurrentPosts": sc_max_concurrent_posts,
+ "scMinPostsIntervalMs": sc_min_posts_interval_ms,
+ "xPaddingBytes": x_padding_bytes,
+ "noGRPCHeader": noGRPCHeader,
+ "scStreamUpServerSecs": scStreamUpServerSecs,
+ "xmux": xmux,
+ "headers": http_headers if http_headers is not None else {},
+ "downloadSettings": downloadSettings,
+ }
+ if random_user_agent:
+ if mode in ("stream-one", "stream-up") and not noGRPCHeader:
+ extra["headers"]["User-Agent"] = choice(self.grpc_user_agent_data)
+ else:
+ extra["headers"]["User-Agent"] = choice(self.user_agent_list)
+
+ extra = self._remove_none_values(extra)
+
+ if extra:
+ payload["extra"] = (json.dumps(extra)).replace(" ", "")
+ elif net == "ws":
+ if heartbeatPeriod:
+ payload["heartbeatPeriod"] = heartbeatPeriod
+ payload["path"] = path
+ payload["host"] = host
+
+ elif net == "quic":
+ if protocol != "vmess":
+ payload["key"] = path
+ payload["quicSecurity"] = host
+ elif net == "kcp":
+ if protocol != "vmess":
+ payload["seed"] = path
+ payload["host"] = host
+ else:
+ payload["path"] = path
+ payload["host"] = host
+
+ def _make_tls_settings(
+ self,
+ payload: dict,
+ tls: str,
+ sni: str,
+ fp: str,
+ alpn: list | None,
+ pbk: str,
+ sid: str,
+ spx: str,
+ fs: str,
+ ais: bool,
+ ech_config_list: str,
+ mldsa65_verify: str,
+ ):
+ payload["sni"] = sni
+ payload["fp"] = fp
+ if alpn:
+ payload["alpn"] = ",".join(alpn)
+ if fs:
+ xray_fragment = fs["xray"]
+ payload["fragment"] = (
+ f"{xray_fragment['length']},{xray_fragment['interval']},{xray_fragment['packets']}"
+ if xray_fragment
+ else ""
+ )
+
+ if ech_config_list:
+ payload["echConfigList"] = ech_config_list
+
+ if tls == "reality":
+ payload["pbk"] = pbk
+ payload["sid"] = sid
+ if spx:
+ payload["spx"] = spx
+ if mldsa65_verify:
+ payload["pqv"] = mldsa65_verify
+
+ if ais:
+ payload["allowInsecure"] = 1
+
+ def vmess(
+ self,
+ remark: str,
+ address: str,
+ port: int,
+ id: Union[str, UUID],
+ host="",
+ net="tcp",
+ path="",
+ type="",
+ tls="none",
+ sni="",
+ fp="",
+ alpn=None,
+ pbk="",
+ sid="",
+ spx="",
+ ais="",
+ fs="",
+ multiMode: bool = False,
+ sc_max_each_post_bytes: int | None = None,
+ sc_max_concurrent_posts: int | None = None,
+ sc_min_posts_interval_ms: int | None = None,
+ x_padding_bytes: str | None = None,
+ mode: str = "",
+ noGRPCHeader: bool | None = None,
+ heartbeatPeriod: int | None = None,
+ scStreamUpServerSecs: int | None = None,
+ xmux: dict | None = None,
+ downloadSettings: dict | None = None,
+ random_user_agent: bool = False,
+ http_headers: dict | None = None,
+ ech_config_list: str | None = None,
+ mldsa65_verify: str | None = None,
+ ):
+ payload = {
+ "add": address,
+ "aid": "0",
+ "host": host,
+ "id": str(id),
+ "net": net,
+ "path": path,
+ "port": port,
+ "ps": remark,
+ "scy": "auto",
+ "tls": tls,
+ "type": type,
+ "v": "2",
+ }
+ self._make_net_settings(
+ payload=payload,
+ protocol="vmess",
+ net=net,
+ multiMode=multiMode,
+ path=path,
+ host=host,
+ sc_max_each_post_bytes=sc_max_each_post_bytes,
+ sc_max_concurrent_posts=sc_max_concurrent_posts,
+ sc_min_posts_interval_ms=sc_min_posts_interval_ms,
+ x_padding_bytes=x_padding_bytes,
+ mode=mode,
+ noGRPCHeader=noGRPCHeader,
+ heartbeatPeriod=heartbeatPeriod,
+ scStreamUpServerSecs=scStreamUpServerSecs,
+ http_headers=http_headers,
+ xmux=xmux,
+ random_user_agent=random_user_agent,
+ downloadSettings=downloadSettings,
+ )
+ if tls in ("tls", "reality"):
+ self._make_tls_settings(
+ payload, tls, sni, fp, alpn, pbk, sid, spx, fs, ais, ech_config_list, mldsa65_verify
+ )
+ return "vmess://" + base64.b64encode(json.dumps(payload, sort_keys=True).encode("utf-8")).decode()
+
+ def vless(
+ self,
+ remark: str,
+ address: str,
+ port: int,
+ id: Union[str, UUID],
+ encryption: str = "none",
+ net="ws",
+ path="",
+ host="",
+ type="",
+ flow="",
+ tls="none",
+ sni="",
+ fp="",
+ alpn=None,
+ pbk="",
+ sid="",
+ spx="",
+ ais="",
+ fs="",
+ multiMode: bool = False,
+ sc_max_each_post_bytes: int | None = None,
+ sc_max_concurrent_posts: int | None = None,
+ sc_min_posts_interval_ms: int | None = None,
+ x_padding_bytes: str | None = None,
+ mode: str = "",
+ noGRPCHeader: bool | None = None,
+ heartbeatPeriod: int | None = None,
+ scStreamUpServerSecs: int | None = None,
+ http_headers: dict | None = None,
+ xmux: dict | None = None,
+ random_user_agent: bool = False,
+ downloadSettings: dict | None = None,
+ ech_config_list: str | None = None,
+ mldsa65_verify: str | None = None,
+ ):
+ payload = {"encryption": encryption, "security": tls, "type": net, "headerType": type}
+ if flow and (tls in ("tls", "reality") and net in ("tcp", "raw", "kcp") and type != "http"):
+ payload["flow"] = flow
+
+ self._make_net_settings(
+ payload=payload,
+ protocol="vless",
+ net=net,
+ multiMode=multiMode,
+ path=path,
+ host=host,
+ sc_max_each_post_bytes=sc_max_each_post_bytes,
+ sc_max_concurrent_posts=sc_max_concurrent_posts,
+ sc_min_posts_interval_ms=sc_min_posts_interval_ms,
+ x_padding_bytes=x_padding_bytes,
+ mode=mode,
+ noGRPCHeader=noGRPCHeader,
+ heartbeatPeriod=heartbeatPeriod,
+ scStreamUpServerSecs=scStreamUpServerSecs,
+ http_headers=http_headers,
+ xmux=xmux,
+ random_user_agent=random_user_agent,
+ downloadSettings=downloadSettings,
+ )
+ if tls in ("tls", "reality"):
+ self._make_tls_settings(
+ payload, tls, sni, fp, alpn, pbk, sid, spx, fs, ais, ech_config_list, mldsa65_verify
+ )
+ return "vless://" + f"{id}@{address}:{port}?" + urlparse.urlencode(payload) + f"#{(urlparse.quote(remark))}"
+
+ def trojan(
+ self,
+ remark: str,
+ address: str,
+ port: int,
+ password: str,
+ net="tcp",
+ path="",
+ host="",
+ type="",
+ flow="",
+ tls="none",
+ sni="",
+ fp="",
+ alpn=None,
+ pbk="",
+ sid="",
+ spx="",
+ ais="",
+ fs="",
+ multiMode: bool = False,
+ sc_max_each_post_bytes: int | None = None,
+ sc_max_concurrent_posts: int | None = None,
+ sc_min_posts_interval_ms: int | None = None,
+ x_padding_bytes: str | None = None,
+ mode: str = "",
+ noGRPCHeader: bool | None = None,
+ heartbeatPeriod: int | None = None,
+ scStreamUpServerSecs: int | None = None,
+ http_headers: dict | None = None,
+ xmux: dict | None = None,
+ random_user_agent: bool = False,
+ downloadSettings: dict | None = None,
+ ech_config_list: str | None = None,
+ mldsa65_verify: str | None = None,
+ ):
+ payload = {"security": tls, "type": net, "headerType": type}
+ if flow and (tls in ("tls", "reality") and net in ("tcp", "raw", "kcp") and type != "http"):
+ payload["flow"] = flow
+
+ self._make_net_settings(
+ payload=payload,
+ protocol="trojan",
+ net=net,
+ multiMode=multiMode,
+ path=path,
+ host=host,
+ sc_max_each_post_bytes=sc_max_each_post_bytes,
+ sc_max_concurrent_posts=sc_max_concurrent_posts,
+ sc_min_posts_interval_ms=sc_min_posts_interval_ms,
+ x_padding_bytes=x_padding_bytes,
+ mode=mode,
+ noGRPCHeader=noGRPCHeader,
+ heartbeatPeriod=heartbeatPeriod,
+ scStreamUpServerSecs=scStreamUpServerSecs,
+ http_headers=http_headers,
+ xmux=xmux,
+ random_user_agent=random_user_agent,
+ downloadSettings=downloadSettings,
+ )
+ if tls in ("tls", "reality"):
+ self._make_tls_settings(
+ payload, tls, sni, fp, alpn, pbk, sid, spx, fs, ais, ech_config_list, mldsa65_verify
+ )
+ return (
+ "trojan://"
+ + f"{urlparse.quote(password, safe=':')}@{address}:{port}?"
+ + urlparse.urlencode(payload)
+ + f"#{urlparse.quote(remark)}"
+ )
+
+ def shadowsocks(self, remark: str, address: str, port: int, password: str, method: str):
+ return (
+ "ss://"
+ + base64.b64encode(f"{method}:{password}".encode()).decode()
+ + f"@{address}:{port}#{urlparse.quote(remark)}"
+ )
diff --git a/app/subscription/outline.py b/app/subscription/outline.py
new file mode 100644
index 000000000..3d3ced184
--- /dev/null
+++ b/app/subscription/outline.py
@@ -0,0 +1,48 @@
+import json
+from .funcs import detect_shadowsocks_2022
+
+
+class OutlineConfiguration:
+ def __init__(self):
+ self.config = {}
+
+ def add_directly(self, data: dict):
+ self.config.update(data)
+
+ def render(self, reverse=False):
+ if reverse:
+ items = list(self.config.items())
+ items.reverse()
+ self.config = dict(items)
+ return json.dumps(self.config, indent=0)
+
+ def make_outbound(self, remark: str, address: str, port: int, password: str, method: str):
+ config = {
+ "method": method,
+ "password": password,
+ "server": address,
+ "server_port": port,
+ "tag": remark,
+ }
+ return config
+
+ def add(self, remark: str, address: str, inbound: dict, settings: dict):
+ if inbound["protocol"] != "shadowsocks":
+ return
+
+ method, password = detect_shadowsocks_2022(
+ inbound.get("is_2022", False),
+ inbound.get("method", ""),
+ settings["method"],
+ inbound.get("password"),
+ settings["password"],
+ )
+
+ outbound = self.make_outbound(
+ remark=remark,
+ address=address,
+ port=inbound["port"],
+ password=password,
+ method=method,
+ )
+ self.add_directly(outbound)
diff --git a/app/subscription/share.py b/app/subscription/share.py
new file mode 100644
index 000000000..2d88cd56c
--- /dev/null
+++ b/app/subscription/share.py
@@ -0,0 +1,292 @@
+import base64
+import random
+import secrets
+from collections import defaultdict
+from datetime import datetime as dt, timedelta, timezone
+
+from jdatetime import date as jd
+
+from app.core.hosts import hosts as hosts_storage
+from app.core.manager import core_manager
+from app.db.models import UserStatus
+from app.models.user import UsersResponseWithInbounds
+from app.settings import subscription_settings
+from app.utils.system import get_public_ip, get_public_ipv6, readable_size
+
+from . import (
+ ClashConfiguration,
+ ClashMetaConfiguration,
+ OutlineConfiguration,
+ SingBoxConfiguration,
+ StandardLinks,
+ XrayConfiguration,
+)
+
+SERVER_IP = get_public_ip()
+SERVER_IPV6 = get_public_ipv6()
+
+STATUS_EMOJIS = {
+ "active": "✅",
+ "expired": "⌛️",
+ "limited": "🪫",
+ "disabled": "❌",
+ "on_hold": "🔌",
+}
+
+
+async def generate_subscription(
+ user: UsersResponseWithInbounds, config_format: str, as_base64: bool, reverse: bool = False
+) -> str:
+ conf = None
+ if config_format == "links":
+ conf = StandardLinks()
+ elif config_format == "clash-meta":
+ conf = ClashMetaConfiguration()
+ elif config_format == "clash":
+ conf = ClashConfiguration()
+ elif config_format == "sing-box":
+ conf = SingBoxConfiguration()
+ elif config_format == "outline":
+ conf = OutlineConfiguration()
+ elif config_format == "xray":
+ conf = XrayConfiguration()
+ else:
+ raise ValueError(f'Unsupported format "{config_format}"')
+
+ format_variables = setup_format_variables(user)
+
+ config = await process_inbounds_and_tags(user, format_variables, conf, reverse)
+
+ if as_base64:
+ config = base64.b64encode(config.encode()).decode()
+
+ return config
+
+
+def format_time_left(seconds_left: int) -> str:
+ if not seconds_left or seconds_left <= 0:
+ return "∞"
+
+ minutes, seconds = divmod(seconds_left, 60)
+ hours, minutes = divmod(minutes, 60)
+ days, hours = divmod(hours, 24)
+ months, days = divmod(days, 30)
+
+ result = []
+ if months:
+ result.append(f"{months}m")
+ if days:
+ result.append(f"{days}d")
+ if hours and (days < 7):
+ result.append(f"{hours}h")
+ if minutes and not (months or days):
+ result.append(f"{minutes}m")
+ if seconds and not (months or days):
+ result.append(f"{seconds}s")
+ return " ".join(result)
+
+
+def setup_format_variables(user: UsersResponseWithInbounds) -> dict:
+ user_status = user.status
+ expire = user.expire
+ on_hold_expire_duration = user.on_hold_expire_duration
+ now = dt.now(timezone.utc)
+
+ admin_username = ""
+ if admin_data := user.admin:
+ admin_username = admin_data.username
+
+ if user_status != UserStatus.on_hold:
+ if expire is not None:
+ seconds_left = (expire - now).total_seconds()
+ expire_date = expire.date()
+ jalali_expire_date = jd.fromgregorian(
+ year=expire_date.year, month=expire_date.month, day=expire_date.day
+ ).strftime("%Y-%m-%d")
+ if now < expire:
+ days_left = (expire - now).days + 1
+ time_left = format_time_left(seconds_left)
+ else:
+ days_left = "0"
+ time_left = "0"
+
+ else:
+ days_left = "∞"
+ time_left = "∞"
+ expire_date = "∞"
+ jalali_expire_date = "∞"
+ else:
+ if on_hold_expire_duration:
+ days_left = timedelta(seconds=on_hold_expire_duration).days
+ time_left = format_time_left(on_hold_expire_duration)
+ expire_date = "-"
+ jalali_expire_date = "-"
+ else:
+ days_left = "∞"
+ time_left = "∞"
+ expire_date = "∞"
+ jalali_expire_date = "∞"
+
+ if user.data_limit:
+ data_limit = readable_size(user.data_limit)
+ data_left = user.data_limit - user.used_traffic
+ usage_Percentage = round((user.used_traffic / user.data_limit) * 100.0, 2)
+
+ if data_left < 0:
+ data_left = 0
+ data_left = readable_size(data_left)
+ else:
+ data_limit = "∞"
+ data_left = "∞"
+ usage_Percentage = "∞"
+
+ status_emoji = STATUS_EMOJIS.get(user.status.value)
+
+ format_variables = defaultdict(
+ lambda: "",
+ {
+ "SERVER_IP": SERVER_IP,
+ "SERVER_IPV6": SERVER_IPV6,
+ "USERNAME": user.username,
+ "DATA_USAGE": readable_size(user.used_traffic),
+ "DATA_LIMIT": data_limit,
+ "DATA_LEFT": data_left,
+ "DAYS_LEFT": days_left,
+ "EXPIRE_DATE": expire_date,
+ "JALALI_EXPIRE_DATE": jalali_expire_date,
+ "TIME_LEFT": time_left,
+ "STATUS_EMOJI": status_emoji,
+ "USAGE_PERCENTAGE": usage_Percentage,
+ "ADMIN_USERNAME": admin_username,
+ },
+ )
+
+ return format_variables
+
+
+async def filter_hosts(hosts: list, user_status: UserStatus) -> list:
+ if not (await subscription_settings()).host_status_filter:
+ return hosts
+
+ return [host for host in hosts if not host["status"] or user_status in host["status"]]
+
+
+async def process_host(
+ host: dict, format_variables: dict, inbounds: list[str], proxies: dict, conf
+) -> tuple[dict, dict, str]:
+ tag = host["inbound_tag"]
+
+ if tag not in inbounds:
+ return
+
+ host_inbound: dict = await core_manager.get_inbound_by_tag(tag)
+ protocol = host_inbound["protocol"]
+
+ settings = proxies.get(protocol)
+ if not settings:
+ return
+
+ if "flow" in host_inbound:
+ if settings["flow"] == "":
+ settings["flow"] = "" if host_inbound["flow"] == "none" else host_inbound["flow"]
+
+ format_variables.update({"PROTOCOL": protocol})
+ format_variables.update({"TRANSPORT": host_inbound["network"]})
+ sni = ""
+ sni_list = host["sni"] or host_inbound["sni"]
+ if sni_list:
+ salt = secrets.token_hex(8)
+ sni = random.choice(sni_list).replace("*", salt)
+
+ req_host = ""
+ req_host_list = host["host"] or host_inbound["host"]
+ if req_host_list:
+ salt = secrets.token_hex(8)
+ req_host = random.choice(req_host_list).replace("*", salt)
+
+ address = ""
+ address_list = host["address"]
+ if host["address"]:
+ salt = secrets.token_hex(8)
+ address = random.choice(address_list).replace("*", salt)
+
+ if sids := host_inbound.get("sids"):
+ host_inbound["sid"] = random.choice(sids)
+
+ if host["path"] is not None:
+ path = host["path"].format_map(format_variables)
+ else:
+ path = host_inbound.get("path", "").format_map(format_variables)
+
+ if host.get("use_sni_as_host", False) and sni:
+ req_host = sni
+
+ host_inbound.update(
+ {
+ "port": host["port"] or host_inbound["port"],
+ "sni": sni,
+ "host": req_host,
+ "tls": host_inbound["tls"] if host["tls"] is None else host["tls"],
+ "alpn": host["alpn"] if host["alpn"] else None,
+ "path": path,
+ "fp": host["fingerprint"] or host_inbound.get("fp", ""),
+ "ais": host["allowinsecure"] or host_inbound.get("allowinsecure", ""),
+ "fragment_settings": host["fragment_settings"],
+ "noise_settings": host["noise_settings"],
+ "random_user_agent": host["random_user_agent"],
+ "http_headers": host["http_headers"],
+ "mux_settings": host["mux_settings"],
+ "ech_config_list": host["ech_config_list"],
+ },
+ )
+ if ts := host["transport_settings"]:
+ for v in ts.values():
+ if v:
+ if "type" in v:
+ v["header_type"] = v["type"]
+ host_inbound.update(v)
+
+ if host.get("downloadSettings"):
+ ds_data = await process_host(host["downloadSettings"], format_variables, inbounds, proxies, conf)
+ if ds_data and ds_data[0]:
+ ds_data[0]["address"] = ds_data[2].format_map(format_variables)
+ if isinstance(conf, StandardLinks):
+ xc = XrayConfiguration()
+ host_inbound["downloadSettings"] = xc.download_config(ds_data[0], True)
+ else:
+ host_inbound["downloadSettings"] = ds_data[0]
+
+ return host_inbound, settings, address
+
+
+async def process_inbounds_and_tags(
+ user: UsersResponseWithInbounds,
+ format_variables: dict,
+ conf: StandardLinks
+ | XrayConfiguration
+ | SingBoxConfiguration
+ | ClashConfiguration
+ | ClashMetaConfiguration
+ | OutlineConfiguration,
+ reverse=False,
+) -> list | str:
+ proxy_settings = user.proxy_settings.dict()
+ for host in await filter_hosts(hosts_storage.values(), user.status):
+ host_data = await process_host(host, format_variables, user.inbounds, proxy_settings, conf)
+ if not host_data:
+ continue
+ host_inbound, settings, address = host_data
+
+ if host_inbound:
+ conf.add(
+ remark=host["remark"].format_map(format_variables),
+ address=address.format_map(format_variables),
+ inbound=host_inbound,
+ settings=settings,
+ )
+
+ return conf.render(reverse=reverse)
+
+
+def encode_title(text: str) -> str:
+ return f"base64:{base64.b64encode(text.encode()).decode()}"
diff --git a/app/subscription/singbox.py b/app/subscription/singbox.py
new file mode 100644
index 000000000..8a02de15a
--- /dev/null
+++ b/app/subscription/singbox.py
@@ -0,0 +1,345 @@
+import json
+from random import choice
+
+from app.subscription.funcs import detect_shadowsocks_2022, get_grpc_gun
+from app.templates import render_template
+from app.utils.helpers import UUIDEncoder
+from config import SINGBOX_SUBSCRIPTION_TEMPLATE
+
+from . import BaseSubscription
+
+
+class SingBoxConfiguration(BaseSubscription):
+ def __init__(self):
+ super().__init__()
+ self.config = json.loads(render_template(SINGBOX_SUBSCRIPTION_TEMPLATE))
+
+ def add_outbound(self, outbound_data):
+ self.config["outbounds"].append(outbound_data)
+
+ def render(self, reverse=False):
+ urltest_types = ["vmess", "vless", "trojan", "shadowsocks", "hysteria2", "tuic", "http", "ssh"]
+ urltest_tags = [outbound["tag"] for outbound in self.config["outbounds"] if outbound["type"] in urltest_types]
+ selector_types = ["vmess", "vless", "trojan", "shadowsocks", "hysteria2", "tuic", "http", "ssh", "urltest"]
+ selector_tags = [outbound["tag"] for outbound in self.config["outbounds"] if outbound["type"] in selector_types]
+
+ for outbound in self.config["outbounds"]:
+ if outbound.get("type") == "urltest":
+ outbound["outbounds"] = urltest_tags
+
+ for outbound in self.config["outbounds"]:
+ if outbound.get("type") == "selector":
+ outbound["outbounds"] = selector_tags
+
+ if reverse:
+ self.config["outbounds"].reverse()
+ return json.dumps(self.config, indent=4, cls=UUIDEncoder)
+
+ def tls_config(
+ self, sni=None, fp=None, tls=None, pbk=None, sid=None, alpn=None, ais=None, fragment=None, ech_config_list=None
+ ):
+ config = {
+ "enabled": tls in ("tls", "reality"),
+ "server_name": sni,
+ "insecure": ais,
+ "utls": {"enabled": bool(fp), "fingerprint": fp} if fp else None,
+ "alpn": alpn if alpn else None,
+ "ech": {
+ "enabled": True,
+ "config": [],
+ "config_path": "",
+ }
+ if ech_config_list
+ else None,
+ "reality": {
+ "enabled": tls == "reality",
+ "public_key": pbk,
+ "short_id": sid,
+ }
+ if tls == "reality"
+ else None,
+ }
+ if fragment and (singbox_fragment := fragment.get("sing_box")):
+ config.update(singbox_fragment)
+
+ return self._remove_none_values(config)
+
+ def http_config(
+ self,
+ host="",
+ path="",
+ random_user_agent: bool = False,
+ request: dict | None = None,
+ http_headers: dict | None = None,
+ headers="none",
+ ):
+ config = {
+ "idle_timeout": "15s",
+ "ping_timeout": "15s",
+ "path": path,
+ }
+
+ if headers == "http" and request:
+ config.update(request)
+ else:
+ config["headers"] = {k: [v] for k, v in http_headers.items()} if http_headers else {}
+ config["host"] = [host] if host else None
+ if random_user_agent:
+ config["headers"]["User-Agent"] = choice(self.user_agent_list)
+
+ return self._remove_none_values(config)
+
+ def ws_config(
+ self,
+ host="",
+ path="",
+ random_user_agent: bool = False,
+ max_early_data=None,
+ early_data_header_name=None,
+ http_headers: dict | None = None,
+ ):
+ config = {
+ "headers": {k: [v] for k, v in http_headers.items()} if http_headers else {},
+ "path": path,
+ "max_early_data": max_early_data,
+ "early_data_header_name": early_data_header_name,
+ }
+ config["headers"]["host"] = [host] if host else None
+ if random_user_agent:
+ config["headers"]["User-Agent"] = [choice(self.user_agent_list)]
+
+ return self._remove_none_values(config)
+
+ def grpc_config(self, path="", idle_timeout: str = "", ping_timeout: str = "", permit_without_stream: bool = False):
+ config = {
+ "service_name": path,
+ "idle_timeout": f"{idle_timeout}s" if idle_timeout else "15s",
+ "ping_timeout": f"{ping_timeout}s" if ping_timeout else "15s",
+ "permit_without_stream": permit_without_stream,
+ }
+ return self._remove_none_values(config)
+
+ def httpupgrade_config(self, host="", path="", random_user_agent: bool = False, http_headers: dict | None = None):
+ config = {
+ "headers": {k: [v] for k, v in http_headers.items()} if http_headers else {},
+ "host": host,
+ "path": path,
+ }
+ if random_user_agent:
+ config["headers"]["User-Agent"] = choice(self.user_agent_list)
+ return self._remove_none_values(config)
+
+ def transport_config(
+ self,
+ transport_type="",
+ host="",
+ path="",
+ ping_timeout="",
+ idle_timeout="",
+ max_early_data=None,
+ early_data_header_name=None,
+ random_user_agent: bool = False,
+ http_headers: dict | None = None,
+ permit_without_stream: bool = False,
+ request: dict | None = None,
+ headers="none",
+ ):
+ transport_config = {}
+
+ if transport_type:
+ if transport_type == "http":
+ transport_config = self.http_config(
+ host=host,
+ path=path,
+ random_user_agent=random_user_agent,
+ request=request,
+ http_headers=http_headers,
+ headers=headers,
+ )
+
+ elif transport_type == "ws":
+ transport_config = self.ws_config(
+ host=host,
+ path=path,
+ random_user_agent=random_user_agent,
+ max_early_data=max_early_data,
+ early_data_header_name=early_data_header_name,
+ http_headers=http_headers,
+ )
+
+ elif transport_type == "grpc":
+ transport_config = self.grpc_config(
+ path=path,
+ idle_timeout=idle_timeout,
+ ping_timeout=ping_timeout,
+ permit_without_stream=permit_without_stream,
+ )
+
+ elif transport_type == "httpupgrade":
+ transport_config = self.httpupgrade_config(
+ host=host,
+ path=path,
+ random_user_agent=random_user_agent,
+ http_headers=http_headers,
+ )
+
+ transport_config["type"] = transport_type
+ return transport_config
+
+ def make_outbound(
+ self,
+ type: str,
+ remark: str,
+ address: str,
+ port: int,
+ net="",
+ path="",
+ host="",
+ flow="",
+ tls="",
+ sni="",
+ fp="",
+ alpn=None,
+ pbk="",
+ sid="",
+ headers="",
+ ais="",
+ ping_timeout="",
+ idle_timeout="",
+ http_headers: dict | None = None,
+ mux_settings: dict | None = None,
+ request: dict | None = None,
+ random_user_agent: bool = False,
+ permit_without_stream: bool = False,
+ fragment: dict | None = None,
+ ech_config_list: str | None = None,
+ ):
+ if isinstance(port, str):
+ ports = port.split(",")
+ port = int(choice(ports))
+
+ config = {
+ "type": type,
+ "tag": remark,
+ "server": address,
+ "server_port": port,
+ }
+
+ if net in ("tcp", "raw", "kcp") and headers != "http" and (tls or tls != "none"):
+ if flow:
+ config["flow"] = flow
+
+ if net == "h2":
+ net = "http"
+ alpn = ["h2"]
+ elif net == "h3":
+ net = "http"
+ alpn = ["h3"]
+ elif net in ("tcp", "raw") and headers == "http":
+ net = "http"
+
+ if net in ("http", "ws", "quic", "grpc", "httpupgrade"):
+ max_early_data = None
+ early_data_header_name = None
+
+ if "?ed=" in path:
+ path, max_early_data = path.split("?ed=")
+ (max_early_data,) = max_early_data.split("/")
+ max_early_data = int(max_early_data)
+ early_data_header_name = "Sec-WebSocket-Protocol"
+
+ config["transport"] = self.transport_config(
+ transport_type=net,
+ host=host,
+ path=path,
+ max_early_data=max_early_data,
+ early_data_header_name=early_data_header_name,
+ random_user_agent=random_user_agent,
+ ping_timeout=ping_timeout,
+ idle_timeout=idle_timeout,
+ permit_without_stream=permit_without_stream,
+ http_headers=http_headers,
+ request=request,
+ headers=headers,
+ )
+
+ if tls in ("tls", "reality"):
+ config["tls"] = self.tls_config(
+ sni=sni,
+ fragment=fragment,
+ fp=fp,
+ tls=tls,
+ pbk=pbk,
+ sid=sid,
+ alpn=alpn,
+ ais=ais,
+ ech_config_list=ech_config_list,
+ )
+
+ if mux_settings and (singbox_mux := mux_settings.get("sing_box")):
+ singbox_mux = self._remove_none_values(singbox_mux)
+ config["multiplex"] = singbox_mux
+
+ return config
+
+ def add(self, remark: str, address: str, inbound: dict, settings: dict):
+ net = inbound["network"]
+ path = inbound["path"]
+
+ # not supported by sing-box
+ if net in ("kcp", "splithttp", "xhttp") or (net == "quic" and inbound["header_type"] != "none"):
+ return
+
+ if net in ("grpc", "gun"):
+ path = get_grpc_gun(path)
+
+ remark = self._remark_validation(remark)
+ self.proxy_remarks.append(remark)
+
+ outbound = self.make_outbound(
+ remark=remark,
+ type=inbound["protocol"],
+ address=address,
+ port=inbound["port"],
+ net=net,
+ tls=(inbound["tls"]),
+ flow=settings.get("flow", ""),
+ sni=inbound["sni"],
+ host=inbound["host"],
+ path=path,
+ alpn=inbound.get("alpn", None),
+ fp=inbound.get("fp", ""),
+ pbk=inbound.get("pbk", ""),
+ sid=inbound.get("sid", ""),
+ headers=inbound["header_type"],
+ ais=inbound.get("ais", ""),
+ random_user_agent=inbound.get("random_user_agent", False),
+ idle_timeout=inbound.get("idle_timeout", ""),
+ ping_timeout=inbound.get("health_check_timeout", ""),
+ permit_without_stream=inbound.get("permit_without_stream", False),
+ http_headers=inbound.get("http_headers"),
+ request=inbound.get("request"),
+ mux_settings=inbound.get("mux_settings", {}),
+ fragment=inbound.get("fragment_settings", {}),
+ ech_config_list=inbound.get("ech_config_list"),
+ )
+
+ if inbound["protocol"] == "vmess":
+ outbound["uuid"] = settings["id"]
+
+ elif inbound["protocol"] == "vless":
+ outbound["uuid"] = settings["id"]
+
+ elif inbound["protocol"] == "trojan":
+ outbound["password"] = settings["password"]
+
+ elif inbound["protocol"] == "shadowsocks":
+ outbound["method"], outbound["password"] = detect_shadowsocks_2022(
+ inbound.get("is_2022", False),
+ inbound.get("method", ""),
+ settings["method"],
+ inbound.get("password"),
+ settings["password"],
+ )
+
+ self.add_outbound(outbound)
diff --git a/app/subscription/xray.py b/app/subscription/xray.py
new file mode 100644
index 000000000..0c0c03ee1
--- /dev/null
+++ b/app/subscription/xray.py
@@ -0,0 +1,680 @@
+import json
+from enum import Enum
+from random import choice
+from typing import Union
+
+from app.subscription.funcs import detect_shadowsocks_2022, get_grpc_gun, get_grpc_multi
+from app.templates import render_template
+from app.utils.helpers import UUIDEncoder
+from config import XRAY_SUBSCRIPTION_TEMPLATE
+
+from . import BaseSubscription
+
+
+class XrayConfiguration(BaseSubscription):
+ def __init__(self):
+ super().__init__()
+ self.config = []
+ self.template = render_template(XRAY_SUBSCRIPTION_TEMPLATE)
+
+ def add_config(self, remarks, outbounds):
+ json_template = json.loads(self.template)
+ json_template["remarks"] = remarks
+ json_template["outbounds"] = outbounds + json_template["outbounds"]
+ self.config.append(json_template)
+
+ def render(self, reverse=False):
+ if reverse:
+ self.config.reverse()
+ return json.dumps(self.config, indent=4, cls=UUIDEncoder)
+
+ def tls_config(self, sni=None, fp=None, alpn=None, ais=False, ech_config_list=None) -> dict:
+ tls_settings = {
+ "serverName": sni,
+ "allowInsecure": ais if ais else False,
+ "show": False,
+ "fingerprint": fp,
+ "echConfigList": ech_config_list,
+ }
+ if alpn:
+ tls_settings["alpn"] = alpn
+
+ return self._remove_none_values(tls_settings)
+
+ def reality_config(self, sni=None, fp=None, pbk=None, sid=None, spx=None, mldsa65_verify=None) -> dict:
+ reality_settings = {
+ "serverName": sni,
+ "fingerprint": fp,
+ "show": False,
+ "publicKey": pbk,
+ "shortId": sid,
+ "spiderX": spx,
+ "mldsa65Verify": mldsa65_verify,
+ }
+
+ return self._remove_none_values(reality_settings)
+
+ def ws_config(
+ self,
+ path: str = "",
+ host: str = "",
+ random_user_agent: bool = False,
+ heartbeat_period: int | None = None,
+ http_headers: dict | None = None,
+ ) -> dict:
+ ws_settings = {
+ "headers": http_headers if http_headers is not None else {},
+ "heartbeatPeriod": heartbeat_period,
+ "path": path,
+ "host": host,
+ }
+ if random_user_agent:
+ ws_settings["headers"]["User-Agent"] = choice(self.user_agent_list)
+ return self._remove_none_values(ws_settings)
+
+ def httpupgrade_config(
+ self, path: str = "", host: str = "", random_user_agent: bool = False, http_headers=None
+ ) -> dict:
+ httpupgrade_settings = {
+ "headers": http_headers if http_headers is not None else {},
+ "path": path,
+ "host": host,
+ }
+ if random_user_agent:
+ httpupgrade_settings["headers"]["User-Agent"] = choice(self.user_agent_list)
+
+ return self._remove_none_values(httpupgrade_settings)
+
+ def xhttp_config(
+ self,
+ path: str = "",
+ host: str = "",
+ random_user_agent: bool = False,
+ sc_max_each_post_bytes: int | None = None,
+ sc_max_concurrent_posts: int | None = None,
+ sc_min_posts_interval_ms: int | None = None,
+ x_padding_bytes: str | None = None,
+ xmux: dict | None = None,
+ download_settings: dict | None = None,
+ mode: str = "",
+ no_grpc_header: bool | None = None,
+ http_headers: dict | None = None,
+ ) -> dict:
+ xhttp_settings = {}
+
+ mode = mode.value if isinstance(mode, Enum) else mode
+ xhttp_settings["mode"] = mode
+ if path:
+ xhttp_settings["path"] = path
+ if host:
+ xhttp_settings["host"] = host
+ extra = {
+ "headers": http_headers if http_headers is not None else {},
+ "scMaxEachPostBytes": sc_max_each_post_bytes,
+ "scMaxConcurrentPosts": sc_max_concurrent_posts,
+ "scMinPostsIntervalMs": sc_min_posts_interval_ms,
+ "xPaddingBytes": x_padding_bytes,
+ "noGRPCHeader": no_grpc_header,
+ "xmux": xmux,
+ "downloadSettings": self.download_config(download_settings, False) if download_settings else None,
+ }
+ if random_user_agent:
+ if mode in ("stream-one", "stream-up") and not no_grpc_header:
+ extra["headers"]["User-Agent"] = choice(self.grpc_user_agent_data)
+ else:
+ extra["headers"]["User-Agent"] = choice(self.user_agent_list)
+
+ xhttp_settings["extra"] = extra
+ return self._remove_none_values(xhttp_settings)
+
+ def grpc_config(
+ self,
+ path: str = "",
+ host: str = "",
+ multi_mode: bool = False,
+ random_user_agent: bool = False,
+ idle_timeout=None,
+ health_check_timeout=None,
+ permit_without_stream=False,
+ initial_windows_size=None,
+ http_headers=None,
+ ) -> dict:
+ grpc_settings = {
+ "idle_timeout": idle_timeout if idle_timeout is not None else 60,
+ "health_check_timeout": health_check_timeout if health_check_timeout is not None else 20,
+ "permit_without_stream": permit_without_stream,
+ "initial_windows_size": initial_windows_size if initial_windows_size is not None else 35538,
+ "serviceName": path,
+ "authority": host,
+ "multiMode": multi_mode,
+ }
+ if http_headers and "user-agent" in http_headers:
+ grpc_settings["user_agent"] = http_headers["user-agent"]
+ if random_user_agent:
+ grpc_settings["user_agent"] = choice(self.grpc_user_agent_data)
+ return self._remove_none_values(grpc_settings)
+
+ def tcp_config(
+ self,
+ headers="none",
+ path: str = "",
+ host: str = "",
+ random_user_agent: bool = False,
+ request: dict | None = None,
+ response: dict | None = None,
+ ) -> dict:
+ if headers == "http":
+ tcp_settings = {
+ "header": {
+ "type": headers,
+ "request": request
+ if request
+ else {
+ "version": "1.1",
+ "method": "GET",
+ "path": ["/"],
+ "headers": {
+ "Host": [],
+ "User-Agent": [],
+ "Accept-Encoding": ["gzip, deflate"],
+ "Connection": ["keep-alive"],
+ "Pragma": "no-cache",
+ },
+ },
+ "response": response
+ if response
+ else {
+ "version": "1.1",
+ "status": "200",
+ "reason": "OK",
+ "headers": {
+ "Content-Type": ["application/octet-stream", "video/mpeg"],
+ "Transfer-Encoding": ["chunked"],
+ "Connection": ["keep-alive"],
+ "Pragma": "no-cache",
+ },
+ },
+ }
+ }
+
+ else:
+ tcp_settings = {"header": {"type": headers}}
+
+ if any((path, host, random_user_agent)):
+ if "request" not in tcp_settings["header"]:
+ tcp_settings["header"]["request"] = {}
+
+ if any((random_user_agent, host)):
+ if (
+ "headers" not in tcp_settings["header"]["request"]
+ or tcp_settings["header"]["request"]["headers"] is None
+ ):
+ tcp_settings["header"]["request"]["headers"] = {}
+
+ if path:
+ tcp_settings["header"]["request"]["path"] = [path]
+
+ if host:
+ tcp_settings["header"]["request"]["headers"]["Host"] = [host]
+
+ if random_user_agent:
+ tcp_settings["header"]["request"]["headers"]["User-Agent"] = [choice(self.user_agent_list)]
+
+ return self._remove_none_values(tcp_settings)
+
+ def http_config(
+ self, path: str = "", host: str = "", random_user_agent: bool = False, http_headers: dict | None = None
+ ) -> dict:
+ http_settings = {
+ "headers": {k: [v] for k, v in http_headers.items()} if http_headers is not None else {},
+ "path": path,
+ "host": [host] if host else [],
+ }
+ if random_user_agent:
+ http_settings["headers"]["User-Agent"] = [choice(self.user_agent_list)]
+ return self._remove_none_values(http_settings)
+
+ def quic_config(self, path=None, host=None, header="none") -> dict:
+ quicSettings = {"security": host, "header": {"type": header}, "key": path}
+ return self._remove_none_values(quicSettings)
+
+ def kcp_config(
+ self,
+ seed=None,
+ host=None,
+ header="none",
+ mtu=None,
+ tti=None,
+ uplinkCapacity=None,
+ downlinkCapacity=None,
+ congestion=False,
+ readBufferSize=None,
+ writeBufferSize=None,
+ ) -> dict:
+ kcp_settings = {
+ "header": {"type": header, "domain": host},
+ "mtu": mtu if mtu else 1350,
+ "tti": tti if tti else 50,
+ "uplinkCapacity": uplinkCapacity if uplinkCapacity else 12,
+ "downlinkCapacity": downlinkCapacity if downlinkCapacity else 100,
+ "congestion": congestion,
+ "readBufferSize": readBufferSize if readBufferSize else 2,
+ "writeBufferSize": writeBufferSize if writeBufferSize else 2,
+ "seed": seed,
+ }
+ return self._remove_none_values(kcp_settings)
+
+ @staticmethod
+ def stream_setting_config(
+ network=None, security=None, network_setting=None, tls_settings=None, sockopt=None
+ ) -> dict:
+ stream_settings = {"network": network}
+
+ if security and security != "none":
+ stream_settings["security"] = security
+ stream_settings[f"{security}Settings"] = tls_settings
+
+ if network and network_setting:
+ stream_settings[f"{network}Settings"] = network_setting
+
+ if sockopt:
+ stream_settings["sockopt"] = sockopt
+
+ return stream_settings
+
+ def download_config(self, inbound: dict, link_format: bool = False) -> dict:
+ net = inbound["network"]
+ port = inbound["port"]
+ if isinstance(port, str):
+ ports = port.split(",")
+ port = int(choice(ports))
+
+ address = inbound["address"]
+ tls = inbound["tls"]
+ headers = inbound["header_type"]
+ fragment = inbound["fragment_settings"]
+ noise = inbound["noise_settings"]
+ path = inbound["path"]
+ multi_mode = inbound.get("multi_mode", False)
+
+ if net in ("grpc", "gun"):
+ if multi_mode:
+ path = get_grpc_multi(path)
+ else:
+ path = get_grpc_gun(path)
+
+ dialer_proxy = ""
+ if (fragment or noise) and not link_format:
+ dialer_proxy = "dsdialer"
+
+ download_settings = self.make_stream_setting(
+ net=net,
+ tls=tls,
+ sni=inbound["sni"],
+ host=inbound["host"],
+ path=path,
+ alpn=inbound.get("alpn", None),
+ fp=inbound.get("fp", ""),
+ pbk=inbound.get("pbk", ""),
+ sid=inbound.get("sid", ""),
+ spx=inbound.get("spx", ""),
+ headers=headers,
+ ais=inbound.get("ais", ""),
+ multi_mode=multi_mode,
+ random_user_agent=inbound.get("random_user_agent", False),
+ sc_max_each_post_bytes=inbound.get("sc_max_each_post_bytes"),
+ sc_max_concurrent_posts=inbound.get("sc_max_concurrent_posts"),
+ sc_min_posts_interval_ms=inbound.get("sc_min_posts_interval_ms"),
+ x_padding_bytes=inbound.get("x_padding_bytes"),
+ xmux=inbound.get("xmux", {}),
+ download_settings=inbound.get("downloadSettings"),
+ mode=inbound.get("mode", "auto"),
+ no_grpc_header=inbound.get("no_grpc_header"),
+ heartbeat_period=inbound.get("heartbeat_period"),
+ http_headers=inbound.get("http_headers"),
+ request=inbound.get("request"),
+ response=inbound.get("response"),
+ mtu=inbound.get("mtu"),
+ tti=inbound.get("tti"),
+ uplink_capacity=inbound.get("uplink_capacity"),
+ downlink_capacity=inbound.get("downlink_capacity"),
+ congestion=inbound.get("congestion"),
+ read_buffer_size=inbound.get("read_buffer_size"),
+ write_buffer_size=inbound.get("write_buffer_size"),
+ idle_timeout=inbound.get("idle_timeout"),
+ health_check_timeout=inbound.get("health_check_timeout"),
+ permit_without_stream=inbound.get("permit_without_stream", False),
+ initial_windows_size=inbound.get("initial_windows_size"),
+ dialer_proxy=dialer_proxy,
+ ech_config_list=inbound.get("ech_config_list"),
+ )
+
+ return {
+ "address": address,
+ "port": port,
+ **download_settings,
+ }
+
+ @staticmethod
+ def vmess_config(address=None, port=None, id=None) -> dict:
+ return {
+ "vnext": [
+ {
+ "address": address,
+ "port": port,
+ "users": [{"id": id, "alterId": 0, "email": "t@t.com", "security": "auto"}],
+ }
+ ]
+ }
+
+ @staticmethod
+ def vless_config(address=None, port=None, id=None, flow="", encryption="none") -> dict:
+ return {
+ "vnext": [
+ {
+ "address": address,
+ "port": port,
+ "users": [
+ {
+ "id": id,
+ "security": "auto",
+ "encryption": encryption,
+ "email": "t@t.com",
+ "alterId": 0,
+ "flow": flow,
+ }
+ ],
+ }
+ ]
+ }
+
+ @staticmethod
+ def trojan_config(address=None, port=None, password=None) -> dict:
+ return {
+ "servers": [
+ {
+ "address": address,
+ "port": port,
+ "password": password,
+ "email": "t@t.com",
+ }
+ ]
+ }
+
+ @staticmethod
+ def shadowsocks_config(address=None, port=None, password=None, method=None) -> dict:
+ return {
+ "servers": [
+ {
+ "address": address,
+ "port": port,
+ "password": password,
+ "email": "t@t.com",
+ "method": method,
+ "uot": False,
+ }
+ ]
+ }
+
+ def make_dialer_outbound(
+ self, fragment: dict | None = None, noises: dict | None = None, dialer_tag: str = "dialer"
+ ) -> Union[dict, None]:
+ xray_noises = noises.get("xray", []) if noises else []
+ dialer_settings = {
+ "fragment": fragment.get("xray") if fragment else None,
+ "noises": [
+ {
+ "type": noise["type"],
+ "packet": noise["packet"],
+ "delay": noise["delay"],
+ "applyTo": noise["apply_to"],
+ }
+ for noise in xray_noises
+ ]
+ or None,
+ }
+ dialer_settings = self._remove_none_values(dialer_settings)
+
+ if dialer_settings:
+ return {"tag": dialer_tag, "protocol": "freedom", "settings": dialer_settings}
+
+ def make_stream_setting(
+ self,
+ net="",
+ path="",
+ host="",
+ tls="",
+ sni="",
+ fp="",
+ alpn=None,
+ pbk="",
+ sid="",
+ spx="",
+ headers="",
+ ais="",
+ dialer_proxy="",
+ multi_mode: bool = False,
+ random_user_agent: bool = False,
+ sc_max_each_post_bytes: int | None = None,
+ sc_max_concurrent_posts: int | None = None,
+ sc_min_posts_interval_ms: int | None = None,
+ x_padding_bytes: str | None = None,
+ xmux: dict = {},
+ download_settings: dict = {},
+ mode: str = "",
+ no_grpc_header: bool | None = None,
+ heartbeat_period: int = 0,
+ http_headers: dict | None = None,
+ idle_timeout=None,
+ health_check_timeout=None,
+ permit_without_stream=False,
+ initial_windows_size=None,
+ request: dict | None = None,
+ response: dict | None = None,
+ mtu=None,
+ tti=None,
+ uplink_capacity=None,
+ downlink_capacity=None,
+ congestion=False,
+ read_buffer_size=None,
+ write_buffer_size=None,
+ ech_config_list=None,
+ mldsa65_verify=None,
+ ) -> dict:
+ if net == "ws":
+ network_setting = self.ws_config(
+ path=path,
+ host=host,
+ random_user_agent=random_user_agent,
+ heartbeat_period=heartbeat_period,
+ http_headers=http_headers,
+ )
+ elif net == "grpc":
+ network_setting = self.grpc_config(
+ path=path,
+ host=host,
+ multi_mode=multi_mode,
+ random_user_agent=random_user_agent,
+ idle_timeout=idle_timeout,
+ health_check_timeout=health_check_timeout,
+ permit_without_stream=permit_without_stream,
+ initial_windows_size=initial_windows_size,
+ http_headers=http_headers,
+ )
+ elif net in ("h3", "h2", "http"):
+ network_setting = self.http_config(
+ path=path, host=host, random_user_agent=random_user_agent, http_headers=http_headers
+ )
+ elif net == "kcp":
+ network_setting = self.kcp_config(
+ seed=path,
+ host=host,
+ header=headers,
+ mtu=mtu,
+ tti=tti,
+ uplinkCapacity=uplink_capacity,
+ downlinkCapacity=downlink_capacity,
+ congestion=congestion,
+ readBufferSize=read_buffer_size,
+ writeBufferSize=write_buffer_size,
+ )
+ elif net in ("tcp", "raw") and tls != "reality":
+ network_setting = self.tcp_config(
+ headers=headers,
+ path=path,
+ host=host,
+ random_user_agent=random_user_agent,
+ request=request,
+ response=response,
+ )
+ elif net == "quic":
+ network_setting = self.quic_config(path=path, host=host, header=headers)
+ elif net == "httpupgrade":
+ network_setting = self.httpupgrade_config(
+ path=path, host=host, random_user_agent=random_user_agent, http_headers=http_headers
+ )
+ elif net in ("splithttp", "xhttp"):
+ network_setting = self.xhttp_config(
+ path=path,
+ host=host,
+ random_user_agent=random_user_agent,
+ sc_max_each_post_bytes=sc_max_each_post_bytes,
+ sc_max_concurrent_posts=sc_max_concurrent_posts,
+ sc_min_posts_interval_ms=sc_min_posts_interval_ms,
+ x_padding_bytes=x_padding_bytes,
+ xmux=xmux,
+ download_settings=download_settings,
+ mode=mode,
+ no_grpc_header=no_grpc_header,
+ http_headers=http_headers,
+ )
+ else:
+ network_setting = {}
+
+ if tls == "tls":
+ tls_settings = self.tls_config(sni=sni, fp=fp, alpn=alpn, ais=ais, ech_config_list=ech_config_list)
+ elif tls == "reality":
+ tls_settings = self.reality_config(sni=sni, fp=fp, pbk=pbk, sid=sid, spx=spx, mldsa65_verify=mldsa65_verify)
+ else:
+ tls_settings = None
+
+ if dialer_proxy:
+ sockopt = {"dialerProxy": dialer_proxy}
+ else:
+ sockopt = None
+
+ return self.stream_setting_config(
+ network=net, security=tls, network_setting=network_setting, tls_settings=tls_settings, sockopt=sockopt
+ )
+
+ def add(self, remark: str, address: str, inbound: dict, settings: dict):
+ net = inbound["network"]
+ protocol = inbound["protocol"]
+ port = inbound["port"]
+ if isinstance(port, str):
+ ports = port.split(",")
+ port = int(choice(ports))
+
+ tls = inbound["tls"]
+ headers = inbound["header_type"]
+ fragment = inbound["fragment_settings"]
+ noise = inbound["noise_settings"]
+ path = inbound["path"]
+ multi_mode = inbound.get("multi_mode", False)
+
+ if net in ("grpc", "gun"):
+ if multi_mode:
+ path = get_grpc_multi(path)
+ else:
+ path = get_grpc_gun(path)
+
+ outbound = {"tag": "proxy", "protocol": protocol}
+
+ if inbound["protocol"] == "vmess":
+ outbound["settings"] = self.vmess_config(address=address, port=port, id=settings["id"])
+
+ elif inbound["protocol"] == "vless":
+ if net in ("tcp", "raw", "kcp") and headers != "http" and tls in ("tls", "reality"):
+ flow = settings.get("flow", "")
+ else:
+ flow = None
+ encryption = inbound.get("encryption", "none")
+
+ outbound["settings"] = self.vless_config(
+ address=address, port=port, id=settings["id"], flow=flow, encryption=encryption
+ )
+
+ elif inbound["protocol"] == "trojan":
+ outbound["settings"] = self.trojan_config(address=address, port=port, password=settings["password"])
+
+ elif inbound["protocol"] == "shadowsocks":
+ method, password = detect_shadowsocks_2022(
+ inbound.get("is_2022", False),
+ inbound.get("method", ""),
+ settings["method"],
+ inbound.get("password"),
+ settings["password"],
+ )
+ outbound["settings"] = self.shadowsocks_config(address=address, port=port, password=password, method=method)
+
+ outbounds = [outbound]
+ dialer_proxy = ""
+ extra_outbound = self.make_dialer_outbound(fragment, noise)
+ if extra_outbound:
+ dialer_proxy = extra_outbound["tag"]
+ outbounds.append(extra_outbound)
+
+ if (ds := inbound.get("downloadSettings", {})) and (ds.get("fragment_settings") or ds.get("noise_settings")):
+ ds_outbound = self.make_dialer_outbound(ds.get("fragment_settings"), ds.get("noise_settings"), "dsdialer")
+ if ds_outbound:
+ outbounds.append(ds_outbound)
+
+ outbound["streamSettings"] = self.make_stream_setting(
+ net=net,
+ tls=tls,
+ sni=inbound["sni"],
+ host=inbound["host"],
+ path=path,
+ alpn=inbound.get("alpn", None),
+ fp=inbound.get("fp", ""),
+ pbk=inbound.get("pbk", ""),
+ sid=inbound.get("sid", ""),
+ spx=inbound.get("spx", ""),
+ headers=headers,
+ ais=inbound.get("ais", ""),
+ dialer_proxy=dialer_proxy,
+ multi_mode=multi_mode,
+ random_user_agent=inbound.get("random_user_agent", False),
+ sc_max_each_post_bytes=inbound.get("sc_max_each_post_bytes"),
+ sc_max_concurrent_posts=inbound.get("sc_max_concurrent_posts"),
+ sc_min_posts_interval_ms=inbound.get("sc_min_posts_interval_ms"),
+ x_padding_bytes=inbound.get("x_padding_bytes"),
+ xmux=inbound.get("xmux", {}),
+ download_settings=inbound.get("downloadSettings"),
+ mode=inbound.get("mode", "auto"),
+ no_grpc_header=inbound.get("no_grpc_header"),
+ heartbeat_period=inbound.get("heartbeat_period"),
+ http_headers=inbound.get("http_headers"),
+ request=inbound.get("request"),
+ response=inbound.get("response"),
+ mtu=inbound.get("mtu"),
+ tti=inbound.get("tti"),
+ uplink_capacity=inbound.get("uplink_capacity"),
+ downlink_capacity=inbound.get("downlink_capacity"),
+ congestion=inbound.get("congestion"),
+ read_buffer_size=inbound.get("read_buffer_size"),
+ write_buffer_size=inbound.get("write_buffer_size"),
+ idle_timeout=inbound.get("idle_timeout"),
+ health_check_timeout=inbound.get("health_check_timeout"),
+ permit_without_stream=inbound.get("permit_without_stream", False),
+ initial_windows_size=inbound.get("initial_windows_size"),
+ ech_config_list=inbound.get("ech_config_list"),
+ mldsa65_verify=inbound.get("mldsa65Verify"),
+ )
+
+ mux_settings: dict = inbound.get("mux_settings", {})
+ if mux_settings and (xray_mux := mux_settings.get("xray")):
+ xray_mux = self._remove_none_values(xray_mux)
+ outbound["mux"] = xray_mux
+
+ self.add_config(remarks=remark, outbounds=outbounds)
diff --git a/app/telegram/__init__.py b/app/telegram/__init__.py
new file mode 100644
index 000000000..2551d13bc
--- /dev/null
+++ b/app/telegram/__init__.py
@@ -0,0 +1,125 @@
+import asyncio
+from asyncio import Lock
+
+from aiogram import Bot, Dispatcher
+from aiogram.client.default import DefaultBotProperties
+from aiogram.client.session.aiohttp import AiohttpSession
+from aiogram.enums import ParseMode
+from aiogram.exceptions import TelegramBadRequest, TelegramNetworkError, TelegramRetryAfter, TelegramUnauthorizedError
+from python_socks._errors import ProxyConnectionError
+
+from app import on_shutdown, on_startup
+from app.models.settings import RunMethod, Telegram
+from app.settings import telegram_settings
+from app.utils.logger import get_logger
+
+from .handlers import include_routers
+from .middlewares import setup_middlewares
+
+logger = get_logger("telegram-bot")
+
+
+_bot = None
+_polling_task: asyncio.Task = None
+_lock = Lock()
+_dp = Dispatcher()
+
+
+def get_bot():
+ return _bot
+
+
+def get_dispatcher():
+ return _dp
+
+
+async def startup_telegram_bot():
+ restart = False
+ global _bot
+ global _dp
+ global _polling_task
+
+ if _bot:
+ await shutdown_telegram_bot()
+ restart = True
+
+ async with _lock:
+ settings: Telegram = await telegram_settings()
+ if settings.enable:
+ logger.info("Telegram bot starting")
+ session = AiohttpSession(proxy=settings.proxy_url)
+ _bot = Bot(token=settings.token, session=session, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
+
+ try:
+ if not restart:
+ # register handlers
+ include_routers(_dp)
+ # register middlewares
+ setup_middlewares(_dp)
+ except RuntimeError:
+ pass
+
+ try:
+ if settings.method == RunMethod.LONGPOLLING:
+ _polling_task = asyncio.create_task(_dp.start_polling(_bot, handle_signals=False))
+ else:
+ # register webhook
+ webhook_address = f"{settings.webhook_url}/api/tghook"
+ logger.info(webhook_address)
+ await _bot.set_webhook(
+ webhook_address,
+ secret_token=settings.webhook_secret,
+ allowed_updates=["message", "callback_query", "inline_query"],
+ drop_pending_updates=True,
+ )
+ logger.info("Telegram bot started successfully.")
+ except (
+ TelegramNetworkError,
+ ProxyConnectionError,
+ TelegramBadRequest,
+ TelegramUnauthorizedError,
+ ) as err:
+ if hasattr(err, "message"):
+ logger.error(err.message)
+ else:
+ logger.error(err)
+
+
+async def shutdown_telegram_bot():
+ global _bot
+ global _dp
+ global _polling_task
+
+ async with _lock:
+ if isinstance(_bot, Bot):
+ logger.info("Shutting down telegram bot")
+ try:
+ if _polling_task is not None and not _polling_task.done():
+ logger.info("stopping long polling")
+ # Force stop the dispatcher first
+ await _dp.stop_polling()
+ # Cancel the polling task
+ _polling_task.cancel()
+ _polling_task = None
+ else:
+ await _bot.delete_webhook(drop_pending_updates=True)
+ except (
+ TelegramNetworkError,
+ TelegramRetryAfter,
+ ProxyConnectionError,
+ TelegramUnauthorizedError,
+ ) as err:
+ if hasattr(err, "message"):
+ logger.error(err.message)
+ else:
+ logger.error(err)
+
+ if _bot.session:
+ await _bot.session.close()
+
+ _bot = None
+ logger.info("Telegram bot shut down successfully.")
+
+
+on_startup(startup_telegram_bot)
+on_shutdown(shutdown_telegram_bot)
diff --git a/app/telegram/handlers/__init__.py b/app/telegram/handlers/__init__.py
new file mode 100644
index 000000000..1d74283b8
--- /dev/null
+++ b/app/telegram/handlers/__init__.py
@@ -0,0 +1,11 @@
+from aiogram import Dispatcher
+
+from . import admin, base, error_handler, client
+
+
+def include_routers(dp: Dispatcher) -> None:
+ dp.include_router(base.router)
+ dp.include_router(admin.router) # keep these last one
+ dp.include_router(client.router) # and before error_handler
+
+ dp.include_router(error_handler.router)
diff --git a/app/telegram/handlers/admin/__init__.py b/app/telegram/handlers/admin/__init__.py
new file mode 100644
index 000000000..be527b5e7
--- /dev/null
+++ b/app/telegram/handlers/admin/__init__.py
@@ -0,0 +1,17 @@
+from aiogram import Router
+
+from app.telegram.utils.filters import IsAdminFilter
+from . import main_menu, user, confirm_action, bulk_actions
+
+
+router = Router(name="admin")
+
+router.message.filter(IsAdminFilter())
+router.callback_query.filter(IsAdminFilter())
+router.inline_query.filter(IsAdminFilter())
+
+router.include_router(main_menu.router)
+router.include_router(confirm_action.router)
+router.include_router(bulk_actions.router)
+
+router.include_router(user.router) # keep this as last one
diff --git a/app/telegram/handlers/admin/bulk_actions.py b/app/telegram/handlers/admin/bulk_actions.py
new file mode 100644
index 000000000..98967ee34
--- /dev/null
+++ b/app/telegram/handlers/admin/bulk_actions.py
@@ -0,0 +1,163 @@
+from datetime import datetime as dt, timezone as tz, timedelta as td
+
+from aiogram import Router, F
+from aiogram.exceptions import TelegramBadRequest
+from aiogram.fsm.context import FSMContext
+from aiogram.types import CallbackQuery, Message
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.admin import AdminDetails
+from app.models.user import BulkUser
+from app.operation import OperatorType
+from app.operation.user import UserOperation
+from app.telegram.keyboards.admin import AdminPanel, AdminPanelAction
+from app.telegram.keyboards.base import CancelKeyboard
+from app.telegram.keyboards.bulk_actions import BulkActionPanel, BulkAction
+from app.telegram.keyboards.confim_action import ConfirmAction
+from app.telegram.utils import forms
+from app.telegram.utils.shared import add_to_messages_to_delete, delete_messages
+from app.telegram.utils.texts import Message as Texts
+
+user_operations = UserOperation(OperatorType.TELEGRAM)
+
+router = Router(name="bulk_actions")
+
+
+@router.callback_query(AdminPanel.Callback.filter(AdminPanelAction.bulk_actions == F.action))
+async def bulk_actions(event: CallbackQuery):
+ await event.message.edit_text(Texts.choose_action, reply_markup=BulkActionPanel().as_markup())
+
+
+@router.callback_query(BulkActionPanel.Callback.filter((BulkAction.delete_expired == F.action) & ~F.amount))
+async def delete_expired(event: CallbackQuery, state: FSMContext):
+ try:
+ await event.message.delete()
+ except TelegramBadRequest:
+ pass
+ await state.set_state(forms.DeleteExpired.expired_before)
+ msg = await event.message.answer(Texts.enter_expire_before, reply_markup=CancelKeyboard().as_markup())
+ await state.update_data(messages_to_delete=[msg.message_id])
+
+
+@router.message(forms.DeleteExpired.expired_before)
+async def process_expire_before(event: Message, state: FSMContext):
+ await delete_messages(event, state)
+ await add_to_messages_to_delete(state, event)
+
+ if not event.text or not event.text.isnumeric():
+ msg = await event.reply(text=Texts.duration_not_valid, reply_markup=CancelKeyboard().as_markup())
+ await add_to_messages_to_delete(state, msg)
+ return
+
+ await state.clear()
+
+ await event.answer(
+ Texts.confirm_delete_expired(event.text),
+ reply_markup=ConfirmAction(
+ confirm_action=BulkActionPanel.Callback(action=BulkAction.delete_expired, amount=event.text).pack(),
+ cancel_action=AdminPanel.Callback(
+ action=AdminPanelAction.bulk_actions,
+ ).pack(),
+ ).as_markup(),
+ )
+
+
+@router.callback_query(BulkActionPanel.Callback.filter((BulkAction.delete_expired == F.action) & F.amount))
+async def delete_expired_done(
+ event: CallbackQuery, db: AsyncSession, admin: AdminDetails, callback_data: BulkActionPanel.Callback
+):
+ expire_before = dt.now(tz.utc) - td(days=int(callback_data.amount))
+ result = await user_operations.delete_expired_users(
+ db,
+ admin,
+ expired_before=expire_before,
+ expired_after=dt.fromtimestamp(0, tz.utc),
+ )
+ await event.answer(Texts.users_deleted(result.count))
+ await event.message.edit_text(Texts.choose_action, reply_markup=BulkActionPanel().as_markup())
+
+
+@router.callback_query(BulkActionPanel.Callback.filter((BulkAction.modify_expiry == F.action) & ~F.amount))
+async def modify_expiry(event: CallbackQuery, state: FSMContext):
+ try:
+ await event.message.delete()
+ except TelegramBadRequest:
+ pass
+ await state.set_state(forms.BulkModify.expiry)
+ msg = await event.message.answer(Texts.enter_bulk_expiry, reply_markup=CancelKeyboard().as_markup())
+ await state.update_data(messages_to_delete=[msg.message_id])
+
+
+@router.message(forms.BulkModify.expiry)
+async def process_expiry(event: Message, state: FSMContext):
+ await delete_messages(event, state)
+ await add_to_messages_to_delete(state, event)
+
+ try:
+ amount = int(event.text)
+ except ValueError:
+ msg = await event.reply(text=Texts.duration_not_valid, reply_markup=CancelKeyboard().as_markup())
+ await add_to_messages_to_delete(state, msg)
+ return
+
+ await state.clear()
+
+ await event.answer(
+ Texts.confirm_modify_expiry(amount),
+ reply_markup=ConfirmAction(
+ confirm_action=BulkActionPanel.Callback(action=BulkAction.modify_expiry, amount=str(amount)).pack(),
+ cancel_action=AdminPanel.Callback(
+ action=AdminPanelAction.bulk_actions,
+ ).pack(),
+ ).as_markup(),
+ )
+
+
+@router.callback_query(BulkActionPanel.Callback.filter((BulkAction.modify_expiry == F.action) & F.amount))
+async def modify_expiry_done(event: CallbackQuery, db: AsyncSession, callback_data: BulkActionPanel.Callback):
+ result = await user_operations.bulk_modify_expire(db, BulkUser(amount=int(callback_data.amount) * 86400))
+ await event.answer(Texts.users_expiry_changed(result, int(callback_data.amount)))
+ await event.message.edit_text(Texts.choose_action, reply_markup=BulkActionPanel().as_markup())
+
+
+@router.callback_query(BulkActionPanel.Callback.filter((BulkAction.modify_data_limit == F.action) & ~F.amount))
+async def modify_data_limit(event: CallbackQuery, state: FSMContext):
+ try:
+ await event.message.delete()
+ except TelegramBadRequest:
+ pass
+ await state.set_state(forms.BulkModify.data_limit)
+ msg = await event.message.answer(Texts.enter_bulk_data_limit, reply_markup=CancelKeyboard().as_markup())
+ await state.update_data(messages_to_delete=[msg.message_id])
+
+
+@router.message(forms.BulkModify.data_limit)
+async def process_data_limit(event: Message, state: FSMContext):
+ await delete_messages(event, state)
+ await add_to_messages_to_delete(state, event)
+
+ try:
+ amount = int(event.text)
+ except ValueError:
+ msg = await event.reply(text=Texts.data_limit_not_valid, reply_markup=CancelKeyboard().as_markup())
+ await add_to_messages_to_delete(state, msg)
+ return
+
+ await state.clear()
+
+ await event.answer(
+ Texts.confirm_modify_data_limit(amount),
+ reply_markup=ConfirmAction(
+ confirm_action=BulkActionPanel.Callback(action=BulkAction.modify_data_limit, amount=str(amount)).pack(),
+ cancel_action=AdminPanel.Callback(
+ action=AdminPanelAction.bulk_actions,
+ ).pack(),
+ ).as_markup(),
+ )
+
+
+@router.callback_query(BulkActionPanel.Callback.filter((BulkAction.modify_data_limit == F.action) & F.amount))
+async def modify_data_limit_done(event: CallbackQuery, db: AsyncSession, callback_data: BulkActionPanel.Callback):
+ result = await user_operations.bulk_modify_datalimit(db, BulkUser(amount=int(callback_data.amount) * (1024**3)))
+ await event.answer(Texts.users_data_limit_changed(result, int(callback_data.amount)))
+ await event.message.edit_text(Texts.choose_action, reply_markup=BulkActionPanel().as_markup())
diff --git a/app/telegram/handlers/admin/confirm_action.py b/app/telegram/handlers/admin/confirm_action.py
new file mode 100644
index 000000000..dad00a7c5
--- /dev/null
+++ b/app/telegram/handlers/admin/confirm_action.py
@@ -0,0 +1,43 @@
+from aiogram import Router
+from aiogram.types import CallbackQuery
+from sqlalchemy.ext.asyncio.session import AsyncSession
+
+from app.models.admin import AdminDetails
+from app.operation import OperatorType
+from app.operation.user import UserOperation
+from app.telegram.keyboards.confim_action import ConfirmAction
+from app.telegram.keyboards.user import UserPanel, UserPanelAction
+from app.telegram.utils.texts import Message as Texts
+
+
+router = Router(name="confirm_action")
+
+user_operations = UserOperation(OperatorType.TELEGRAM)
+
+
+@router.callback_query(ConfirmAction.Callback.filter())
+async def confirm_action(
+ event: CallbackQuery, callback_data: ConfirmAction.Callback, db: AsyncSession, admin: AdminDetails
+):
+ text = Texts.confirm
+ if callback_data.action.startswith(UserPanel.Callback.__prefix__):
+ action = UserPanel.Callback.unpack(callback_data.action)
+ user = await user_operations.get_user_by_id(db, action.user_id, admin)
+ match action.action:
+ case UserPanelAction.disable:
+ text = Texts.confirm_disable_user(user.username)
+ case UserPanelAction.enable:
+ text = Texts.confirm_enable_user(user.username)
+ case UserPanelAction.delete:
+ text = Texts.confirm_delete_user(user.username)
+ case UserPanelAction.revoke_sub:
+ text = Texts.confirm_revoke_sub(user.username)
+ case UserPanelAction.reset_usage:
+ text = Texts.confirm_reset_usage(user.username)
+ case UserPanelAction.activate_next_plan:
+ text = Texts.confirm_activate_next_plan(user.username)
+
+ await event.message.edit_text(
+ text,
+ reply_markup=ConfirmAction(confirm_action=callback_data.action, cancel_action=callback_data.cancel).as_markup(),
+ )
diff --git a/app/telegram/handlers/admin/main_menu.py b/app/telegram/handlers/admin/main_menu.py
new file mode 100644
index 000000000..892dba612
--- /dev/null
+++ b/app/telegram/handlers/admin/main_menu.py
@@ -0,0 +1,56 @@
+from aiogram import Router, F
+from aiogram.exceptions import TelegramBadRequest
+from aiogram.types import CallbackQuery
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.operation import OperatorType
+from app.operation.node import NodeOperation
+from app.models.admin import AdminDetails
+from app.operation.system import SystemOperation
+from app.settings import telegram_settings
+from app.telegram.utils.filters import IsAdminSUDO, IsAdminFilter
+from app.telegram.keyboards.admin import AdminPanel, AdminPanelAction
+from app.telegram.utils.texts import Message as Texts
+
+
+system_operator = SystemOperation(OperatorType.TELEGRAM)
+node_operator = NodeOperation(OperatorType.TELEGRAM)
+
+router = Router(name="main_menu")
+
+
+@router.callback_query(IsAdminFilter(), AdminPanel.Callback.filter(AdminPanelAction.refresh == F.action))
+async def reload_data(event: CallbackQuery, db: AsyncSession, admin: AdminDetails):
+ stats = await system_operator.get_system_stats(db, admin)
+ try:
+ settings = await telegram_settings()
+ await event.message.edit_text(
+ text=Texts.start(stats),
+ reply_markup=AdminPanel(
+ is_sudo=admin.is_sudo, panel_url=settings.mini_app_web_url if settings.mini_app_login else None
+ ).as_markup(),
+ )
+ except TelegramBadRequest:
+ pass
+
+ await event.answer(Texts.refreshed)
+
+
+@router.callback_query(IsAdminSUDO(), AdminPanel.Callback.filter(AdminPanelAction.sync_users == F.action))
+async def sync_users(event: CallbackQuery, db: AsyncSession, admin: AdminDetails):
+ await event.answer(Texts.syncing)
+ for node in await node_operator.get_db_nodes(db):
+ await node_operator.sync_node_users(db, node.id, flush_users=True)
+ try:
+ stats = await system_operator.get_system_stats(db, admin)
+ settings = await telegram_settings()
+ await event.message.edit_text(
+ text=Texts.start(stats),
+ reply_markup=AdminPanel(
+ is_sudo=admin.is_sudo, panel_url=settings.mini_app_web_url if settings.mini_app_login else None
+ ).as_markup(),
+ )
+ except TelegramBadRequest:
+ pass
+
+ await event.answer(Texts.synced)
diff --git a/app/telegram/handlers/admin/user.py b/app/telegram/handlers/admin/user.py
new file mode 100644
index 000000000..6e4e79532
--- /dev/null
+++ b/app/telegram/handlers/admin/user.py
@@ -0,0 +1,622 @@
+import random
+from datetime import datetime as dt, timedelta as td
+
+from aiogram import Router, F
+from aiogram.types import CallbackQuery, Message, InlineQuery, InlineQueryResultArticle, InputTextMessageContent
+from aiogram.fsm.context import FSMContext
+from sqlalchemy.ext.asyncio import AsyncSession
+from aiogram.exceptions import TelegramBadRequest
+
+from app.db.models import UserStatus
+from app.models.user import UserCreate, UserModify, UserStatusModify, CreateUserFromTemplate, ModifyUserByTemplate
+from app.models.validators import UserValidator
+from app.operation import OperatorType
+from app.operation.user import UserOperation
+from app.operation.group import GroupOperation
+from app.operation.user_template import UserTemplateOperation
+from app.telegram.keyboards.group import SelectGroupAction, GroupsSelector
+from app.telegram.utils import forms
+from app.models.admin import AdminDetails
+from app.telegram.keyboards.admin import AdminPanel, AdminPanelAction, InlineQuerySearch
+from app.telegram.keyboards.base import CancelKeyboard
+from app.telegram.utils.texts import Message as Texts
+from app.telegram.keyboards.user import UserPanel, UserPanelAction, ChooseStatus, ChooseTemplate, RandomUsername
+from app.telegram.utils.shared import add_to_messages_to_delete, delete_messages
+
+user_operations = UserOperation(OperatorType.TELEGRAM)
+group_operations = GroupOperation(OperatorType.TELEGRAM)
+user_templates = UserTemplateOperation(OperatorType.TELEGRAM)
+
+router = Router(name="user")
+
+
+@router.callback_query(
+ AdminPanel.Callback.filter(AdminPanelAction.create_user == F.action),
+)
+async def create_user(event: CallbackQuery, state: FSMContext):
+ try:
+ await event.message.delete()
+ except TelegramBadRequest:
+ pass
+ await state.set_state(forms.CreateUser.username)
+ msg = await event.message.answer(Texts.enter_username, reply_markup=RandomUsername().as_markup())
+ await state.update_data(messages_to_delete=[msg.message_id])
+
+
+@router.message(forms.CreateUser.username)
+@router.callback_query(RandomUsername.Callback.filter(~F.with_template))
+async def process_username(event: Message | CallbackQuery, state: FSMContext, db: AsyncSession, admin: AdminDetails):
+ await delete_messages(event, state)
+ if isinstance(event, Message):
+ await add_to_messages_to_delete(state, event)
+ username = event.text
+ try:
+ UserValidator.validate_username(username)
+ except ValueError as e:
+ msg = await event.reply(f"❌ {e}", reply_markup=RandomUsername().as_markup())
+ await add_to_messages_to_delete(state, msg)
+ return
+ else:
+ await add_to_messages_to_delete(state, event.message)
+ username = "".join(random.choices("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", k=5))
+
+ try:
+ await user_operations.get_validated_user(db, username, admin)
+ if isinstance(event, Message):
+ msg = await event.reply(Texts.username_already_exist, reply_markup=RandomUsername().as_markup())
+ await add_to_messages_to_delete(state, msg)
+ else:
+ await event.answer(Texts.username_already_exist)
+ return
+ except ValueError:
+ pass
+
+ await delete_messages(event, state)
+ await state.update_data(username=username)
+ await state.set_state(forms.CreateUser.data_limit)
+ if isinstance(event, Message):
+ msg = await event.answer(Texts.enter_data_limit, reply_markup=CancelKeyboard().as_markup())
+ else:
+ msg = await event.message.answer(Texts.enter_data_limit, reply_markup=CancelKeyboard().as_markup())
+ await add_to_messages_to_delete(state, msg)
+
+
+@router.message(forms.CreateUser.data_limit)
+async def process_data_limit(event: Message, state: FSMContext):
+ await delete_messages(event, state)
+ await add_to_messages_to_delete(state, event)
+
+ try:
+ data_limit = float(event.text)
+ if data_limit < 0:
+ raise ValueError
+ await state.update_data(data_limit=data_limit)
+
+ await state.set_state(forms.CreateUser.expire)
+
+ await add_to_messages_to_delete(state, event)
+ await delete_messages(event, state)
+
+ msg = await event.answer(Texts.enter_duration, reply_markup=CancelKeyboard().as_markup())
+ await add_to_messages_to_delete(state, msg)
+ return
+
+ except ValueError:
+ msg = await event.reply(Texts.data_limit_not_valid, reply_markup=CancelKeyboard().as_markup())
+ await add_to_messages_to_delete(state, msg)
+ return
+
+
+@router.message(forms.CreateUser.expire)
+async def process_expire(event: Message, state: FSMContext, db: AsyncSession):
+ await delete_messages(event, state)
+ await add_to_messages_to_delete(state, event)
+
+ try:
+ duration = int(event.text)
+ if duration < 0:
+ raise ValueError
+ except ValueError:
+ msg = await event.reply(text=Texts.duration_not_valid, reply_markup=CancelKeyboard().as_markup())
+ await add_to_messages_to_delete(state, msg)
+ return
+
+ await state.update_data(duration=duration)
+ await delete_messages(event, state)
+ if duration:
+ await state.set_state(forms.CreateUser.status)
+ return await event.answer(Texts.choose_status, reply_markup=ChooseStatus().as_markup())
+ else:
+ await state.update_data(status=UserStatus.active.value)
+ await state.set_state(forms.CreateUser.group_ids)
+ groups = await group_operations.get_all_groups(db)
+ return await event.answer(Texts.select_groups, reply_markup=GroupsSelector(groups).as_markup())
+
+
+@router.callback_query(ChooseStatus.Callback.filter())
+async def process_status(
+ event: CallbackQuery, db: AsyncSession, state: FSMContext, callback_data: ChooseStatus.Callback
+):
+ await state.update_data(status=callback_data.status)
+
+ await add_to_messages_to_delete(state, event.message)
+ await delete_messages(event, state)
+
+ if callback_data.status == UserStatus.on_hold.value:
+ await state.set_state(forms.CreateUser.on_hold_timeout)
+ msg = await event.message.answer(Texts.enter_on_hold_timeout, reply_markup=CancelKeyboard().as_markup())
+ await add_to_messages_to_delete(state, msg)
+ else:
+ await state.set_state(forms.CreateUser.group_ids)
+ groups = await group_operations.get_all_groups(db)
+ await event.message.answer(Texts.select_groups, reply_markup=GroupsSelector(groups).as_markup())
+
+
+@router.message(forms.CreateUser.on_hold_timeout)
+async def process_on_hold_timeout(event: Message, state: FSMContext, db: AsyncSession):
+ await delete_messages(event, state)
+ await add_to_messages_to_delete(state, event)
+
+ try:
+ timeout = int(event.text)
+ if timeout < 0:
+ raise ValueError
+ except ValueError:
+ msg = await event.reply(text=Texts.duration_not_valid, reply_markup=CancelKeyboard().as_markup())
+ await add_to_messages_to_delete(state, msg)
+ return
+
+ await state.update_data(on_hold_timeout=timeout)
+ groups = await group_operations.get_all_groups(db)
+
+ await add_to_messages_to_delete(state, event)
+ await delete_messages(event, state)
+
+ await event.answer(Texts.select_groups, reply_markup=GroupsSelector(groups).as_markup())
+
+
+@router.callback_query(GroupsSelector.Callback.filter(SelectGroupAction.select == F.action))
+async def select_groups(
+ event: CallbackQuery, db: AsyncSession, state: FSMContext, callback_data: GroupsSelector.Callback
+):
+ group_ids = await state.get_value("group_ids")
+ if isinstance(group_ids, list):
+ if callback_data.group_id in group_ids:
+ group_ids.remove(callback_data.group_id)
+ else:
+ group_ids.append(callback_data.group_id)
+ else:
+ group_ids = [callback_data.group_id]
+
+ await state.update_data(group_ids=group_ids)
+ all_groups = await group_operations.get_all_groups(db)
+
+ await event.message.edit_reply_markup(
+ reply_markup=GroupsSelector(
+ groups=all_groups, selected_groups=group_ids, user_id=callback_data.user_id
+ ).as_markup()
+ )
+
+
+@router.callback_query(GroupsSelector.Callback.filter(SelectGroupAction.create == F.action))
+async def process_done(event: CallbackQuery, db: AsyncSession, admin: AdminDetails, state: FSMContext):
+ data = await state.get_data()
+ if not data.get("group_ids", []):
+ return await event.answer(Texts.select_a_group, show_alert=True)
+
+ duration = data.get("duration")
+ if data.get("status") == UserStatus.on_hold.value:
+ data["status"] = UserStatus.on_hold
+ data["on_hold_expire_duration"] = td(days=duration).total_seconds() if duration else 0
+ timeout = data.get("on_hold_timeout")
+ data["on_hold_timeout"] = (dt.now() + td(days=timeout)) if timeout else None
+ else:
+ data["status"] = UserStatus.active
+ data["expire"] = (dt.now() + td(days=duration)) if duration else None
+
+ data["data_limit"] *= 1024**3
+
+ await delete_messages(event, state)
+ await state.clear()
+
+ del data["messages_to_delete"]
+ del data["duration"]
+
+ new_user = UserCreate(**data)
+ user = await user_operations.create_user(db, new_user, admin)
+ groups = await user_operations.validate_all_groups(db, user)
+ await event.answer(Texts.user_created)
+ return await event.message.edit_text(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup())
+
+
+@router.callback_query(UserPanel.Callback.filter(UserPanelAction.modify_groups == F.action))
+async def modify_groups(
+ event: CallbackQuery, db: AsyncSession, state: FSMContext, callback_data: UserPanel.Callback, admin: AdminDetails
+):
+ try:
+ user = await user_operations.get_user_by_id(db, callback_data.user_id, admin)
+ except ValueError:
+ return await event.answer(Texts.user_not_found)
+
+ groups = await user_operations.validate_all_groups(db, user)
+ all_groups = await group_operations.get_all_groups(db)
+ await state.clear()
+ await state.update_data(user_id=user.id, group_ids=[group.id for group in groups])
+ await event.message.edit_text(
+ Texts.select_groups,
+ reply_markup=GroupsSelector(
+ all_groups, selected_groups=[group.id for group in groups], user_id=user.id
+ ).as_markup(),
+ )
+
+
+@router.callback_query(GroupsSelector.Callback.filter(SelectGroupAction.modify == F.action))
+async def modify_groups_done(event: CallbackQuery, db: AsyncSession, admin: AdminDetails, state: FSMContext):
+ data = await state.get_data()
+ if not data.get("group_ids", []):
+ return await event.answer(Texts.select_a_group, show_alert=True)
+
+ user_id = data.get("user_id")
+ try:
+ user = await user_operations.get_user_by_id(db, user_id, admin)
+ except ValueError:
+ return await event.answer(Texts.user_not_found)
+
+ modified_user = UserModify(group_ids=data["group_ids"])
+ user = await user_operations.modify_user(db, user.username, modified_user, admin)
+ groups = await user_operations.validate_all_groups(db, user)
+ await delete_messages(event, state)
+ await state.clear()
+ await event.message.edit_text(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup())
+
+
+@router.callback_query(UserPanel.Callback.filter(UserPanelAction.modify_expiry == F.action))
+async def modify_expiry(event: CallbackQuery, callback_data: UserPanel.Callback, state: FSMContext):
+ await state.set_state(forms.ModifyUser.new_expiry)
+ await state.update_data(user_id=callback_data.user_id)
+ try:
+ await event.message.delete()
+ except TelegramBadRequest:
+ pass
+ msg = await event.message.answer(
+ Texts.enter_modify_expiry,
+ reply_markup=CancelKeyboard(UserPanel.Callback(user_id=callback_data.user_id)).as_markup(),
+ )
+ await add_to_messages_to_delete(state, msg)
+
+
+@router.message(forms.ModifyUser.new_expiry)
+async def modify_expiry_done(event: Message, state: FSMContext, db: AsyncSession, admin: AdminDetails):
+ await delete_messages(event, state)
+ await add_to_messages_to_delete(state, event)
+ try:
+ duration = int(event.text)
+ if duration < 0:
+ raise ValueError
+ except ValueError:
+ msg = await event.reply(text=Texts.duration_not_valid, reply_markup=CancelKeyboard().as_markup())
+ await add_to_messages_to_delete(state, msg)
+ return
+ user_id = await state.get_value("user_id")
+ await state.clear()
+ await delete_messages(event, state)
+ try:
+ user = await user_operations.get_user_by_id(db, user_id, admin)
+ except ValueError:
+ return await event.answer(Texts.user_not_found)
+ if user.status == UserStatus.on_hold:
+ if duration:
+ modified_user = UserModify(on_hold_expire_duration=int(td(days=duration).total_seconds()))
+ else:
+ modified_user = UserModify(status=UserStatusModify.active, expire=0)
+ else:
+ modified_user = UserModify(expire=(dt.now() + td(days=duration)) if duration else 0)
+ user = await user_operations.modify_user(db, user.username, modified_user, admin)
+ groups = await user_operations.validate_all_groups(db, user)
+ await event.answer(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup())
+
+
+@router.callback_query(UserPanel.Callback.filter(UserPanelAction.modify_data_limit == F.action))
+async def modify_data_limit(event: CallbackQuery, callback_data: UserPanel.Callback, state: FSMContext):
+ await state.set_state(forms.ModifyUser.new_data_limit)
+ await state.update_data(user_id=callback_data.user_id)
+ try:
+ await event.message.delete()
+ except TelegramBadRequest:
+ pass
+ msg = await event.message.answer(
+ Texts.enter_modify_data_limit,
+ reply_markup=CancelKeyboard(UserPanel.Callback(user_id=callback_data.user_id)).as_markup(),
+ )
+ await add_to_messages_to_delete(state, msg)
+
+
+@router.message(forms.ModifyUser.new_data_limit)
+async def modify_data_limit_done(event: Message, state: FSMContext, db: AsyncSession, admin: AdminDetails):
+ await delete_messages(event, state)
+ await add_to_messages_to_delete(state, event)
+ try:
+ data_limit = float(event.text)
+ if data_limit < 0:
+ raise ValueError
+ except ValueError:
+ msg = await event.reply(text=Texts.data_limit_not_valid, reply_markup=CancelKeyboard().as_markup())
+ await add_to_messages_to_delete(state, msg)
+ return
+ user_id = await state.get_value("user_id")
+ await state.clear()
+ await delete_messages(event, state)
+ try:
+ user = await user_operations.get_user_by_id(db, user_id, admin)
+ except ValueError:
+ return await event.answer(Texts.user_not_found)
+ modified_user = UserModify(data_limit=data_limit * 1024**3)
+ user = await user_operations.modify_user(db, user.username, modified_user, admin)
+ groups = await user_operations.validate_all_groups(db, user)
+ await event.answer(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup())
+
+
+@router.callback_query(UserPanel.Callback.filter(UserPanelAction.modify_note == F.action))
+async def modify_note(event: CallbackQuery, callback_data: UserPanel.Callback, state: FSMContext):
+ await state.set_state(forms.ModifyUser.new_note)
+ await state.update_data(user_id=callback_data.user_id)
+ try:
+ await event.message.delete()
+ except TelegramBadRequest:
+ pass
+ msg = await event.message.answer(
+ Texts.enter_modify_note,
+ reply_markup=CancelKeyboard(UserPanel.Callback(user_id=callback_data.user_id)).as_markup(),
+ )
+ await add_to_messages_to_delete(state, msg)
+
+
+@router.message(forms.ModifyUser.new_note)
+async def modify_note_done(event: Message, state: FSMContext, db: AsyncSession, admin: AdminDetails):
+ await delete_messages(event, state)
+ await add_to_messages_to_delete(state, event)
+ note = event.text
+ user_id = await state.get_value("user_id")
+ await state.clear()
+ await delete_messages(event, state)
+ try:
+ user = await user_operations.get_user_by_id(db, user_id, admin)
+ except ValueError:
+ return await event.answer(Texts.user_not_found)
+ modified_user = UserModify(note=note)
+ user = await user_operations.modify_user(db, user.username, modified_user, admin)
+ groups = await user_operations.validate_all_groups(db, user)
+ await event.answer(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup())
+
+
+@router.callback_query(UserPanel.Callback.filter(UserPanelAction.disable == F.action))
+async def disable_user(event: CallbackQuery, admin: AdminDetails, db: AsyncSession, callback_data: UserPanel.Callback):
+ user = await user_operations.get_user_by_id(db, callback_data.user_id, admin)
+ modified_user = UserModify(**user.model_dump())
+ modified_user.status = UserStatusModify.disabled
+ user = await user_operations.modify_user(db, user.username, modified_user, admin)
+ await event.answer(f"User {user.username} has been disabled.")
+ groups = await user_operations.validate_all_groups(db, user)
+ await event.message.edit_text(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup())
+
+
+@router.callback_query(UserPanel.Callback.filter(UserPanelAction.delete == F.action))
+async def delete_user(event: CallbackQuery, admin: AdminDetails, db: AsyncSession, callback_data: UserPanel.Callback):
+ user = await user_operations.get_user_by_id(db, callback_data.user_id, admin)
+ await user_operations.remove_user(db, user.username, admin)
+ await event.answer(Texts.user_deleted(user.username))
+ await event.message.edit_text(Texts.user_deleted(user.username), reply_markup=AdminPanel(admin.is_sudo).as_markup())
+
+
+@router.callback_query(UserPanel.Callback.filter(UserPanelAction.enable == F.action))
+async def enable_user(event: CallbackQuery, admin: AdminDetails, db: AsyncSession, callback_data: UserPanel.Callback):
+ user = await user_operations.get_user_by_id(db, callback_data.user_id, admin)
+ modified_user = UserModify(**user.model_dump())
+ modified_user.status = UserStatusModify.active
+ user = await user_operations.modify_user(db, user.username, modified_user, admin)
+ await event.answer(Texts.user_enabled(user.username))
+ groups = await user_operations.validate_all_groups(db, user)
+ await event.message.edit_text(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup())
+
+
+@router.callback_query(UserPanel.Callback.filter(UserPanelAction.revoke_sub == F.action))
+async def revoke_sub(event: CallbackQuery, admin: AdminDetails, db: AsyncSession, callback_data: UserPanel.Callback):
+ user = await user_operations.get_user_by_id(db, callback_data.user_id, admin)
+ user = await user_operations.revoke_user_sub(db, user.username, admin)
+ await event.answer(Texts.user_sub_revoked(user.username))
+ groups = await user_operations.validate_all_groups(db, user)
+ await event.message.edit_text(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup())
+
+
+@router.callback_query(UserPanel.Callback.filter(UserPanelAction.reset_usage == F.action))
+async def reset_usage(event: CallbackQuery, admin: AdminDetails, db: AsyncSession, callback_data: UserPanel.Callback):
+ user = await user_operations.get_user_by_id(db, callback_data.user_id, admin)
+ user = await user_operations.reset_user_data_usage(db, user.username, admin)
+ await event.answer(Texts.user_reset_usage(user.username))
+ groups = await user_operations.validate_all_groups(db, user)
+ await event.message.edit_text(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup())
+
+
+@router.callback_query(UserPanel.Callback.filter(UserPanelAction.activate_next_plan == F.action))
+async def activate_next_plan(
+ event: CallbackQuery, admin: AdminDetails, db: AsyncSession, callback_data: UserPanel.Callback
+):
+ user = await user_operations.get_user_by_id(db, callback_data.user_id, admin)
+ user = await user_operations.active_next_plan(db, user.username, admin)
+ await event.answer(Texts.user_next_plan_activated(user.username))
+ groups = await user_operations.validate_all_groups(db, user)
+ await event.message.edit_text(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup())
+
+
+@router.callback_query(UserPanel.Callback.filter(UserPanelAction.modify_with_template == F.action))
+async def modify_with_template(event: CallbackQuery, db: AsyncSession, callback_data: UserPanel.Callback):
+ templates = await user_templates.get_user_templates(db)
+ if not templates:
+ return await event.answer(Texts.there_is_no_template)
+
+ await event.message.edit_text(
+ Texts.choose_a_template, reply_markup=ChooseTemplate(templates, user_id=callback_data.user_id).as_markup()
+ )
+
+
+@router.callback_query(ChooseTemplate.Callback.filter(F.username))
+async def modify_with_template_done(
+ event: CallbackQuery, db: AsyncSession, admin: AdminDetails, callback_data: ChooseTemplate.Callback
+):
+ user = await user_operations.get_user_by_id(db, callback_data.user_id, admin)
+ user = await user_operations.modify_user_with_template(
+ db,
+ user.username,
+ ModifyUserByTemplate(user_template_id=callback_data.template_id),
+ admin,
+ )
+ groups = await user_operations.validate_all_groups(db, user)
+ return await event.message.edit_text(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup())
+
+
+@router.callback_query(AdminPanel.Callback.filter(AdminPanelAction.create_user_from_template == F.action))
+async def create_user_from_template(event: CallbackQuery, db: AsyncSession):
+ templates = await user_templates.get_user_templates(db)
+ if not templates:
+ return await event.answer(Texts.there_is_no_template)
+ await event.message.edit_text(Texts.choose_a_template, reply_markup=ChooseTemplate(templates).as_markup())
+
+
+@router.callback_query(ChooseTemplate.Callback.filter(~F.username))
+async def create_user_from_template_username(
+ event: CallbackQuery, state: FSMContext, callback_data: ChooseTemplate.Callback
+):
+ try:
+ await event.message.delete()
+ except TelegramBadRequest:
+ pass
+
+ await state.set_state(forms.CreateUserFromTemplate.username)
+ msg = await event.message.answer(Texts.enter_username, reply_markup=RandomUsername(with_template=True).as_markup())
+ await state.update_data(template_id=callback_data.template_id, messages_to_delete=[msg.message_id])
+
+
+@router.message(forms.CreateUserFromTemplate.username)
+@router.callback_query(RandomUsername.Callback.filter(F.with_template))
+async def create_user_from_template_choose(
+ event: Message | CallbackQuery, state: FSMContext, db: AsyncSession, admin: AdminDetails
+):
+ await delete_messages(event, state)
+
+ if isinstance(event, Message):
+ await add_to_messages_to_delete(state, event)
+
+ username = event.text
+ try:
+ UserValidator.validate_username(username)
+ except ValueError as e:
+ msg = await event.reply(f"❌ {e}", reply_markup=RandomUsername(with_template=True).as_markup())
+ await add_to_messages_to_delete(state, msg)
+ return
+ else:
+ await add_to_messages_to_delete(state, event.message)
+ username = "".join(random.choices("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", k=5))
+
+ template_id = await state.get_value("template_id")
+ template = await user_templates.get_validated_user_template(db, template_id)
+
+ try:
+ actual_username = username
+ if template.username_prefix:
+ actual_username = template.username_prefix + username
+ if template.username_suffix:
+ actual_username = username + template.username_suffix
+
+ await user_operations.get_validated_user(db, actual_username, admin)
+ if isinstance(event, Message):
+ msg = await event.reply(
+ Texts.username_already_exist, reply_markup=RandomUsername(with_template=True).as_markup()
+ )
+ await add_to_messages_to_delete(state, msg)
+ else:
+ await event.answer(Texts.username_already_exist)
+ return
+ except ValueError:
+ pass
+
+ await state.clear()
+ await delete_messages(event, state)
+
+ user = await user_operations.create_user_from_template(
+ db, CreateUserFromTemplate(username=username, user_template_id=template_id), admin
+ )
+ groups = await user_operations.validate_all_groups(db, user)
+ if isinstance(event, Message):
+ return await event.answer(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup())
+ else:
+ return await event.message.answer(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup())
+
+
+@router.message(F.text.contains("/sub/"))
+async def get_user_by_sub(event: Message, db: AsyncSession, admin: AdminDetails):
+ """get exact user by subscription token, otherwise not found"""
+ token = event.text.strip("/").split("/")[-1]
+ try:
+ db_user = await user_operations.get_validated_sub(db, token)
+ user = await user_operations.validate_user(db_user)
+ if user.admin and user.admin.username != admin.username and not admin.is_sudo:
+ return await event.reply(Texts.user_not_found)
+ except ValueError:
+ return await event.reply(Texts.user_not_found)
+
+ groups = await user_operations.validate_all_groups(db, user)
+ await event.reply(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup())
+
+
+@router.message(F.text)
+@router.callback_query(UserPanel.Callback.filter(UserPanelAction.show == F.action))
+async def get_user(event: Message | CallbackQuery, admin: AdminDetails, db: AsyncSession, **kwargs):
+ """get exact user, otherwise not found"""
+ try:
+ if isinstance(event, Message):
+ user = await user_operations.get_user(db, event.text, admin)
+ else:
+ user = await user_operations.get_user_by_id(db, kwargs["callback_data"].user_id, admin)
+ except ValueError:
+ if isinstance(event, Message):
+ return await event.reply(Texts.user_not_found, reply_markup=InlineQuerySearch(event.text).as_markup())
+ else:
+ return await event.answer(Texts.user_not_found)
+
+ groups = await user_operations.validate_all_groups(db, user)
+ if isinstance(event, Message):
+ await event.reply(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup())
+ else:
+ await event.message.edit_text(Texts.user_details(user, groups), reply_markup=UserPanel(user).as_markup())
+
+
+@router.inline_query()
+async def search_user(event: InlineQuery, admin: AdminDetails, db: AsyncSession):
+ search = await user_operations.get_users(db, admin, search=event.query.strip(), limit=50)
+ result = [
+ InlineQueryResultArticle(
+ id=str(user.id),
+ title=f"{Texts.status_emoji(user.status)}{user.username}",
+ description=Texts.user_short_detail(user),
+ url=user.subscription_url if user.subscription_url.startswith("https://") else None,
+ input_message_content=InputTextMessageContent(message_text=user.username),
+ )
+ for user in search.users
+ ]
+ if not result:
+ result = [
+ InlineQueryResultArticle(
+ id="1",
+ title=Texts.user_not_found,
+ input_message_content=InputTextMessageContent(message_text="/start"),
+ )
+ ]
+ try:
+ await event.answer(result, cache_time=5)
+ except TelegramBadRequest: # in case of query too old
+ pass
+
+
+@router.callback_query()
+async def debug(event: CallbackQuery):
+ await event.answer(event.data, show_alert=True)
diff --git a/app/telegram/handlers/base.py b/app/telegram/handlers/base.py
new file mode 100644
index 000000000..b4036e4cf
--- /dev/null
+++ b/app/telegram/handlers/base.py
@@ -0,0 +1,60 @@
+from aiogram import Router, types, F
+from aiogram.exceptions import TelegramBadRequest
+from aiogram.filters import CommandStart
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.admin import AdminDetails
+from app.telegram.keyboards.admin import AdminPanel
+from app.telegram.keyboards.base import CancelAction, CancelKeyboard
+from aiogram.fsm.context import FSMContext
+from app.operation import OperatorType
+from app.operation.system import SystemOperation
+from app.telegram.utils.texts import Message as Texts
+from app.telegram.utils.shared import delete_messages
+from app.settings import telegram_settings
+
+system_operator = SystemOperation(OperatorType.TELEGRAM)
+
+router = Router(name="base")
+
+
+@router.callback_query(CancelKeyboard.Callback.filter(CancelAction.cancel == F.action))
+@router.message(CommandStart())
+async def command_start_handler(
+ event: types.Message | types.CallbackQuery,
+ admin: AdminDetails | None,
+ state: FSMContext | None = None,
+ db: AsyncSession | None = None,
+):
+ """
+ This handler receives messages with `/start` command
+ """
+ message = event.message if isinstance(event, types.CallbackQuery) else event
+
+ if state is not None and (await state.get_state() is not None):
+ await delete_messages(event, state)
+ await state.clear()
+
+ settings = await telegram_settings()
+
+ if admin:
+ stats = await system_operator.get_system_stats(db, admin)
+ if isinstance(event, types.CallbackQuery):
+ try:
+ return await message.edit_text(
+ text=Texts.start(stats),
+ reply_markup=AdminPanel(
+ is_sudo=admin.is_sudo,
+ panel_url=settings.mini_app_web_url if settings.mini_app_login else None,
+ ).as_markup(),
+ )
+ except TelegramBadRequest:
+ pass
+ await message.answer(
+ text=Texts.start(stats),
+ reply_markup=AdminPanel(
+ is_sudo=admin.is_sudo, panel_url=settings.mini_app_web_url if settings.mini_app_login else None
+ ).as_markup(),
+ )
+ else:
+ await message.answer(f"Hello, {event.from_user.full_name}!")
diff --git a/app/telegram/handlers/client/__init__.py b/app/telegram/handlers/client/__init__.py
new file mode 100644
index 000000000..51db2c729
--- /dev/null
+++ b/app/telegram/handlers/client/__init__.py
@@ -0,0 +1,7 @@
+from aiogram import Router
+from . import show_info
+
+
+router = Router(name="client")
+
+router.include_router(show_info.router)
diff --git a/app/telegram/handlers/client/show_info.py b/app/telegram/handlers/client/show_info.py
new file mode 100644
index 000000000..25922dc69
--- /dev/null
+++ b/app/telegram/handlers/client/show_info.py
@@ -0,0 +1,42 @@
+from io import BytesIO
+
+from aiogram import Router, F
+from aiogram.types import BufferedInputFile
+from aiogram.types import Message
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.settings import ConfigFormat
+from app.operation import OperatorType
+from app.operation.subscription import SubscriptionOperation
+from app.operation.user import UserOperation
+from app.telegram.utils.texts import Message as Texts
+
+user_operations = UserOperation(OperatorType.TELEGRAM)
+subscription_operations = SubscriptionOperation(OperatorType.TELEGRAM)
+
+router = Router(name="show_info")
+
+
+@router.message(F.text)
+async def get_user(event: Message, db: AsyncSession):
+ """get exact user, otherwise not found"""
+ token = event.text.strip("/").split("/")[-1]
+ try:
+ db_user = await user_operations.get_validated_sub(db, token)
+ user = await user_operations.validate_user(db_user)
+ user_with_inbounds = await subscription_operations.validated_user(db_user)
+ configs = (await subscription_operations.fetch_config(user_with_inbounds, ConfigFormat.links))[0]
+ except ValueError:
+ return await event.reply(Texts.user_not_found)
+
+ if configs:
+ if len(configs) < 4085: # Telegram message limit (including formatting)
+ await event.reply(Texts.client_user_details(user))
+ await event.answer(f"
{configs}
")
+ else:
+ file = BytesIO(configs.encode("utf-8"))
+ await event.answer_document(
+ BufferedInputFile(file.read(), f"{user.username}.txt"), caption=Texts.client_user_details(user)
+ )
+ else:
+ await event.reply(Texts.client_user_details(user))
diff --git a/app/telegram/handlers/error_handler.py b/app/telegram/handlers/error_handler.py
new file mode 100644
index 000000000..b4dce5e01
--- /dev/null
+++ b/app/telegram/handlers/error_handler.py
@@ -0,0 +1,40 @@
+from aiogram import Router, F
+from aiogram.filters import ExceptionTypeFilter
+from aiogram.fsm.context import FSMContext
+from aiogram.types import ErrorEvent
+from aiogram.exceptions import TelegramAPIError
+from pydantic import ValidationError
+
+from app.utils.helpers import format_validation_error
+
+router = Router(name="error_handler")
+
+
+@router.error(ExceptionTypeFilter(ValueError, ValidationError), F.update.message | F.update.callback_query)
+async def handle_exception(event: ErrorEvent, state: FSMContext = None):
+ update = event.update
+
+ if state:
+ chat_id = update.callback_query.message.chat.id if update.callback_query else update.message.chat.id
+ messages_to_delete = await state.get_value("messages_to_delete", [])
+ try:
+ await update.bot.delete_messages(chat_id, messages_to_delete)
+ except TelegramAPIError:
+ pass
+ await state.clear()
+
+ error = "❌ Error: "
+ if isinstance(event.exception, ValidationError):
+ error += format_validation_error(event.exception)
+ else:
+ error += str(event.exception)
+
+ if update.message:
+ msg = await update.message.answer(error)
+ if state:
+ await state.update_data(messages_to_delete=[msg.message_id])
+
+ if update.callback_query:
+ if len(error) > 200:
+ error = error[:197] + "..."
+ await update.callback_query.answer(error, show_alert=True)
diff --git a/app/telegram/keyboards/__init__.py b/app/telegram/keyboards/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/app/telegram/keyboards/admin.py b/app/telegram/keyboards/admin.py
new file mode 100644
index 000000000..31a97e598
--- /dev/null
+++ b/app/telegram/keyboards/admin.py
@@ -0,0 +1,49 @@
+from enum import Enum
+
+from aiogram.utils.keyboard import InlineKeyboardBuilder, WebAppInfo
+from aiogram.filters.callback_data import CallbackData
+
+from app.telegram.utils.texts import Button as Texts
+
+
+class AdminPanelAction(str, Enum):
+ sync_users = "sync_users"
+ refresh = "refresh"
+ create_user = "create_user"
+ create_user_from_template = "create_user_from_template"
+ bulk_actions = "bulk_actions"
+
+
+class AdminPanel(InlineKeyboardBuilder):
+ class Callback(CallbackData, prefix="panel"):
+ action: AdminPanelAction
+
+ def __init__(self, is_sudo: bool = False, panel_url: str = None, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ adjust = []
+ if panel_url and panel_url.startswith("https://"):
+ self.button(text=Texts.open_panel, web_app=WebAppInfo(url=panel_url))
+ adjust.append(1)
+
+ self.button(text=Texts.refresh_data, callback_data=self.Callback(action=AdminPanelAction.refresh))
+ if is_sudo:
+ self.button(text=Texts.sync_users, callback_data=self.Callback(action=AdminPanelAction.sync_users))
+ self.button(text=Texts.users, switch_inline_query_current_chat="")
+ self.button(text=Texts.bulk_actions, callback_data=self.Callback(action=AdminPanelAction.bulk_actions))
+ adjust = adjust + [2] * 2
+ else:
+ self.button(text=Texts.users, switch_inline_query_current_chat="")
+ adjust = adjust + [1] * 2
+ self.button(text=Texts.create_user, callback_data=self.Callback(action=AdminPanelAction.create_user))
+ self.button(
+ text=Texts.create_user_from_template,
+ callback_data=self.Callback(action=AdminPanelAction.create_user_from_template),
+ )
+ adjust = adjust + [1] * 2
+ self.adjust(*adjust)
+
+
+class InlineQuerySearch(InlineKeyboardBuilder):
+ def __init__(self, query: str, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.button(text=Texts.search, switch_inline_query_current_chat=query)
diff --git a/app/telegram/keyboards/base.py b/app/telegram/keyboards/base.py
new file mode 100644
index 000000000..93060a979
--- /dev/null
+++ b/app/telegram/keyboards/base.py
@@ -0,0 +1,19 @@
+from enum import StrEnum
+from aiogram.utils.keyboard import InlineKeyboardBuilder
+from aiogram.filters.callback_data import CallbackData
+
+from app.telegram.utils.texts import Button as Texts
+
+
+class CancelAction(StrEnum):
+ cancel = "cancel"
+
+
+class CancelKeyboard(InlineKeyboardBuilder):
+ def __init__(self, action: CallbackData = CancelAction.cancel, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.button(text=Texts.cancel, callback_data=action or self.Callback())
+ self.adjust(1, 1)
+
+ class Callback(CallbackData, prefix=""):
+ action: CancelAction = CancelAction.cancel
diff --git a/app/telegram/keyboards/bulk_actions.py b/app/telegram/keyboards/bulk_actions.py
new file mode 100644
index 000000000..029aa1c00
--- /dev/null
+++ b/app/telegram/keyboards/bulk_actions.py
@@ -0,0 +1,33 @@
+from enum import Enum
+
+from aiogram.utils.keyboard import InlineKeyboardBuilder
+from aiogram.filters.callback_data import CallbackData
+
+from app.telegram.keyboards.base import CancelKeyboard, CancelAction
+from app.telegram.utils.texts import Button as Texts
+
+
+class BulkAction(str, Enum):
+ delete_expired = "delete_expired"
+ modify_expiry = "modify_expiry"
+ modify_data_limit = "modify_data_limit"
+
+
+class BulkActionPanel(InlineKeyboardBuilder):
+ class Callback(CallbackData, prefix="bulk"):
+ action: BulkAction
+ amount: str = ""
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ self.button(text=Texts.delete_expired, callback_data=self.Callback(action=BulkAction.delete_expired))
+ self.button(text=Texts.modify_expiry, callback_data=self.Callback(action=BulkAction.modify_expiry))
+ self.button(text=Texts.modify_data_limit, callback_data=self.Callback(action=BulkAction.modify_data_limit))
+
+ self.button(
+ text=Texts.back,
+ callback_data=CancelKeyboard.Callback(action=CancelAction.cancel),
+ )
+
+ self.adjust(1, repeat=True)
diff --git a/app/telegram/keyboards/confim_action.py b/app/telegram/keyboards/confim_action.py
new file mode 100644
index 000000000..fe6201c57
--- /dev/null
+++ b/app/telegram/keyboards/confim_action.py
@@ -0,0 +1,16 @@
+from aiogram.utils.keyboard import InlineKeyboardBuilder
+from aiogram.filters.callback_data import CallbackData
+
+from app.telegram.utils.texts import Button as Texts
+
+
+class ConfirmAction(InlineKeyboardBuilder):
+ class Callback(CallbackData, prefix="confirm", sep="|"):
+ action: str
+ cancel: str
+
+ def __init__(self, confirm_action: str, cancel_action: str, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.button(text=Texts.confirm, callback_data=confirm_action)
+ self.button(text=Texts.cancel, callback_data=cancel_action)
+ self.adjust(2, repeat=True)
diff --git a/app/telegram/keyboards/group.py b/app/telegram/keyboards/group.py
new file mode 100644
index 000000000..d71361bdd
--- /dev/null
+++ b/app/telegram/keyboards/group.py
@@ -0,0 +1,41 @@
+from enum import StrEnum
+from aiogram.utils.keyboard import InlineKeyboardBuilder
+from app.models.group import GroupsResponse
+from aiogram.filters.callback_data import CallbackData
+
+from app.telegram.keyboards.base import CancelKeyboard, CancelAction
+from app.telegram.keyboards.user import UserPanel
+from app.telegram.utils.texts import Button as Texts
+
+
+class SelectGroupAction(StrEnum):
+ select = "select"
+ create = "create"
+ modify = "modify"
+
+
+class GroupsSelector(InlineKeyboardBuilder):
+ def __init__(self, groups: GroupsResponse, selected_groups: list[int] = None, user_id: int = 0, *args, **kwargs):
+ selected_groups = selected_groups or []
+ super().__init__(*args, **kwargs)
+ for group in groups.groups:
+ self.button(
+ text=("✅" if group.id in selected_groups else "❌") + f" {group.name}",
+ callback_data=self.Callback(group_id=group.id, user_id=user_id),
+ )
+ self.button(
+ text=Texts.cancel,
+ callback_data=UserPanel.Callback(user_id=user_id)
+ if user_id
+ else CancelKeyboard.Callback(action=CancelAction.cancel),
+ )
+ self.button(
+ text=Texts.done,
+ callback_data=self.Callback(action=SelectGroupAction.modify if user_id else SelectGroupAction.create),
+ )
+ self.adjust(*[1 for i in groups.groups], 2)
+
+ class Callback(CallbackData, prefix="select_group"):
+ action: SelectGroupAction = SelectGroupAction.select
+ group_id: int = 0
+ user_id: int = 0
diff --git a/app/telegram/keyboards/user.py b/app/telegram/keyboards/user.py
new file mode 100644
index 000000000..23854c460
--- /dev/null
+++ b/app/telegram/keyboards/user.py
@@ -0,0 +1,157 @@
+from enum import Enum
+from typing import List
+
+from aiogram.types import CopyTextButton
+from aiogram.utils.keyboard import InlineKeyboardBuilder
+from aiogram.filters.callback_data import CallbackData
+
+from app.models.user import UserResponse, UserStatus
+from app.models.user_template import UserTemplate
+from app.telegram.utils.texts import Button as Texts
+from .base import CancelAction, CancelKeyboard
+
+from .confim_action import ConfirmAction
+
+
+class UserPanelAction(str, Enum):
+ show = "show"
+ disable = "disable"
+ enable = "enable"
+ delete = "delete"
+ revoke_sub = "revoke_sub"
+ reset_usage = "reset_usage"
+ activate_next_plan = "activate_next_plan"
+ modify_with_template = "modify_with_template"
+ modify_data_limit = "modify_data_limit"
+ modify_expiry = "modify_expiry"
+ modify_note = "modify_note"
+ modify_groups = "modify_groups"
+
+
+class UserPanel(InlineKeyboardBuilder):
+ class Callback(CallbackData, prefix="user"):
+ user_id: int
+ action: UserPanelAction = UserPanelAction.show
+
+ def __init__(self, user: UserResponse, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ if user.status == UserStatus.active:
+ self.button(
+ text=Texts.disable,
+ callback_data=ConfirmAction.Callback(
+ action=self.Callback(action=UserPanelAction.disable, user_id=user.id).pack(),
+ cancel=self.Callback(user_id=user.id).pack(),
+ ),
+ )
+ else:
+ self.button(
+ text=Texts.enable,
+ callback_data=ConfirmAction.Callback(
+ action=self.Callback(action=UserPanelAction.enable, user_id=user.id).pack(),
+ cancel=self.Callback(user_id=user.id).pack(),
+ ),
+ )
+ self.button(
+ text=Texts.delete,
+ callback_data=ConfirmAction.Callback(
+ action=self.Callback(action=UserPanelAction.delete, user_id=user.id).pack(),
+ cancel=self.Callback(user_id=user.id).pack(),
+ ),
+ )
+ self.button(
+ text=Texts.revoke_sub,
+ callback_data=ConfirmAction.Callback(
+ action=self.Callback(action=UserPanelAction.revoke_sub, user_id=user.id).pack(),
+ cancel=self.Callback(user_id=user.id).pack(),
+ ),
+ )
+ self.button(
+ text=Texts.reset_usage,
+ callback_data=ConfirmAction.Callback(
+ action=self.Callback(action=UserPanelAction.reset_usage, user_id=user.id).pack(),
+ cancel=self.Callback(user_id=user.id).pack(),
+ ),
+ )
+ self.button(
+ text=Texts.modify_data_limit,
+ callback_data=self.Callback(action=UserPanelAction.modify_data_limit, user_id=user.id),
+ )
+ self.button(
+ text=Texts.modify_expiry,
+ callback_data=self.Callback(action=UserPanelAction.modify_expiry, user_id=user.id),
+ )
+ self.button(
+ text=Texts.modify_note, callback_data=self.Callback(action=UserPanelAction.modify_note, user_id=user.id)
+ )
+ self.button(
+ text=Texts.modify_groups, callback_data=self.Callback(action=UserPanelAction.modify_groups, user_id=user.id)
+ )
+ if not user.next_plan:
+ self.button(
+ text=Texts.activate_next_plan,
+ callback_data=ConfirmAction.Callback(
+ action=self.Callback(action=UserPanelAction.activate_next_plan, user_id=user.id).pack(),
+ cancel=self.Callback(user_id=user.id).pack(),
+ ),
+ )
+
+ self.button(
+ text=Texts.modify_with_template,
+ callback_data=self.Callback(action=UserPanelAction.modify_with_template, user_id=user.id),
+ )
+
+ self.button(text=Texts.subscription_url, copy_text=CopyTextButton(text=user.subscription_url))
+
+ self.button(
+ text=Texts.back,
+ callback_data=CancelKeyboard.Callback(action=CancelAction.cancel),
+ )
+
+ self.adjust(2, 2, 2, 2, 1, 1)
+
+
+class ChooseStatus(InlineKeyboardBuilder):
+ class Callback(CallbackData, prefix="user"):
+ status: str
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.button(text=Texts.on_hold, callback_data=self.Callback(status=UserStatus.on_hold.value))
+ self.button(text=Texts.enable, callback_data=self.Callback(status=UserStatus.active.value))
+ self.adjust(2, repeat=True)
+
+
+class ChooseTemplate(InlineKeyboardBuilder):
+ class Callback(CallbackData, prefix="choose_template"):
+ template_id: int
+ user_id: int = 0 # in case choose template for modify
+
+ def __init__(self, templates: List[UserTemplate], user_id: int = 0, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ for template in templates:
+ self.button(
+ text=template.name,
+ callback_data=self.Callback(template_id=template.id, user_id=user_id).pack(),
+ )
+
+ self.button(
+ text=Texts.back,
+ callback_data=UserPanel.Callback(user_id=user_id).pack(),
+ )
+ self.adjust(1, repeat=True)
+
+
+class RandomUsername(InlineKeyboardBuilder):
+ class Callback(CallbackData, prefix="random_username"):
+ with_template: bool = False
+
+ def __init__(self, with_template: bool = False, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ self.button(text=Texts.random_username, callback_data=self.Callback(with_template=with_template))
+ self.button(
+ text=Texts.back,
+ callback_data=CancelKeyboard.Callback(action=CancelAction.cancel),
+ )
+ self.adjust(1, repeat=True)
diff --git a/app/telegram/middlewares/__init__.py b/app/telegram/middlewares/__init__.py
new file mode 100644
index 000000000..c4936c2d9
--- /dev/null
+++ b/app/telegram/middlewares/__init__.py
@@ -0,0 +1,9 @@
+from aiogram import Dispatcher
+from aiogram.utils.chat_action import ChatActionMiddleware
+
+from .acl import ACLMiddleware
+
+
+def setup_middlewares(dp: Dispatcher) -> None:
+ dp.update.outer_middleware(ACLMiddleware())
+ dp.message.middleware.register(ChatActionMiddleware())
diff --git a/app/telegram/middlewares/acl.py b/app/telegram/middlewares/acl.py
new file mode 100644
index 000000000..631166b61
--- /dev/null
+++ b/app/telegram/middlewares/acl.py
@@ -0,0 +1,36 @@
+from typing import Any, Awaitable, Callable, Dict
+
+from aiogram import BaseMiddleware
+from aiogram.types import Update
+
+from app.db import GetDB
+from app.db.crud.admin import get_admin_by_telegram_id
+from app.models.admin import AdminDetails
+from app.settings import telegram_settings
+from app.models.settings import Telegram
+
+
+class ACLMiddleware(BaseMiddleware):
+ async def __call__(
+ self, handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]], event: Update, data: Dict[str, Any]
+ ) -> Any:
+ message_obj = event.message or event.callback_query or event.inline_query
+ user_id = message_obj.from_user.id
+ async with GetDB() as db:
+ settings: Telegram = await telegram_settings()
+ admin = await get_admin_by_telegram_id(db, user_id)
+ if admin:
+ if admin.is_disabled:
+ if settings.for_admins_only:
+ return
+ data["admin"] = None
+ else:
+ admin = AdminDetails.model_validate(admin)
+ data["admin"] = admin
+ else:
+ if settings.for_admins_only:
+ return
+ data["admin"] = None
+
+ data["db"] = db
+ return await handler(event, data)
diff --git a/app/telegram/utils/filters.py b/app/telegram/utils/filters.py
new file mode 100644
index 000000000..b3fda4272
--- /dev/null
+++ b/app/telegram/utils/filters.py
@@ -0,0 +1,13 @@
+from aiogram.filters import Filter
+
+from app.models.admin import AdminDetails
+
+
+class IsAdminFilter(Filter):
+ async def __call__(self, _, admin: AdminDetails | None = None) -> bool:
+ return bool(admin)
+
+
+class IsAdminSUDO(Filter):
+ async def __call__(self, _, admin: AdminDetails | None = None) -> bool:
+ return admin.is_sudo if admin else False
diff --git a/app/telegram/utils/forms.py b/app/telegram/utils/forms.py
new file mode 100644
index 000000000..7531ee519
--- /dev/null
+++ b/app/telegram/utils/forms.py
@@ -0,0 +1,29 @@
+from aiogram.fsm.state import State, StatesGroup
+
+
+class CreateUser(StatesGroup):
+ username = State()
+ data_limit = State()
+ expire = State()
+ status = State()
+ on_hold_timeout = State()
+ group_ids = State()
+
+
+class CreateUserFromTemplate(StatesGroup):
+ username = State()
+
+
+class DeleteExpired(StatesGroup):
+ expired_before = State()
+
+
+class BulkModify(StatesGroup):
+ expiry = State()
+ data_limit = State()
+
+
+class ModifyUser(StatesGroup):
+ new_data_limit = State()
+ new_expiry = State()
+ new_note = State()
diff --git a/app/telegram/utils/shared.py b/app/telegram/utils/shared.py
new file mode 100644
index 000000000..aaaa22d8a
--- /dev/null
+++ b/app/telegram/utils/shared.py
@@ -0,0 +1,39 @@
+import math
+from typing import List
+from aiogram.exceptions import TelegramAPIError
+from aiogram.fsm.context import FSMContext
+from aiogram.types import CallbackQuery, Message
+
+
+def readable_size(size_bytes: int):
+ if int(size_bytes) == 0:
+ return "0 Bytes"
+
+ is_negative = False
+ if size_bytes < 0:
+ size_bytes = size_bytes * -1
+ is_negative = True
+
+ size_name = ("Bytes", "KB", "MB", "GB", "TB", "PT")
+ i = int(math.floor(math.log(size_bytes, 1024)))
+ s = round(size_bytes / (1024**i), 1)
+ return f"{'-' if is_negative else ''}{int(s) if s.is_integer() else s} {size_name[i]}"
+
+
+async def add_to_messages_to_delete(state: FSMContext, *messages: Message):
+ messages_to_delete = await state.get_value("messages_to_delete", [])
+ for message in messages:
+ messages_to_delete.append(message.message_id)
+ await state.update_data(messages_to_delete=messages_to_delete)
+
+
+async def delete_messages(event: Message | CallbackQuery, state: FSMContext = None, message_ids: List[int] = None):
+ message_ids = message_ids or []
+ if state:
+ message_ids += await state.get_value("messages_to_delete", [])
+
+ chat_id = event.chat.id if isinstance(event, Message) else event.message.chat.id
+ try:
+ await event.bot.delete_messages(chat_id, message_ids)
+ except TelegramAPIError:
+ pass
diff --git a/app/telegram/utils/texts.py b/app/telegram/utils/texts.py
new file mode 100644
index 000000000..6610d1986
--- /dev/null
+++ b/app/telegram/utils/texts.py
@@ -0,0 +1,263 @@
+from aiogram.utils.formatting import html_decoration
+
+from app.models.group import Group
+from app.models.user import UserResponse, UserStatus
+from app.models.system import SystemStats
+from app.telegram.utils.shared import readable_size
+from app.subscription.share import STATUS_EMOJIS
+
+from datetime import datetime as dt, timedelta as td, timezone as tz
+from html import escape
+
+
+b = html_decoration.bold
+c = html_decoration.code
+i = html_decoration.italic
+u = html_decoration.underline
+ln = html_decoration.link
+p = html_decoration.pre
+pl = html_decoration.pre_language
+sp = html_decoration.spoiler
+st = html_decoration.strikethrough
+bl = html_decoration.blockquote
+ebl = html_decoration.expandable_blockquote
+
+
+class Button:
+ modify_groups = "👥 Modify Groups"
+ subscription_url = "🔗 Subscription URL"
+ modify_note = "📝 Modify Note"
+ random_username = "🎲 Random Username"
+ modify_data_limit = "📶 Modify Data Limit"
+ modify_expiry = "📅 Modify Expiry"
+ delete_expired = "⌛ Delete Expired"
+ bulk_actions = "🔧 Bulk Actions"
+ open_panel = "🎛 Open Panel"
+ done = "✅ Done"
+ search = "🔎 Search"
+ enable = "✅ Enable"
+ disable = "❌ Disable"
+ revoke_sub = "📵 Revoke Sub"
+ reset_usage = "🔄 Reset Usage"
+ delete = "🗑 Delete"
+ activate_next_plan = "☑ Activate Next Plan"
+ confirm = "✅ Confirm"
+ cancel = "❌ Cancel"
+ create_user = "👤 Create User"
+ create_user_from_template = "👤 Create User From Template"
+ modify_with_template = "📦 Modify with Template"
+ sync_users = "🔄 Sync Users"
+ refresh_data = "♻ Refresh"
+ users = "👥 Users"
+ on_hold = "🔘 On-Hold"
+ back = "🔙 Back"
+
+
+class Message:
+ enter_modify_note = "📝 Enter new Note:"
+ enter_modify_data_limit = "📶 Enter new Data Limit (GB):\nSend 0 for unlimited."
+ enter_modify_expiry = "📅 Enter new Expiry (days):\nSend 0 for unlimited."
+ enter_bulk_data_limit = "📶 Enter data limit change (GB):\nPositive and Negative values are allowed."
+ enter_bulk_expiry = "📅 Enter Expiry change (days):\nPositive and Negative values are allowed."
+ enter_expire_before = "📅 Delete Users expired before (days):\nSend 0 for all."
+ choose_action = "🔧 Choose an Action:"
+ there_is_no_template = "❌ There is no Template!"
+ user_not_found = "❌ User not found!"
+ confirm = "⚠ Are you sure you want to proceed?"
+ enter_username = "🗣 Enter new user's Username:"
+ username_already_exist = "❌ Username already exists."
+ enter_data_limit = "🌐 Enter Data Limit (GB):\nSend 0 for unlimited."
+ data_limit_not_valid = "❌ Data limit is not valid."
+ enter_duration = "📅 Enter duration (days):\nSend 0 for unlimited."
+ duration_not_valid = "❌ Duration is not valid."
+ choose_status = "Do you want to enable it or keep it on-hold?"
+ enter_on_hold_timeout = "🔌 Enter On-Hold timeout duration (days):\nSend 0 for Never."
+ select_groups = "👥 Select Groups:"
+ select_a_group = "❌ You have to select at least one group."
+ canceled = "💢 Operation Canceled"
+ user_created = "✅ User created successfully"
+ refreshed = "♻ Refreshed successfully"
+ syncing = "🔄 Syncing..."
+ synced = "✅ Users successfully Synced"
+ choose_a_template = "📦 Choose a Template:"
+
+ @staticmethod
+ def start(stats: SystemStats):
+ memory_percentage = int(stats.mem_used / stats.mem_total * 100)
+ return f"""\
+⚙ {b("PasarGuard Version")}: {c(stats.version)}
+
+📊 {b("CPU Usage")}: {c(stats.cpu_usage)} %
+🎛 {b("CPU Cores")}: {c(stats.cpu_cores)}
+📈 {b("Memory")}: {c(readable_size(stats.mem_used))} / {c(readable_size(stats.mem_total))} ({c(memory_percentage)} %)
+🌐 {b("Total Data Usage")}: {c(readable_size(stats.outgoing_bandwidth + stats.incoming_bandwidth))}
+
+👥 {b("Total Users")}: {c(stats.total_user)}
+🟢 {b("Online Users")}: {c(stats.online_users)}
+🔘 {b("Active Users")}: {c(stats.active_users)}
+🔌 {b("On-Hold Users")}: {c(stats.on_hold_users)}
+⌛ {b("Expired Users")}: {c(stats.expired_users)}
+🪫 {b("Limited Users")}: {c(stats.limited_users)}
+🔴 {b("Disabled Users")}: {c(stats.disabled_users)}
+"""
+
+ @staticmethod
+ def status_emoji(status: UserStatus):
+ return STATUS_EMOJIS[status.value]
+
+ @staticmethod
+ def user_details(user: UserResponse, groups: list[Group]) -> str:
+ data_limit = c(readable_size(user.data_limit)) if user.data_limit else "∞"
+ used_traffic = c(readable_size(user.used_traffic))
+ expire = user.expire.strftime("%Y-%m-%d %H:%M") if user.expire else "∞"
+ days_left = (user.expire - dt.now(tz.utc)).days if user.expire else "∞"
+ on_hold_timeout = user.on_hold_timeout.strftime("%Y-%m-%d %H:%M") if user.on_hold_timeout else "-"
+ on_hold_expire_duration = td(seconds=user.on_hold_expire_duration).days if user.on_hold_expire_duration else "0"
+ online_at = bl(user.online_at.strftime("%Y-%m-%d %H:%M:%S")) if user.online_at else "-"
+ admin = ln(user.admin.username, f"tg://user?id={user.admin.telegram_id}")
+ note = bl(escape(user.note)) if user.note else "-"
+ emojy_status = Message.status_emoji(user.status)
+ groups = ", ".join([g.name for g in groups])
+
+ if user.status == UserStatus.on_hold:
+ expire_text = f"{b('On Hold Duration: ')} {c(on_hold_expire_duration)} days\n"
+ expire_text += f"{b('On Hold Timeout:')} {c(on_hold_timeout)}"
+ else:
+ expire_text = f"{b('Expire: ')} {c(expire)}\n"
+ expire_text += f"{b('Days left: ')} {c(days_left)}"
+
+ return f"""\
+👤 {b("User Information")}
+
+{b("Status:")} {emojy_status} {user.status.value.replace("_", " ").title()}
+{b("Username:")} {c(user.username)}
+
+{b("Data Limit:")} {data_limit}
+{b("Used Traffic:")} {used_traffic}
+{b("Data Limit Strategy:")} {user.data_limit_reset_strategy.value.replace("_", " ").title()}
+{expire_text}
+{b("Online At:")} {online_at}
+{b("Groups:")} {c(groups)}
+{b("Admin:")} {admin}
+{b("Note:")} {note}"""
+
+ @staticmethod
+ def user_short_detail(user: UserResponse) -> str:
+ data_limit = readable_size(user.data_limit) if user.data_limit else "∞"
+ used_traffic = readable_size(user.used_traffic)
+ if user.status == UserStatus.on_hold:
+ expiry = int(user.on_hold_expire_duration / 24 / 60 / 60)
+ else:
+ expiry = (user.expire - dt.now(tz.utc)).days if user.expire else "∞"
+ return f"{used_traffic} / {data_limit} | {expiry} days\n{user.note or ''}"
+
+ @classmethod
+ def client_user_details(cls, user: UserResponse) -> str:
+ data_limit = c(readable_size(user.data_limit)) if user.data_limit else "∞"
+ used_traffic = c(readable_size(user.used_traffic))
+ expire = user.expire.strftime("%Y-%m-%d %H:%M") if user.expire else "∞"
+ days_left = (user.expire - dt.now(tz.utc)).days if user.expire else "∞"
+ online_at = bl(user.online_at.strftime("%Y-%m-%d %H:%M:%S")) if user.online_at else "-"
+ emojy_status = cls.status_emoji(user.status)
+
+ return f"""\
+👤 {b("User Information")}
+
+{b("Status:")} {emojy_status} {user.status.value.replace("_", " ").title()}
+{b("Username:")} {c(user.username)}
+{b("Data Limit:")} {data_limit}
+{b("Used Traffic:")} {used_traffic}
+{b("Data Limit Strategy:")} {user.data_limit_reset_strategy.value.replace("_", " ").title()}
+{b("Expire:")} {c(expire)}
+{b("Days left:")} {c(days_left)}
+{b("Online At:")} {online_at}
+{b("Subscription URL:")}
+{p(user.subscription_url)}
+"""
+
+ @staticmethod
+ def confirm_disable_user(username: str) -> str:
+ return f"⚠ Are you sure you want to {b('Disable')} {c(username)}?"
+
+ @staticmethod
+ def confirm_enable_user(username: str) -> str:
+ return f"⚠ Are you sure you want to {b('Enable')} {c(username)}?"
+
+ @staticmethod
+ def confirm_delete_user(username: str) -> str:
+ return f"⚠ Are you sure you want to {b('Delete')} {c(username)}?"
+
+ @staticmethod
+ def confirm_revoke_sub(username: str) -> str:
+ return f"⚠ Are you sure you want to {b('Revoke Subscription')} of {c(username)}?"
+
+ @staticmethod
+ def confirm_reset_usage(username: str) -> str:
+ return f"⚠ Are you sure you want to {b('Reset Usage')} of {c(username)}?"
+
+ @staticmethod
+ def confirm_activate_next_plan(username: str) -> str:
+ return f"⚠ Are you sure you want to {b('Activate Next Plan')} for {c(username)}?"
+
+ @classmethod
+ def confirm_delete_expired(cls, expired_before_days: int | str) -> str:
+ return f"⚠ Are you sure you want to delete all users expired before {expired_before_days} days ago?"
+
+ @staticmethod
+ def user_disabled(username: str) -> str:
+ return f"✅ {username} has been successfully disabled."
+
+ @staticmethod
+ def user_enabled(username: str) -> str:
+ return f"✅ {username} has been successfully enabled."
+
+ @staticmethod
+ def user_deleted(username: str) -> str:
+ return f"✅ {username} has been successfully deleted."
+
+ @staticmethod
+ def user_sub_revoked(username: str) -> str:
+ return f"✅ {username}'s subscription has been successfully revoked."
+
+ @staticmethod
+ def user_reset_usage(username: str) -> str:
+ return f"✅ {username}'s usage has been successfully reset."
+
+ @staticmethod
+ def user_next_plan_activated(username: str) -> str:
+ return f"✅ {username}'s next plan has been successfully activated."
+
+ @classmethod
+ def users_deleted(cls, count):
+ return f"✅ {count} users successfully deleted."
+
+ @classmethod
+ def confirm_modify_expiry(cls, days: int) -> str:
+ if days > 0:
+ return f"⚠ Are you sure you want to extend users expiry by {c(days)} days?"
+ else:
+ return f"⚠ Are you sure you want to subtract {c(abs(days))} days from users expiry?"
+
+ @classmethod
+ def users_expiry_changed(cls, result: int, amount: int):
+ if amount > 0:
+ return f"✅ {result} users successfully extended by {amount} days."
+ else:
+ return f"✅ {result} users successfully subtracted by {abs(amount)} days."
+
+ @classmethod
+ def confirm_modify_data_limit(cls, amount: int) -> str:
+ if amount > 0:
+ return f"⚠ Are you sure you want to increase users data limit by {c(amount)} GB?"
+ else:
+ return f"⚠ Are you sure you want to decrease users data limit by {c(abs(amount))} GB?"
+
+ @classmethod
+ def users_data_limit_changed(cls, result: int, amount: int):
+ if amount > 0:
+ return f"✅ {result} users successfully increased by {amount} GB."
+ else:
+ return f"✅ {result} users successfully decreased by {abs(amount)} GB."
+
+
+__all__ = ["Button", "Message"]
diff --git a/app/templates/__init__.py b/app/templates/__init__.py
new file mode 100644
index 000000000..02a9d0b54
--- /dev/null
+++ b/app/templates/__init__.py
@@ -0,0 +1,21 @@
+from datetime import datetime as dt, timezone as tz
+from typing import Union
+
+import jinja2
+
+from config import CUSTOM_TEMPLATES_DIRECTORY
+
+from .filters import CUSTOM_FILTERS
+
+template_directories = ["app/templates"]
+if CUSTOM_TEMPLATES_DIRECTORY:
+ # User's templates have priority over default templates
+ template_directories.insert(0, CUSTOM_TEMPLATES_DIRECTORY)
+
+env = jinja2.Environment(loader=jinja2.FileSystemLoader(template_directories))
+env.filters.update(CUSTOM_FILTERS)
+env.globals["now"] = lambda: dt.now(tz.utc)
+
+
+def render_template(template: str, context: Union[dict, None] = None) -> str:
+ return env.get_template(template).render(context or {})
diff --git a/app/templates/clash/README.md b/app/templates/clash/README.md
new file mode 100644
index 000000000..c8751d3d6
--- /dev/null
+++ b/app/templates/clash/README.md
@@ -0,0 +1,42 @@
+# Clash Template
+
+## Usage
+
+- Can be used to send completely prepared config to users depend on your usage.
+
+## Config Template
+
+- With the config template, you can change things like routing and rules.
+
+## How To Use
+
+First of all, you need to set a directory for all of your templates (home, subscription page, etc.).
+
+```shell
+CUSTOM_TEMPLATES_DIRECTORY="/var/lib/marzban/templates/"
+```
+
+Make sure you put all of your templates in this folder.\
+If you are using Docker, make sure Docker has access to this folder.\
+Then, we need to make a directory for our Clash template.
+
+```shell
+mkdir /var/lib/marzban/templates/v2ray
+```
+
+After that, put your templates (config and settings) in the directory.\
+Now, change these variables with your files' names.
+
+```shell
+CLASH_SUBSCRIPTION_TEMPLATE = "clash/default.yml")
+```
+
+Now, restart your Marzban and enjoy.
+
+If you have already changed your env variables, and you want to just update the template files, there is no need to restart Marzban.
+
+## Docs
+
+you can use these docs to find out how to modify template files
+
+[Mihomo Docs](https://wiki.metacubex.one/en/)
diff --git a/app/templates/clash/default.yml b/app/templates/clash/default.yml
new file mode 100644
index 000000000..bb7126db3
--- /dev/null
+++ b/app/templates/clash/default.yml
@@ -0,0 +1,13 @@
+mode: Global
+port: 7890
+
+{{ conf | except("proxy-groups", "port", "mode") | yaml }}
+
+proxy-groups:
+- name: '♻️ Automatic'
+ type: 'url-test'
+ url: 'http://www.gstatic.com/generate_204'
+ interval: 300
+ proxies:
+ {{ proxy_remarks | yaml | indent(2) }}
+{{ conf.get("proxy-groups", []) | yaml }}
diff --git a/app/templates/filters.py b/app/templates/filters.py
new file mode 100644
index 000000000..b9b01da2c
--- /dev/null
+++ b/app/templates/filters.py
@@ -0,0 +1,41 @@
+import os
+from datetime import datetime
+
+import yaml
+
+from app.utils.system import readable_size
+
+
+def to_yaml(obj):
+ if not obj:
+ return ""
+
+ return yaml.dump(obj, allow_unicode=True, indent=2)
+
+
+def exclude_keys(obj, *target_keys):
+ return {key: val for key, val in obj.items() if key not in target_keys}
+
+
+def only_keys(obj, *target_keys):
+ return {key: val for key, val in obj.items() if key in target_keys}
+
+
+def datetimeformat(dt):
+ if isinstance(dt, int):
+ dt = datetime.fromtimestamp(dt)
+ formatted_datetime = dt.strftime("%Y-%m-%d %H:%M:%S")
+ return formatted_datetime
+
+
+def env_override(value, key):
+ return os.getenv(key, value)
+
+
+CUSTOM_FILTERS = {
+ "yaml": to_yaml,
+ "except": exclude_keys,
+ "only": only_keys,
+ "datetime": datetimeformat,
+ "bytesformat": readable_size,
+}
diff --git a/app/templates/home/index.html b/app/templates/home/index.html
new file mode 100644
index 000000000..1bb31bc69
--- /dev/null
+++ b/app/templates/home/index.html
@@ -0,0 +1,1942 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/templates/singbox/README.md b/app/templates/singbox/README.md
new file mode 100644
index 000000000..d73ee8be8
--- /dev/null
+++ b/app/templates/singbox/README.md
@@ -0,0 +1,42 @@
+# Sing-box Template
+
+## Usage
+
+- Can be used to send completely prepared config to users depend on your usage.
+
+## Config Template
+
+- With the config template, you can change things like routing and rules.
+
+## How To Use
+
+First of all, you need to set a directory for all of your templates (home, subscription page, etc.).
+
+```shell
+CUSTOM_TEMPLATES_DIRECTORY="/var/lib/marzban/templates/"
+```
+
+Make sure you put all of your templates in this folder.\
+If you are using Docker, make sure Docker has access to this folder.\
+Then, we need to make a directory for our Sing-box template.
+
+```shell
+mkdir /var/lib/marzban/templates/sing-box
+```
+
+After that, put your templates (config and settings) in the directory.\
+Now, change these variables with your files' names.
+
+```shell
+SINGBOX_SUBSCRIPTION_TEMPLATE="singbox/default.json"
+```
+
+Now, restart your Marzban and enjoy.
+
+If you have already changed your env variables, and you want to just update the template files, there is no need to restart Marzban.
+
+## Docs
+
+you can use sing-box official documentation to find out how to modify template files
+
+[Sing-Box documentation](https://sing-box.sagernet.org/configuration/)
diff --git a/app/templates/singbox/default.json b/app/templates/singbox/default.json
new file mode 100644
index 000000000..e5594628d
--- /dev/null
+++ b/app/templates/singbox/default.json
@@ -0,0 +1,85 @@
+{
+ "log": {
+ "level": "warn",
+ "timestamp": false
+ },
+ "dns": {
+ "servers": [
+ {
+ "tag": "dns-remote",
+ "address": "1.1.1.2",
+ "detour": "proxy"
+ },
+ {
+ "tag": "dns-local",
+ "address": "local",
+ "detour": "direct"
+ }
+ ],
+ "rules": [
+ {
+ "outbound": "any",
+ "server": "dns-local"
+ }
+ ],
+ "final": "dns-remote"
+ },
+ "inbounds": [
+ {
+ "type": "tun",
+ "tag": "tun-in",
+ "interface_name": "sing-tun",
+ "address": [
+ "172.19.0.1/30",
+ "fdfe:dcba:9876::1/126"
+ ],
+ "auto_route": true,
+ "route_exclude_address": [
+ "192.168.0.0/16",
+ "10.0.0.0/8",
+ "169.254.0.0/16",
+ "172.16.0.0/12",
+ "fe80::/10",
+ "fc00::/7"
+ ]
+ }
+ ],
+ "outbounds": [
+ {
+ "type": "selector",
+ "tag": "proxy",
+ "outbounds": null,
+ "interrupt_exist_connections": true
+ },
+ {
+ "type": "urltest",
+ "tag": "Best Latency",
+ "outbounds": null
+ },
+ {
+ "type": "direct",
+ "tag": "direct"
+ }
+ ],
+ "route": {
+ "rules": [
+ {
+ "inbound": "tun-in",
+ "action": "sniff"
+ },
+ {
+ "protocol": "dns",
+ "action": "hijack-dns"
+ }
+ ],
+ "final": "proxy",
+ "auto_detect_interface": true,
+ "override_android_vpn": true
+ },
+ "experimental": {
+ "cache_file": {
+ "enabled": true,
+ "store_rdrc": true
+ }
+ }
+}
diff --git a/app/templates/subscription/index.html b/app/templates/subscription/index.html
new file mode 100644
index 000000000..d5ab9d210
--- /dev/null
+++ b/app/templates/subscription/index.html
@@ -0,0 +1,203 @@
+
+
+
+ Subscription Information
+
+
+
+
+
+
User Information
+
Username: {{ user.username }}
+
+ Status:
+ {{ user.status.value }}
+
+
+ Data Limit: {% if not user.data_limit %}∞{% else %}{{
+ user.data_limit | bytesformat }}{% endif %}
+
+
+ Data Used: {{ user.used_traffic | bytesformat }}{% if
+ user.data_limit_reset_strategy != 'no_reset' %} (resets every {{
+ user.data_limit_reset_strategy.value }}){% endif %}
+
+
+ {% if user.status.value == 'on_hold' %}
+ On Hold For: {% if not user.on_hold_expire_duration %}∞{% else %}{{ (user.on_hold_expire_duration / 86400) | int }} days{% endif %}
+
+ On Hold Until: {% if user.on_hold_timeout %}{{ user.on_hold_timeout | datetime }}{% else %}∞{% endif %}
+
+ {% else %}
+ Expiration Date: {% if not user.expire %} ∞ {% else %} {% set
+ current_datetime = now() %} {% set remaining_days = ((user.expire -
+ current_datetime).days) %} {{ user.expire | datetime }} ({{
+ remaining_days | int }} days remaining) {% endif %}
+ {% endif %}
+
+
+ {% if user.status in ('active','on_hold') %}
+
Links:
+
+ {% for link in links %}
+
+
+
+
+
+ {% endfor %}
+
+
+
+
+
+
+
+
+ {% endif %}
+
+
+
+
diff --git a/app/templates/user_agent/default.json b/app/templates/user_agent/default.json
new file mode 100644
index 000000000..abb85644c
--- /dev/null
+++ b/app/templates/user_agent/default.json
@@ -0,0 +1,104 @@
+{
+ "list":[
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 Edg/125.0.0.0",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Safari/537.36 PageSpeedPlus/1.0.0",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0",
+ "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Mobile Safari/537.36",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0",
+ "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 11.6; rv:92.0) Gecko/20100101 Firefox/92.0",
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
+ "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) obsidian/1.4.14 Chrome/114.0.5735.289 Electron/25.8.1 Safari/537.36",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:126.0) Gecko/20100101 Firefox/126.0",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15",
+ "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36",
+ "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36",
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
+ "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/25.0 Chrome/121.0.0.0 Mobile Safari/537.36",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
+ "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:125.0) Gecko/20100101 Firefox/125.0",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) obsidian/1.4.16 Chrome/114.0.5735.289 Electron/25.8.1 Safari/537.36",
+ "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.114 Safari/537.36",
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
+ "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) obsidian/1.5.3 Chrome/114.0.5735.289 Electron/25.8.1 Safari/537.36",
+ "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15",
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) obsidian/1.5.12 Chrome/120.0.6099.283 Electron/28.2.3 Safari/537.36",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) obsidian/1.4.16 Chrome/114.0.5735.289 Electron/25.8.1 Safari/537.36",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36",
+ "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 OPR/109.0.0.0",
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.129 Safari/537.36",
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
+ "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Mobile Safari/537.36",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Safari/605.1.15",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) obsidian/1.4.13 Chrome/114.0.5735.289 Electron/25.8.1 Safari/537.36",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15",
+ "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0",
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1",
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Mobile/15E148 Safari/604.1",
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1",
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Safari/605.1.15",
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) obsidian/1.4.13 Chrome/114.0.5735.289 Electron/25.8.1 Safari/537.36",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15",
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15",
+ "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.1 Mobile/15E148 Safari/604.1",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0",
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) obsidian/1.5.12 Chrome/120.0.6099.283 Electron/28.2.3 Safari/537.36",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.2 Safari/605.1.15",
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1",
+ "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36 Edg/121.0.0.0",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:124.0) Gecko/20100101 Firefox/124.0",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) obsidian/1.5.3 Chrome/114.0.5735.289 Electron/25.8.1 Safari/537.36",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.67 Safari/537.36",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Safari/605.1.15",
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0",
+ "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Mobile Safari/537.36",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36",
+ "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/24.0 Chrome/117.0.0.0 Mobile Safari/537.36"
+ ]
+}
\ No newline at end of file
diff --git a/app/templates/user_agent/grpc.json b/app/templates/user_agent/grpc.json
new file mode 100644
index 000000000..821671ea4
--- /dev/null
+++ b/app/templates/user_agent/grpc.json
@@ -0,0 +1,20 @@
+{
+ "list": [
+ "grpc-dotnet/2.41.0 (.NET 6.0.1; CLR 6.0.1; net6.0; windows; x64)",
+ "grpc-dotnet/2.41.0 (.NET 6.0.0-preview.7.21377.19; CLR 6.0.0; net6.0; osx; x64)",
+ "grpc-dotnet/2.41.0 (Mono 6.12.0.140; CLR 4.0.30319; netstandard2.0; osx; x64)",
+ "grpc-dotnet/2.41.0 (.NET 6.0.0-rc.1.21380.1; CLR 6.0.0; net6.0; linux; arm64)",
+ "grpc-dotnet/2.41.0 (.NET 5.0.8; CLR 5.0.8; net5.0; linux; arm64)",
+ "grpc-dotnet/2.41.0 (.NET Core; CLR 3.1.4; netstandard2.1; linux; arm64)",
+ "grpc-dotnet/2.41.0 (.NET Framework; CLR 4.0.30319.42000; netstandard2.0; windows; x86)",
+ "grpc-dotnet/2.41.0 (.NET 6.0.0-rc.1.21380.1; CLR 6.0.0; net6.0; windows; x64)",
+ "grpc-python-asyncio/1.62.1 grpc-c/39.0.0 (linux; chttp2)",
+ "grpc-go/1.58.1",
+ "grpc-java-okhttp/1.55.1",
+ "grpc-node/1.7.1 grpc-c/1.7.1 (osx; chttp2)",
+ "grpc-node/1.24.2 grpc-c/8.0.0 (linux; chttp2; ganges)",
+ "grpc-c++/1.16.0 grpc-c/6.0.0 (linux; nghttp2; hw)",
+ "grpc-node/1.19.0 grpc-c/7.0.0 (linux; chttp2; gold)",
+ "grpc-ruby/1.62.0 grpc-c/39.0.0 (osx; chttp2)]"
+ ]
+}
\ No newline at end of file
diff --git a/app/templates/xray/README.md b/app/templates/xray/README.md
new file mode 100644
index 000000000..ef2403cdb
--- /dev/null
+++ b/app/templates/xray/README.md
@@ -0,0 +1,44 @@
+# V2ray Template
+
+## Usage
+
+- Can be used to send completely prepared config to users and avoid application default values.
+
+## Config Template
+
+- With the config template, you can change things like routing and rules.
+
+## How To Use
+
+First of all, you need to set a directory for all of your templates (home, subscription page, etc.).
+
+```shell
+CUSTOM_TEMPLATES_DIRECTORY="/var/lib/marzban/templates/"
+```
+
+Make sure you put all of your templates in this folder.\
+If you are using Docker, make sure Docker has access to this folder.\
+Then, we need to make a directory for our V2ray template.
+
+```shell
+mkdir /var/lib/marzban/templates/v2ray
+```
+
+After that, put your templates (config and settings) in the directory.\
+Now, change these variables with your files' names.
+
+```shell
+V2RAY_SUBSCRIPTION_TEMPLATE="v2ray/default.json"
+```
+
+Now, restart your Marzban and enjoy.
+
+If you have already changed your env variables, and you want to just update the template files, there is no need to restart Marzban.
+
+## Docs
+
+you can use these docs to find out how to modify template files
+
+[Xray Docs](https://xtls.github.io/en/) \
+[Xray Examples](https://github.com/XTLS/Xray-examples) \
+[Xray Examples](https://github.com/chika0801/Xray-examples) Unofficial
diff --git a/app/templates/xray/default.json b/app/templates/xray/default.json
new file mode 100644
index 000000000..d842a9f79
--- /dev/null
+++ b/app/templates/xray/default.json
@@ -0,0 +1,58 @@
+{
+ "log": {
+ "access": "",
+ "error": "",
+ "loglevel": "warning"
+ },
+ "inbounds": [
+ {
+ "tag": "socks",
+ "port": 10808,
+ "listen": "0.0.0.0",
+ "protocol": "socks",
+ "sniffing": {
+ "enabled": true,
+ "destOverride": [
+ "http",
+ "tls"
+ ],
+ "routeOnly": false
+ },
+ "settings": {
+ "auth": "noauth",
+ "udp": true,
+ "allowTransparent": false
+ }
+ },
+ {
+ "tag": "http",
+ "port": 10809,
+ "listen": "0.0.0.0",
+ "protocol": "http",
+ "sniffing": {
+ "enabled": true,
+ "destOverride": [
+ "http",
+ "tls"
+ ],
+ "routeOnly": false
+ },
+ "settings": {
+ "auth": "noauth",
+ "udp": true,
+ "allowTransparent": false
+ }
+ }
+ ],
+ "outbounds": [],
+ "dns": {
+ "servers": [
+ "1.1.1.1",
+ "8.8.8.8"
+ ]
+ },
+ "routing": {
+ "domainStrategy": "AsIs",
+ "rules": []
+ }
+}
\ No newline at end of file
diff --git a/app/utils/crypto.py b/app/utils/crypto.py
new file mode 100644
index 000000000..ea5448070
--- /dev/null
+++ b/app/utils/crypto.py
@@ -0,0 +1,76 @@
+import base64
+import binascii
+
+from cryptography import x509
+from cryptography.hazmat.backends import default_backend
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric import x25519
+from OpenSSL import crypto
+
+
+def get_cert_SANs(cert: bytes):
+ cert = x509.load_pem_x509_certificate(cert, default_backend())
+ san_list = []
+ for extension in cert.extensions:
+ if isinstance(extension.value, x509.SubjectAlternativeName):
+ san = extension.value
+ for name in san:
+ san_list.append(name.value)
+ return san_list
+
+
+def generate_certificate():
+ k = crypto.PKey()
+ k.generate_key(crypto.TYPE_RSA, 4096)
+ cert = crypto.X509()
+ cert.get_subject().CN = "Gozargah"
+ cert.gmtime_adj_notBefore(0)
+ cert.gmtime_adj_notAfter(100 * 365 * 24 * 60 * 60)
+ cert.set_issuer(cert.get_subject())
+ cert.set_pubkey(k)
+ cert.sign(k, "sha512")
+ cert_pem = crypto.dump_certificate(crypto.FILETYPE_PEM, cert).decode("utf-8")
+ key_pem = crypto.dump_privatekey(crypto.FILETYPE_PEM, k).decode("utf-8")
+
+ return {"cert": cert_pem, "key": key_pem}
+
+
+def add_base64_padding(b64_string: str) -> str:
+ """Adds missing Base64 padding if necessary."""
+ missing_padding = len(b64_string) % 4
+ return b64_string + ("=" * (4 - missing_padding)) if missing_padding else b64_string
+
+
+def get_x25519_public_key(private_key_b64: str) -> str:
+ """
+ Converts an X25519 private key (URL-safe Base64) into a public key (URL-safe Base64 format).
+
+ :param private_key_b64: The private key in URL-safe Base64 format (without padding).
+ :return: The corresponding public key as a URL-safe Base64 string (without padding).
+ """
+ try:
+ # Decode Base64 (URL-safe) Add padding if needed
+ private_key_bytes = base64.urlsafe_b64decode(add_base64_padding(private_key_b64))
+
+ # Ensure the private key is 32 bytes
+ if len(private_key_bytes) != 32:
+ raise ValueError("Invalid private key length. Must be 32 bytes after decoding.")
+
+ # Load the private key
+ private_key = x25519.X25519PrivateKey.from_private_bytes(private_key_bytes)
+
+ # Derive the public key
+ public_key = private_key.public_key()
+
+ # Convert the public key to bytes
+ public_key_bytes = public_key.public_bytes(
+ encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw
+ )
+
+ # Encode the public key as URL-safe Base64 (without padding)
+ public_key_b64 = base64.urlsafe_b64encode(public_key_bytes).decode().rstrip("=")
+
+ return public_key_b64
+
+ except (ValueError, binascii.Error):
+ raise ValueError("Invalid private key.")
diff --git a/app/utils/helpers.py b/app/utils/helpers.py
new file mode 100644
index 000000000..13e10b96d
--- /dev/null
+++ b/app/utils/helpers.py
@@ -0,0 +1,76 @@
+import re
+import json
+import html
+from datetime import datetime as dt, timezone as tz
+from typing import Union
+from uuid import UUID
+
+from pydantic import ValidationError
+
+
+def yml_uuid_representer(dumper, data):
+ return dumper.represent_scalar("tag:yaml.org,2002:str", str(data))
+
+
+def readable_datetime(date_time: Union[dt, int, None], include_date: bool = True, include_time: bool = True):
+ def get_datetime_format():
+ dt_format = ""
+ if include_date:
+ dt_format += "%d %B %Y"
+ if include_time:
+ if dt_format:
+ dt_format += ", "
+ dt_format += "%H:%M:%S"
+
+ return dt_format
+
+ if isinstance(date_time, int):
+ date_time = dt.fromtimestamp(date_time)
+
+ return date_time.strftime(get_datetime_format()) if date_time else "-"
+
+
+def fix_datetime_timezone(value: dt | int):
+ if isinstance(value, dt):
+ # If datetime is naive (no timezone), assume it's UTC
+ if value.tzinfo is None:
+ return value.replace(tzinfo=tz.utc)
+ # If datetime has timezone, convert to UTC
+ else:
+ return value.astimezone(tz.utc)
+ elif isinstance(value, int):
+ # Timestamp will be assume it's UTC
+ return dt.fromtimestamp(value, tz=tz.utc)
+
+ raise ValueError("input can be datetime or timestamp")
+
+
+class UUIDEncoder(json.JSONEncoder):
+ def default(self, obj):
+ if isinstance(obj, UUID):
+ # if the obj is uuid, we simply return the value of uuid
+ return str(obj)
+ return super().default(self, obj)
+
+
+def format_validation_error(error: ValidationError) -> str:
+ return "\n".join([e["loc"][0].replace("_", " ").capitalize() + ": " + e["msg"] for e in error.errors()])
+
+
+def escape_tg_html(list: tuple[str]) -> tuple[str]:
+ """Escapes HTML special characters for the telegram HTML parser."""
+ return tuple(html.escape(text) for text in list)
+
+
+def escape_ds_markdown(text: str) -> str:
+ """Escapes markdown special characters for Discord."""
+ # Other characters like >, |, [, ], (, ) are often handled by Discord's parser
+ # or are part of specific markdown constructs (e.g., links, blockquotes)
+ # that might not need general escaping.
+ escape_chars = r"[*_`~]"
+ return re.sub(escape_chars, r"\\\g<0>", text)
+
+
+def escape_ds_markdown_list(list: tuple[str]) -> tuple[str]:
+ """Escapes markdown special characters for Discord."""
+ return tuple(escape_ds_markdown(text) for text in list)
diff --git a/app/utils/jwt.py b/app/utils/jwt.py
new file mode 100644
index 000000000..54adf2c99
--- /dev/null
+++ b/app/utils/jwt.py
@@ -0,0 +1,93 @@
+import time
+import jwt
+from base64 import b64decode, b64encode
+from datetime import datetime, timedelta, timezone
+from hashlib import sha256
+from math import ceil
+
+from aiocache import cached
+from app.db import GetDB
+from app.db.crud.general import get_jwt_secret_key
+from config import JWT_ACCESS_TOKEN_EXPIRE_MINUTES
+
+
+@cached()
+async def get_secret_key():
+ async with GetDB() as db:
+ key = await get_jwt_secret_key(db=db)
+ return key
+
+
+async def create_admin_token(username: str, is_sudo=False) -> str:
+ data = {"sub": username, "access": "sudo" if is_sudo else "admin", "iat": datetime.utcnow()}
+ if JWT_ACCESS_TOKEN_EXPIRE_MINUTES > 0:
+ expire = datetime.now(timezone.utc) + timedelta(minutes=JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
+ data["exp"] = expire
+ encoded_jwt = jwt.encode(data, await get_secret_key(), algorithm="HS256")
+ return encoded_jwt
+
+
+async def get_admin_payload(token: str) -> dict | None:
+ try:
+ payload = jwt.decode(token, await get_secret_key(), algorithms=["HS256"])
+ username: str = payload.get("sub")
+ access: str = payload.get("access")
+ if not username or access not in ("admin", "sudo"):
+ return
+ try:
+ created_at = datetime.fromtimestamp(payload["iat"], tz=timezone.utc)
+ except KeyError:
+ created_at = None
+
+ return {"username": username, "is_sudo": access == "sudo", "created_at": created_at}
+ except jwt.exceptions.PyJWTError:
+ return
+
+
+async def create_subscription_token(username: str) -> str:
+ data = username + "," + str(ceil(time.time()))
+ data_b64_str = b64encode(data.encode("utf-8"), altchars=b"-_").decode("utf-8").rstrip("=")
+ data_b64_sign = b64encode(
+ sha256((data_b64_str + await get_secret_key()).encode("utf-8")).digest(), altchars=b"-_"
+ ).decode("utf-8")[:10]
+ data_final = data_b64_str + data_b64_sign
+ return data_final
+
+
+async def get_subscription_payload(token: str) -> dict | None:
+ try:
+ if len(token) < 15:
+ return
+
+ if token.startswith("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."):
+ payload = jwt.decode(token, await get_secret_key(), algorithms=["HS256"])
+ if payload.get("access") == "subscription":
+ return {
+ "username": payload["sub"],
+ "created_at": datetime.fromtimestamp(payload["iat"], tz=timezone.utc),
+ }
+ else:
+ return
+ else:
+ u_token = token[:-10]
+ u_signature = token[-10:]
+ try:
+ u_token_dec = b64decode(
+ (u_token.encode("utf-8") + b"=" * (-len(u_token.encode("utf-8")) % 4)),
+ altchars=b"-_",
+ validate=True,
+ )
+ u_token_dec_str = u_token_dec.decode("utf-8")
+ except Exception:
+ return
+ u_token_resign = b64encode(
+ sha256((u_token + await get_secret_key()).encode("utf-8")).digest(), altchars=b"-_"
+ ).decode("utf-8")[:10]
+ if u_signature == u_token_resign:
+ u_username = u_token_dec_str.split(",")[0]
+ u_created_at = int(u_token_dec_str.split(",")[1])
+ return {"username": u_username, "created_at": datetime.fromtimestamp(u_created_at, tz=timezone.utc)}
+ else:
+ return
+ except jwt.exceptions.PyJWTError:
+ return
diff --git a/app/utils/logger.py b/app/utils/logger.py
new file mode 100644
index 000000000..f81cc0fee
--- /dev/null
+++ b/app/utils/logger.py
@@ -0,0 +1,94 @@
+import logging
+from copy import copy
+from urllib.parse import unquote
+
+import click
+from uvicorn.config import LOGGING_CONFIG
+from uvicorn.logging import DefaultFormatter
+
+from config import (
+ ECHO_SQL_QUERIES,
+ LOG_BACKUP_COUNT,
+ LOG_FILE_PATH,
+ LOG_MAX_BYTES,
+ LOG_ROTATION_ENABLED,
+ LOG_ROTATION_INTERVAL,
+ LOG_ROTATION_UNIT,
+ SAVE_LOGS_TO_FILE,
+)
+
+
+class CustomLoggingFormatter(DefaultFormatter):
+ def formatMessage(self, record: logging.LogRecord) -> str:
+ recordcopy = copy(record)
+ recordcopy.__dict__["nameprefix"] = click.style(record.name.capitalize(), fg="blue")
+ return super().formatMessage(recordcopy)
+
+
+LOGGING_CONFIG["formatters"]["custom"] = {
+ "()": CustomLoggingFormatter,
+ "fmt": "%(levelprefix)s %(asctime)s - %(nameprefix)s - %(message)s",
+ "use_colors": None,
+}
+LOGGING_CONFIG["handlers"]["custom"] = {
+ "class": LOGGING_CONFIG["handlers"]["default"]["class"],
+ "formatter": "custom",
+ "stream": LOGGING_CONFIG["handlers"]["default"]["stream"],
+}
+
+LOGGING_CONFIG["formatters"]["default"]["fmt"] = "%(levelprefix)s %(asctime)s - %(message)s"
+LOGGING_CONFIG["formatters"]["access"]["fmt"] = (
+ '%(levelprefix)s %(asctime)s - %(client_addr)s - "%(request_line)s" %(status_code)s'
+)
+
+
+if SAVE_LOGS_TO_FILE:
+ if LOG_ROTATION_ENABLED:
+ LOGGING_CONFIG["handlers"]["file"] = {
+ "class": "logging.handlers.TimedRotatingFileHandler",
+ "formatter": "default",
+ "filename": LOG_FILE_PATH,
+ "interval": LOG_ROTATION_INTERVAL,
+ "when": LOG_ROTATION_UNIT,
+ "backupCount": LOG_BACKUP_COUNT,
+ }
+ else:
+ LOGGING_CONFIG["handlers"]["file"] = {
+ "class": "logging.handlers.RotatingFileHandler",
+ "formatter": "default",
+ "filename": LOG_FILE_PATH,
+ "maxBytes": LOG_MAX_BYTES,
+ "backupCount": LOG_BACKUP_COUNT,
+ }
+ LOGGING_CONFIG["loggers"]["uvicorn"]["handlers"].append("file")
+ LOGGING_CONFIG["loggers"]["uvicorn.access"]["handlers"].append("file")
+
+
+def get_logger(name: str = "uvicorn.error") -> logging.Logger:
+ if not LOGGING_CONFIG["loggers"].get(name):
+ handlers = ["custom"]
+ if SAVE_LOGS_TO_FILE:
+ handlers.append("file")
+ LOGGING_CONFIG["loggers"][name] = {
+ "handlers": handlers,
+ "level": LOGGING_CONFIG["loggers"]["uvicorn"]["level"],
+ }
+ logging.config.dictConfig(LOGGING_CONFIG)
+
+ logger = logging.getLogger(name)
+ return logger
+
+
+if ECHO_SQL_QUERIES:
+ _ = get_logger("sqlalchemy.engine")
+
+
+class EndpointFilter(logging.Filter):
+ def __init__(self, excluded_endpoints: list[str]):
+ self.excluded_endpoints = excluded_endpoints
+
+ def filter(self, record: logging.LogRecord) -> bool:
+ if record.args and len(record.args) >= 2:
+ path = unquote(record.args[2])
+ return path not in self.excluded_endpoints
+ return True
diff --git a/app/utils/responses.py b/app/utils/responses.py
new file mode 100644
index 000000000..bcd8a5fb3
--- /dev/null
+++ b/app/utils/responses.py
@@ -0,0 +1,43 @@
+"""Documented Error responses for API routes"""
+
+from pydantic import BaseModel
+
+
+class HTTPException(BaseModel):
+ detail: str
+
+
+class Unauthorized(HTTPException):
+ detail: str = "Not authenticated"
+
+
+class Forbidden(HTTPException):
+ detail: str = "You are not allowed to ..."
+
+
+class NotFound(HTTPException):
+ detail: str = "Entity {} not found"
+
+
+class Conflict(HTTPException):
+ detail: str = "Entity already exists"
+
+
+_400 = {"description": "BadRequest Error", "model": HTTPException}
+
+_401 = {
+ "description": "Unauthorized Error",
+ "model": Unauthorized,
+ "headers": {
+ "WWW-Authenticate": {
+ "description": "Authentication type",
+ "schema": {"type": "string"},
+ },
+ },
+}
+
+_403 = {"description": "Forbidden Error", "model": Forbidden}
+
+_404 = {"description": "NotFound Error", "model": NotFound}
+
+_409 = {"description": "Conflict Error", "model": Conflict}
diff --git a/app/utils/store.py b/app/utils/store.py
new file mode 100644
index 000000000..25e323bf3
--- /dev/null
+++ b/app/utils/store.py
@@ -0,0 +1,28 @@
+from sqlalchemy.ext.asyncio import AsyncSession
+
+
+class DictStorage(dict):
+ def __init__(self, update_func):
+ super().__init__()
+ self.update_func = update_func
+
+ def __getitem__(self, key):
+ return super().__getitem__(key)
+
+ def __iter__(self):
+ return super().__iter__()
+
+ def __str__(self):
+ return super().__str__()
+
+ def values(self):
+ return super().values()
+
+ def keys(self):
+ return super().keys()
+
+ def get(self, key, default=None):
+ return super().get(key, default)
+
+ async def update(self, db: AsyncSession):
+ await self.update_func(self, db)
diff --git a/app/utils/system.py b/app/utils/system.py
new file mode 100644
index 000000000..68b0be637
--- /dev/null
+++ b/app/utils/system.py
@@ -0,0 +1,114 @@
+import ipaddress
+import math
+import secrets
+import socket
+from dataclasses import dataclass
+
+import httpx
+import psutil
+
+
+@dataclass
+class MemoryStat:
+ total: int
+ used: int
+ free: int
+
+
+@dataclass
+class CPUStat:
+ cores: int
+ percent: float
+
+
+def cpu_usage() -> CPUStat:
+ return CPUStat(cores=psutil.cpu_count(), percent=psutil.cpu_percent())
+
+
+def memory_usage() -> MemoryStat:
+ mem = psutil.virtual_memory()
+ return MemoryStat(total=mem.total, used=mem.used, free=mem.available)
+
+
+def random_password() -> str:
+ return secrets.token_urlsafe(24)
+
+
+def check_port(port: int) -> bool:
+ s = socket.socket()
+ try:
+ s.connect(("127.0.0.1", port))
+ return True
+ except socket.error:
+ return False
+ finally:
+ s.close()
+
+
+def get_public_ip():
+ try:
+ resp = httpx.get("http://api4.ipify.org/", timeout=5).text.strip()
+ if ipaddress.IPv4Address(resp).is_global:
+ return resp
+ except Exception:
+ pass
+
+ try:
+ resp = httpx.get("http://ipv4.icanhazip.com/", timeout=5).text.strip()
+ if ipaddress.IPv4Address(resp).is_global:
+ return resp
+ except Exception:
+ pass
+
+ # Disable IPv6 for this request
+ transport = httpx.HTTPTransport(local_address="0.0.0.0")
+ with httpx.Client(transport=transport) as client:
+ try:
+ resp = client.get("https://ifconfig.io/ip", timeout=5).text.strip()
+ if ipaddress.IPv4Address(resp).is_global:
+ return resp
+ except httpx.RequestError:
+ pass
+
+ sock = None
+ try:
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sock.connect(("8.8.8.8", 80))
+ resp = sock.getsockname()[0]
+ if ipaddress.IPv4Address(resp).is_global:
+ return resp
+ except (socket.error, IndexError):
+ pass
+ finally:
+ if sock:
+ sock.close()
+
+ return "127.0.0.1"
+
+
+def get_public_ipv6():
+ try:
+ resp = httpx.get("http://api6.ipify.org/", timeout=5).text.strip()
+ if ipaddress.IPv6Address(resp).is_global:
+ return "[%s]" % resp
+ except Exception:
+ pass
+
+ try:
+ resp = httpx.get("http://ipv6.icanhazip.com/", timeout=5).text.strip()
+ if ipaddress.IPv6Address(resp).is_global:
+ return "[%s]" % resp
+ except Exception:
+ pass
+
+ return "[::1]"
+
+
+def readable_size(size_bytes):
+ if not size_bytes or size_bytes <= 0:
+ return "0 B"
+ size_name = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB")
+ i = int(math.floor(math.log(size_bytes, 1024)))
+ p = math.pow(1024, i)
+ s = round(size_bytes / p, 2)
+ return f"{s} {size_name[i]}"
diff --git a/build_dashboard.sh b/build_dashboard.sh
new file mode 100755
index 000000000..e750a1902
--- /dev/null
+++ b/build_dashboard.sh
@@ -0,0 +1,5 @@
+#! /bin/bash
+set -e
+cd "$(dirname "$0")/dashboard"
+VITE_BASE_API=/ bun run build
+cp ./build/index.html ./build/404.html
diff --git a/cli/README.md b/cli/README.md
new file mode 100644
index 000000000..529542f84
--- /dev/null
+++ b/cli/README.md
@@ -0,0 +1,60 @@
+# PasarGuard CLI
+
+A modern, type-safe command-line interface for managing PasarGuard, built with Typer.
+
+## Features
+
+- 🎯 Type-safe CLI with rich output
+- 📊 Beautiful tables and panels
+- 🔒 Secure admin management
+- 📈 System status monitoring
+- ⌨️ Interactive prompts and confirmations
+
+## Installation
+
+The CLI is included with PasarGuard and can be used directly:
+
+```bash
+PasarGuard cli --help
+
+# Or from the project root
+uv run PasarGuard-cli.py --help
+```
+
+## Usage
+
+### General Commands
+
+```bash
+# Show version
+pasarguard cli version
+
+# Show help
+pasarguard cli --help
+```
+
+### Admin Management
+
+```bash
+# List all admins
+pasarguard cli admins --list
+
+# Create new admin
+pasarguard cli admins --create username
+
+# Delete admin
+pasarguard cli admins --delete username
+
+# Modify admin (password and sudo status)
+PasarGuard cli admins --modify username
+
+# Reset admin usage
+pasarguard cli admins --reset-usage username
+```
+
+### System Information
+
+```bash
+# Show system status
+PasarGuard cli system
+```
diff --git a/cli/__init__.py b/cli/__init__.py
new file mode 100644
index 000000000..abcb32581
--- /dev/null
+++ b/cli/__init__.py
@@ -0,0 +1,49 @@
+"""
+PasarGuard CLI Package
+
+A modern, type-safe CLI built with Typer for managing PasarGuard instances.
+"""
+
+from pydantic import ValidationError
+from rich.console import Console
+from rich.table import Table
+
+from app.models.admin import AdminDetails
+from app.operation import OperatorType
+from app.operation.admin import AdminOperation
+from app.operation.system import SystemOperation
+
+# Initialize console for rich output
+console = Console()
+
+# system admin for CLI operations
+SYSTEM_ADMIN = AdminDetails(username="cli", is_sudo=True, telegram_id=None, discord_webhook=None)
+
+
+def get_admin_operation() -> AdminOperation:
+ """Get admin operation instance."""
+ return AdminOperation(OperatorType.CLI)
+
+
+def get_system_operation() -> SystemOperation:
+ """Get node operation instance."""
+ return SystemOperation(OperatorType.CLI)
+
+
+class BaseCLI:
+ """Base class for CLI operations."""
+
+ def __init__(self):
+ self.console = console
+
+ def create_table(self, title: str, columns: list) -> Table:
+ """Create a rich table with given columns."""
+ table = Table(title=title)
+ for column in columns:
+ table.add_column(column["name"], style=column.get("style", "white"))
+ return table
+
+ def format_cli_validation_error(self, errors: ValidationError):
+ for error in errors.errors():
+ for err in error["msg"].split(";"):
+ self.console.print(f"[red]Error: {err}[/red]")
diff --git a/cli/admin.py b/cli/admin.py
new file mode 100644
index 000000000..83529d29d
--- /dev/null
+++ b/cli/admin.py
@@ -0,0 +1,226 @@
+"""
+Admin CLI Module
+
+Handles admin account management through the command line interface.
+"""
+
+import typer
+from pydantic import ValidationError
+
+from app.db.base import GetDB
+from app.models.admin import AdminCreate, AdminModify
+from app.utils.system import readable_size
+from cli import SYSTEM_ADMIN, BaseCLI, console, get_admin_operation
+
+
+class AdminCLI(BaseCLI):
+ """Admin CLI operations."""
+
+ async def list_admins(self, db):
+ """List all admin accounts."""
+ admin_op = get_admin_operation()
+ admins = await admin_op.get_admins(db)
+
+ if not admins:
+ self.console.print("[yellow]No admins found[/yellow]")
+ return
+
+ table = self.create_table(
+ "Admin Accounts",
+ [
+ {"name": "Username", "style": "cyan"},
+ {"name": "Is Sudo", "style": "green"},
+ {"name": "Used Traffic", "style": "blue"},
+ {"name": "Is Disabled", "style": "red"},
+ ],
+ )
+
+ for admin in admins:
+ table.add_row(
+ admin.username,
+ "✓" if admin.is_sudo else "✗",
+ readable_size(admin.used_traffic),
+ "✓" if admin.is_disabled else "✗",
+ )
+
+ self.console.print(table)
+
+ async def create_admin(self, db, username: str):
+ """Create a new admin account."""
+ admin_op = get_admin_operation()
+
+ # Check if admin already exists
+ admins = await admin_op.get_admins(db)
+ if any(admin.username == username for admin in admins):
+ self.console.print(f"[red]Admin '{username}' already exists[/red]")
+ return
+
+ while True:
+ # Get password
+ password = typer.prompt("Password", hide_input=True)
+ if not password:
+ self.console.print("[red]Password is required[/red]")
+ continue
+
+ confirm_password = typer.prompt("Confirm Password", hide_input=True)
+ if password != confirm_password:
+ self.console.print("[red]Passwords do not match[/red]")
+ continue
+
+ try:
+ # Create admin
+ new_admin = AdminCreate(username=username, password=password, is_sudo=False)
+ await admin_op.create_admin(db, new_admin, SYSTEM_ADMIN)
+ self.console.print(f"[green]Admin '{username}' created successfully[/green]")
+ break
+ except ValidationError as e:
+ self.format_cli_validation_error(e)
+ continue
+ except Exception as e:
+ self.console.print(f"[red]Error creating admin: {e}[/red]")
+ break
+
+ async def delete_admin(self, db, username: str):
+ """Delete an admin account."""
+ admin_op = get_admin_operation()
+
+ # Check if admin exists
+ admins = await admin_op.get_admins(db)
+ if not any(admin.username == username for admin in admins):
+ self.console.print(f"[red]Admin '{username}' not found[/red]")
+ return
+
+ if typer.confirm(f"Are you sure you want to delete admin '{username}'?"):
+ try:
+ await admin_op.remove_admin(db, username, SYSTEM_ADMIN)
+ self.console.print(f"[green]Admin '{username}' deleted successfully[/green]")
+ except Exception as e:
+ self.console.print(f"[red]Error deleting admin: {e}[/red]")
+
+ async def modify_admin(self, db, username: str):
+ """Modify an admin account."""
+ admin_op = get_admin_operation()
+
+ # Check if admin exists
+ admins = await admin_op.get_admins(db)
+ if not any(admin.username == username for admin in admins):
+ self.console.print(f"[red]Admin '{username}' not found[/red]")
+ return
+
+ # Get the current admin details
+ current_admin = next(admin for admin in admins if admin.username == username)
+
+ self.console.print(f"[yellow]Modifying admin '{username}'[/yellow]")
+ self.console.print("[cyan]Current settings:[/cyan]")
+ self.console.print(f" Username: {current_admin.username}")
+ self.console.print(f" Is Sudo: {'✓' if current_admin.is_sudo else '✗'}")
+
+ new_password = None
+ is_sudo = current_admin.is_sudo
+ # Password modification
+ if typer.confirm("Do you want to change the password?"):
+ new_password = typer.prompt("New password", hide_input=True)
+ confirm_password = typer.prompt("Confirm Password", hide_input=True)
+ if new_password != confirm_password:
+ self.console.print("[red]Passwords do not match[/red]")
+ return
+
+ # Sudo status modification
+ if typer.confirm(f"Do you want to change sudo status? (Current: {'✓' if current_admin.is_sudo else '✗'})"):
+ is_sudo = typer.confirm("Make this admin a sudo admin?")
+
+ # Confirm changes
+ self.console.print("\n[cyan]Summary of changes:[/cyan]")
+ if new_password:
+ self.console.print(" Password: [yellow]Will be updated[/yellow]")
+ if is_sudo != current_admin.is_sudo:
+ self.console.print(f" Is Sudo: {'✓' if is_sudo else '✗'} [yellow](changed)[/yellow]")
+
+ if typer.confirm("Do you want to apply these changes?"):
+ try:
+ # Interactive modification
+ modified_admin = AdminModify(is_sudo=is_sudo, password=new_password)
+ await admin_op.modify_admin(db, username, modified_admin, SYSTEM_ADMIN)
+ self.console.print(f"[green]Admin '{username}' modified successfully[/green]")
+ except Exception as e:
+ self.console.print(f"[red]Error modifying admin: {e}[/red]")
+ else:
+ self.console.print("[yellow]Modification cancelled[/yellow]")
+
+ async def reset_admin_usage(self, db, username: str):
+ """Reset admin usage statistics."""
+ admin_op = get_admin_operation()
+
+ # Check if admin exists
+ admins = await admin_op.get_admins(db)
+ if not any(admin.username == username for admin in admins):
+ self.console.print(f"[red]Admin '{username}' not found[/red]")
+ return
+
+ if typer.confirm(f"Are you sure you want to reset usage for admin '{username}'?"):
+ try:
+ await admin_op.reset_admin_usage(db, username, SYSTEM_ADMIN)
+ self.console.print(f"[green]Usage reset for admin '{username}'[/green]")
+ except Exception as e:
+ self.console.print(f"[red]Error resetting usage: {e}[/red]")
+
+
+# CLI commands
+async def list_admins():
+ """List all admin accounts."""
+ admin_cli = AdminCLI()
+ async with GetDB() as db:
+ try:
+ await admin_cli.list_admins(db)
+ except Exception as e:
+ console.print(f"[red]Error: {e}[/red]")
+ finally:
+ return
+
+
+async def create_admin(username: str):
+ """Create a new admin account."""
+ admin_cli = AdminCLI()
+ async with GetDB() as db:
+ try:
+ await admin_cli.create_admin(db, username)
+ except Exception as e:
+ console.print(f"[red]Error: {e}[/red]")
+ finally:
+ return
+
+
+async def delete_admin(username: str):
+ """Delete an admin account."""
+ admin_cli = AdminCLI()
+ async with GetDB() as db:
+ try:
+ await admin_cli.delete_admin(db, username)
+ except Exception as e:
+ console.print(f"[red]Error: {e}[/red]")
+ finally:
+ return
+
+
+async def modify_admin(username: str):
+ """Modify an admin account."""
+ admin_cli = AdminCLI()
+ async with GetDB() as db:
+ try:
+ await admin_cli.modify_admin(db, username)
+ except Exception as e:
+ console.print(f"[red]Error: {e}[/red]")
+ finally:
+ return
+
+
+async def reset_admin_usage(username: str):
+ """Reset admin usage statistics."""
+ admin_cli = AdminCLI()
+ async with GetDB() as db:
+ try:
+ await admin_cli.reset_admin_usage(db, username)
+ except Exception as e:
+ console.print(f"[red]Error: {e}[/red]")
+ finally:
+ return
diff --git a/cli/main.py b/cli/main.py
new file mode 100644
index 000000000..cbd3fdb79
--- /dev/null
+++ b/cli/main.py
@@ -0,0 +1,63 @@
+#!/usr/bin/env python3
+"""
+PasarGuard CLI - Command Line Interface for PasarGuard Management
+
+A modern, type-safe CLI built with Typer for managing PasarGuard instances.
+"""
+
+import asyncio
+from typing import Optional
+
+import typer
+
+from cli import console
+from cli.admin import create_admin, delete_admin, list_admins, modify_admin, reset_admin_usage
+from cli.system import show_status
+
+# Initialize Typer app
+app = typer.Typer(
+ name="PasarGuard",
+ help="PasarGuard CLI - Command Line Interface for PasarGuard Management",
+ add_completion=False,
+ rich_markup_mode="rich",
+)
+
+
+@app.command()
+def version():
+ """Show PasarGuard version."""
+ from app import __version__
+
+ console.print(f"[bold blue]PasarGuard[/bold blue] version [bold green]{__version__}[/bold green]")
+
+
+@app.command()
+def admins(
+ list: bool = typer.Option(False, "--list", "-l", help="List all admins"),
+ create: Optional[str] = typer.Option(None, "--create", "-c", help="Create new admin"),
+ delete: Optional[str] = typer.Option(None, "--delete", "-d", help="Delete admin"),
+ modify: Optional[str] = typer.Option(None, "--modify", "-m", help="Modify admin"),
+ reset_usage: Optional[str] = typer.Option(None, "--reset-usage", "-r", help="Reset admin usage"),
+):
+ """List & manage admin accounts."""
+
+ if list or not any([create, delete, modify, reset_usage]):
+ asyncio.run(list_admins())
+ elif create:
+ asyncio.run(create_admin(create))
+ elif delete:
+ asyncio.run(delete_admin(delete))
+ elif modify:
+ asyncio.run(modify_admin(modify))
+ elif reset_usage:
+ asyncio.run(reset_admin_usage(reset_usage))
+
+
+@app.command()
+def system():
+ """Show system status."""
+ asyncio.run(show_status())
+
+
+if __name__ == "__main__":
+ app()
diff --git a/cli/system.py b/cli/system.py
new file mode 100644
index 000000000..a36416b06
--- /dev/null
+++ b/cli/system.py
@@ -0,0 +1,58 @@
+"""
+System CLI Module
+
+Handles system status and information through the command line interface.
+"""
+
+from rich.panel import Panel
+
+from app.db.base import GetDB
+from app.utils.system import readable_size
+from cli import SYSTEM_ADMIN, BaseCLI, console, get_system_operation
+
+
+class SystemCLI(BaseCLI):
+ """System CLI operations."""
+
+ async def show_status(self, db):
+ """Show system status."""
+ system_op = get_system_operation()
+ stats = await system_op.get_system_stats(db, SYSTEM_ADMIN)
+
+ status_text = (
+ f"[bold]System Statistics[/bold]\n\n"
+ f"CPU Usage: [green]{stats.cpu_usage:.1f}%[/green]\n"
+ f"Memory Usage: [green]{stats.mem_used / stats.mem_total * 100:.1f}%[/green] "
+ f"([cyan]{readable_size(stats.mem_used)}[/cyan] / [cyan]{readable_size(stats.mem_total)}[/cyan])\n"
+ f"CPU Cores: [magenta]{stats.cpu_cores}[/magenta]\n"
+ f"Total Users: [blue]{stats.total_user}[/blue]\n"
+ f"Active Users: [green]{stats.active_users}[/green]\n"
+ f"Online Users: [yellow]{stats.online_users}[/yellow]\n"
+ f"On Hold Users: [yellow]{stats.on_hold_users}[/yellow]\n"
+ f"Disabled Users: [red]{stats.disabled_users}[/red]\n"
+ f"Expired Users: [red]{stats.expired_users}[/red]\n"
+ f"Limited Users: [yellow]{stats.limited_users}[/yellow]\n"
+ f"Data Usage (In): [blue]{readable_size(stats.incoming_bandwidth)}[/blue]\n"
+ f"Data Usage (Out): [blue]{readable_size(stats.outgoing_bandwidth)}[/blue]"
+ )
+
+ panel = Panel(
+ status_text,
+ title="System Information",
+ border_style="blue",
+ )
+
+ self.console.print(panel)
+
+
+# CLI commands
+async def show_status():
+ """Show system status."""
+ system_cli = SystemCLI()
+ async with GetDB() as db:
+ try:
+ await system_cli.show_status(db)
+ except Exception as e:
+ console.print(f"[red]Error: {e}[/red]")
+ finally:
+ return
diff --git a/cli_wrapper.sh b/cli_wrapper.sh
new file mode 100644
index 000000000..dff723d88
--- /dev/null
+++ b/cli_wrapper.sh
@@ -0,0 +1,2 @@
+#!/bin/bash
+python /code/pasarguard-cli.py "$@"
diff --git a/config.py b/config.py
new file mode 100644
index 000000000..4bf079076
--- /dev/null
+++ b/config.py
@@ -0,0 +1,99 @@
+import os
+
+from decouple import config
+from dotenv import load_dotenv
+
+TESTING = os.getenv("TESTING", False)
+
+if not TESTING:
+ load_dotenv()
+
+
+SQLALCHEMY_DATABASE_URL = config("SQLALCHEMY_DATABASE_URL", default="sqlite+aiosqlite:///db.sqlite3")
+SQLALCHEMY_POOL_SIZE = config("SQLALCHEMY_POOL_SIZE", cast=int, default=10)
+SQLALCHEMY_MAX_OVERFLOW = config("SQLALCHEMY_MAX_OVERFLOW", cast=int, default=30)
+ECHO_SQL_QUERIES = config("ECHO_SQL_QUERIES", cast=bool, default=False)
+
+UVICORN_HOST = config("UVICORN_HOST", default="0.0.0.0")
+UVICORN_PORT = config("UVICORN_PORT", cast=int, default=8000)
+UVICORN_UDS = config("UVICORN_UDS", default=None)
+UVICORN_SSL_CERTFILE = config("UVICORN_SSL_CERTFILE", default=None)
+UVICORN_SSL_KEYFILE = config("UVICORN_SSL_KEYFILE", default=None)
+UVICORN_SSL_CA_TYPE = config("UVICORN_SSL_CA_TYPE", default="public").lower()
+DASHBOARD_PATH = config("DASHBOARD_PATH", default="/dashboard/")
+UVICORN_LOOP = config("UVICORN_LOOP", default="auto", cast=str)
+
+DEBUG = config("DEBUG", default=False, cast=bool)
+DOCS = config("DOCS", default=False, cast=bool)
+
+ALLOWED_ORIGINS = config("ALLOWED_ORIGINS", default="*").split(",")
+
+VITE_BASE_API = (
+ f"{'https' if UVICORN_SSL_CERTFILE and UVICORN_SSL_KEYFILE else 'http'}://127.0.0.1:{UVICORN_PORT}/"
+ if DEBUG and config("VITE_BASE_API", default="/") == "/"
+ else config("VITE_BASE_API", default="/")
+)
+
+# For backward compatibility
+SUBSCRIPTION_PATH = config("XRAY_SUBSCRIPTION_PATH", default="").strip("/")
+if not SUBSCRIPTION_PATH:
+ SUBSCRIPTION_PATH = config("SUBSCRIPTION_PATH", default="sub").strip("/")
+
+USER_SUBSCRIPTION_CLIENTS_LIMIT = config("USER_SUBSCRIPTION_CLIENTS_LIMIT", cast=int, default=10)
+
+JWT_ACCESS_TOKEN_EXPIRE_MINUTES = config("JWT_ACCESS_TOKEN_EXPIRE_MINUTES", cast=int, default=1440)
+
+CUSTOM_TEMPLATES_DIRECTORY = config("CUSTOM_TEMPLATES_DIRECTORY", default=None)
+SUBSCRIPTION_PAGE_TEMPLATE = config("SUBSCRIPTION_PAGE_TEMPLATE", default="subscription/index.html")
+HOME_PAGE_TEMPLATE = config("HOME_PAGE_TEMPLATE", default="home/index.html")
+
+CLASH_SUBSCRIPTION_TEMPLATE = config("CLASH_SUBSCRIPTION_TEMPLATE", default="clash/default.yml")
+
+SINGBOX_SUBSCRIPTION_TEMPLATE = config("SINGBOX_SUBSCRIPTION_TEMPLATE", default="singbox/default.json")
+
+XRAY_SUBSCRIPTION_TEMPLATE = config("XRAY_SUBSCRIPTION_TEMPLATE", default="xray/default.json")
+
+USER_AGENT_TEMPLATE = config("USER_AGENT_TEMPLATE", default="user_agent/default.json")
+GRPC_USER_AGENT_TEMPLATE = config("GRPC_USER_AGENT_TEMPLATE", default="user_agent/grpc.json")
+
+EXTERNAL_CONFIG = config("EXTERNAL_CONFIG", default="", cast=str)
+
+USERS_AUTODELETE_DAYS = config("USERS_AUTODELETE_DAYS", default=-1, cast=int)
+USER_AUTODELETE_INCLUDE_LIMITED_ACCOUNTS = config("USER_AUTODELETE_INCLUDE_LIMITED_ACCOUNTS", default=False, cast=bool)
+
+DO_NOT_LOG_TELEGRAM_BOT = config("DO_NOT_LOG_TELEGRAM_BOT", default=True, cast=bool)
+
+SAVE_LOGS_TO_FILE = config("SAVE_LOGS_TO_FILE", default=False, cast=bool)
+LOG_FILE_PATH = config("LOG_FILE_PATH", default="pasarguard.log")
+LOG_BACKUP_COUNT = config("LOG_BACKUP_COUNT", cast=int, default=72)
+LOG_ROTATION_ENABLED = config("LOG_ROTATION_ENABLED", default=False, cast=bool)
+LOG_ROTATION_INTERVAL = config("LOG_ROTATION_INTERVAL", cast=int, default=1)
+LOG_ROTATION_UNIT = config("LOG_ROTATION_UNIT", default="H")
+LOG_MAX_BYTES = config("LOG_MAX_BYTES", cast=int, default=10485760) # default: 10 MB
+
+# USERNAME: PASSWORD
+SUDOERS = (
+ {config("SUDO_USERNAME"): config("SUDO_PASSWORD")}
+ if config("SUDO_USERNAME", default="") and config("SUDO_PASSWORD", default="")
+ else {}
+)
+
+DISABLE_RECORDING_NODE_USAGE = config("DISABLE_RECORDING_NODE_USAGE", cast=bool, default=False)
+
+# due to high amout of data this job is only available for postgresql and timescaledb
+if SQLALCHEMY_DATABASE_URL.startswith("postgresql"):
+ ENABLE_RECORDING_NODES_STATS = config("ENABLE_RECORDING_NODES_STATS", cast=bool, default=False)
+else:
+ ENABLE_RECORDING_NODES_STATS = False
+
+# Interval jobs, all values are in seconds
+JOB_CORE_HEALTH_CHECK_INTERVAL = config("JOB_CORE_HEALTH_CHECK_INTERVAL", cast=int, default=10)
+JOB_RECORD_NODE_USAGES_INTERVAL = config("JOB_RECORD_NODE_USAGES_INTERVAL", cast=int, default=30)
+JOB_RECORD_USER_USAGES_INTERVAL = config("JOB_RECORD_USER_USAGES_INTERVAL", cast=int, default=10)
+JOB_REVIEW_USERS_INTERVAL = config("JOB_REVIEW_USERS_INTERVAL", cast=int, default=30)
+JOB_SEND_NOTIFICATIONS_INTERVAL = config("JOB_SEND_NOTIFICATIONS_INTERVAL", cast=int, default=30)
+JOB_GHATER_NODES_STATS_INTERVAL = config("JOB_GHATER_NODES_STATS_INTERVAL", cast=int, default=25)
+JOB_REMOVE_OLD_INBOUNDS_INTERVAL = config("JOB_REMOVE_OLD_INBOUNDS_INTERVAL", cast=int, default=600)
+JOB_REMOVE_EXPIRED_USERS_INTERVAL = config("JOB_REMOVE_EXPIRED_USERS_INTERVAL", cast=int, default=3600)
+JOB_RESET_USER_DATA_USAGE_INTERVAL = config("JOB_RESET_USER_DATA_USAGE_INTERVAL", cast=int, default=600)
+JOB_CLEANUP_SUBSCRIPTION_UPDATES_INTERVAL = config("JOB_CLEANUP_SUBSCRIPTION_UPDATES_INTERVAL", cast=int, default=600)
diff --git a/dashboard/.env.example b/dashboard/.env.example
new file mode 100644
index 000000000..fab1fb2ae
--- /dev/null
+++ b/dashboard/.env.example
@@ -0,0 +1 @@
+VITE_BASE_API=
\ No newline at end of file
diff --git a/dashboard/.gitignore b/dashboard/.gitignore
new file mode 100644
index 000000000..ad63c13bc
--- /dev/null
+++ b/dashboard/.gitignore
@@ -0,0 +1,25 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+bun-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+build
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/dashboard/.prettierrc.json b/dashboard/.prettierrc.json
new file mode 100644
index 000000000..91172deb4
--- /dev/null
+++ b/dashboard/.prettierrc.json
@@ -0,0 +1,21 @@
+{
+ "printWidth": 200,
+ "semi": false,
+ "singleQuote": true,
+ "arrowParens": "avoid",
+ "bracketSameLine": false,
+ "bracketSpacing": true,
+ "embeddedLanguageFormatting": "auto",
+ "endOfLine": "lf",
+ "htmlWhitespaceSensitivity": "css",
+ "insertPragma": false,
+ "jsxSingleQuote": false,
+ "proseWrap": "always",
+ "quoteProps": "as-needed",
+ "requirePragma": false,
+ "singleAttributePerLine": false,
+ "tabWidth": 2,
+ "trailingComma": "all",
+ "useTabs": false,
+ "plugins": ["prettier-plugin-tailwindcss"]
+}
diff --git a/dashboard/README.md b/dashboard/README.md
new file mode 100644
index 000000000..90169c3af
--- /dev/null
+++ b/dashboard/README.md
@@ -0,0 +1,42 @@
+# Dashboard UI for pasargaurd
+
+## Requirements
+
+For development, you will only need Node.js installed on your environement.
+
+### Node
+
+[Node](http://nodejs.org/) is really easy to install & now include [NPM](https://npmjs.org/). This project has been developed on the Nodejs v20.x so if you faced any issue during installation that may
+related to the node version, install Node with version >= v20
+
+## Install
+
+ Install the latest LTS version of Node.js
+ git clone https://github.com/PasarGuard/panel.git
+ `bash cd panel/dashboard`
+ `bash curl -fsSL https://bun.sh/install | bash`
+ `bash bun install`
+
+### Configure app
+
+Copy `example.env` to `.env` then set the backend api address:
+
+ VITE_BASE_API=https://somewhere.com/
+
+#### Environment variables
+
+| Name | Description |
+| ------------- | ------------------------------------------------------------------------------------ |
+| VITE_BASE_API | The api url of the deployed backend ([PasarGuard](https://github.com/PasarGuard/panel.git)) |
+
+## Start development server
+
+ bun dev
+
+## Simple build for production
+
+ bun build
+
+## Contribution
+
+Feel free to contribute. Go on and fork the project. After commiting the changes, make a PR. It means a lot to us.
diff --git a/dashboard/__init__.py b/dashboard/__init__.py
new file mode 100644
index 000000000..fe59c52e1
--- /dev/null
+++ b/dashboard/__init__.py
@@ -0,0 +1,62 @@
+import atexit
+import os
+import subprocess
+from pathlib import Path
+
+from fastapi.staticfiles import StaticFiles
+
+from app import app, on_startup
+from config import DASHBOARD_PATH, DEBUG, UVICORN_PORT, VITE_BASE_API
+
+base_dir = Path(__file__).parent
+build_dir = base_dir / "build"
+statics_dir = build_dir / "statics"
+
+
+def build_api_interface():
+ subprocess.Popen(
+ ["bun", "run", "wait-port-gen-api"],
+ env={**os.environ, "UVICORN_PORT": str(UVICORN_PORT)},
+ cwd=base_dir,
+ stdout=subprocess.DEVNULL,
+ )
+
+
+def build():
+ proc = subprocess.Popen(
+ ["bun", "run", "build", "--outDir", build_dir, "--assetsDir", "statics"],
+ env={**os.environ, "VITE_BASE_API": VITE_BASE_API},
+ cwd=base_dir,
+ )
+ proc.wait()
+ with open(build_dir / "index.html", "r") as file:
+ html = file.read()
+ with open(build_dir / "404.html", "w") as file:
+ file.write(html)
+
+
+def run_dev():
+ build_api_interface()
+ proc = subprocess.Popen(
+ ["bun", "run", "dev", "--base", os.path.join(DASHBOARD_PATH, "")],
+ env={**os.environ, "VITE_BASE_API": VITE_BASE_API, "DEBUG": "false"},
+ cwd=base_dir,
+ )
+
+ atexit.register(proc.terminate)
+
+
+def run_build():
+ if not build_dir.is_dir():
+ build()
+
+ app.mount(DASHBOARD_PATH, StaticFiles(directory=build_dir, html=True), name="dashboard")
+ app.mount("/statics/", StaticFiles(directory=statics_dir, html=True), name="statics")
+
+
+@on_startup
+def run_dashboard():
+ if DEBUG:
+ run_dev()
+ else:
+ run_build()
diff --git a/dashboard/build.zip b/dashboard/build.zip
new file mode 100644
index 0000000000000000000000000000000000000000..13cb6a925483bcf15558b989da9a944dd0e391ad
GIT binary patch
literal 1785169
zcmZ_#18`AuCCY;!wSQFc}ZQIG6$^Y{~{r3a~vj5LVS%0e$
z_!9(V{+|Ql|M9V~H8ydjHFvbJu1c~2WY8n}_QAeak+FY22Or*(i|lqLyNIIQrZ_MQwIBy)cTBqd@9aBh=CDiz}pb0#NgR*Uhs}
zdTQ$70z-{wwRY0CB}e^z=mn6;g)9S~S1>oS80i_Q6KoAQGR~AUu!o#7&lF(i@i7
z%Mj*2mQAidBuqM-Kc0Iv63ZEY%KevC-pJyDm()o+
z5m^qsOnlEa=k5;+T=>Yd21Ji^D(Pwh4_L2y%P4*tj;_2A8AtZ>vX1`7a&3$Fp?!!R
z=;SYq<7a3mDcK27b!}CA7vcI-@pzO>9p%Ta%{Uc}VYoCEwfhEl=k9yW4yzSx2jry+
z9Q%IfLzCHU&ec8bH71JY{$Y$>I&q=fCCHqft!pvHTvPtfxiUL3*M)1zr@gW;rKF~e
zf!MlrBaMBlB6k`#yy^mW?Fx>Gy&vyC{wF{+cFIK^{{gf7pJD$WfZ70+6E%*3V5nS1+ar@ScdPy3v=
z?)zzqG0?GY6y}AxTMn=PZtMR;xc?&4zvlg)0{^e}{}2x7Xy9mJ1pHsb3th{p#rsD*
zIWQ0q;{Wh9Ffsx-7+U~rX@u2H&0ST^ENLx)S&0+n8}x|5&tA}bB4Gj!3GpKKqkhb&
z?lODw>-?jF&BI}eI-VpP&QRXZl22npHfQ-T9@ei3$2mSEKn=@-X-Hw3d>ezt$apmf
z^Q%!M@MGcRqr~bSm54+Af;A?a;M}<+P@&~>{2PtCi)=@OEnIvd{TDB&pDToRn}Gy1
zQVPR3^^0pAV}C7TX%I5o?X($gG7-<|7UXuW+e~)DVax=4SDHUKgM{v%j3AFSHA0;4
zaZ`W^sOyW1wPF;a1wh);CMrro@Jy00tGcKYghe?kQP>%xmEVBK!EKb2!TMd9;Q-;H`}SwhsW@fh
z^N>I~7=g-x)XJOe%qb%F!mxdOc&dbW8ujCubu6*QkX`$kvV~BNo;|d^|F)Vj6&iz#&OaSV)tkZsO&ftw`#Sror=Egu4d!}
zZ>ay!7OBDis4z|NZrKpPm?=_KUJAU+brkh0Qs%%F!h_MB}e4O;(zH$+{PNjyzQeu
zVti_m3GF>WqD7%47N-?`HhnSM&Rf_p_6rpY19-+&KFgdipbQ+AkU4MJ;*&dCs^zAm
zN!KJy|1auB+QBTfK!JdK;(~yX{6}mBjBPAzWdOzo)-*C=4ra=VqAvfU+l6(mx-t6d
zlOtHVfm`5qBlL6ebrGY|fG3MOJ};tsk+vC|%}dhdb=(v3hn
zDFoao)8h_3Gh=j8?b3_Q-1*pr?A^GtYUtc!{UuPN9ABC4<;@@Zd<`D1Hn=Dpm^
zbc@Tz7Fm%OT(YV@wXEV%XuXi*=Ag@hfd!Zq0(3HTHC3OJUX}4&nxrZ8Z>=2
zmGF^i+4r2WjsTW9u*o1FsplDzzS%>q+PIsRMANnekj_)hI_XM1oiWoGRNFFXUf9Z
z1&}}`3(+j){@Zs_GJMndnjT?#bNu2IHG7mut6CJ~bAuFd9^oLmowynIQx%d$_8(`R
zZr*lHpAG@X%$|5sCZ5(J$(PWlb(qxa?oEQ^D1UxXue+lMDe??iR-XN@
zJ4>s_ogODu9dJdBMKTJ~Ez8l?BoPAA%K$UHINT@9!%ZX)zfBER?dqO5z(bkaXH76;glm0D8vIMG>&R
z)>|Y{OJ@SDdVCMknSE{qb`!dJq2U;U?OJk7oyw`?q2wzm!c7ytT>3bd)Cd`(EN%{E
z46wu!rD(e*j@SsLcet`+Z4*>{cUWTM3Hdfd@29hQvtA|Q`$B2
z0W;7~_)t{x`>WpVwY~aJa>3a5b;3rciT}R^s*->jC>}Gzv8`hut5N$t@AiH
zdHZX>xJ8!~WNLeDqSI8YD?P3T2FbxnVxreRf;uMD=Gb0glMExDu+06up{U~_Y}NwC
z%u2k%2qydbkKo8oKOv!s05zqquBw)yaY&ELe*fdaishTz!%+#bPe*>7DStmto;uL#nr${^CGZ#{<
z`i^pqZln--xZ5}m`YXfiSYgTMrAIHB%=wE>E)`jlsr*LRQ?x5488Ch{
z=oO-IEpRY84okrL#b^aQS$)t=F%eN)7Hxec$ygUTC*NVw6pTm*;uom2#sC__@1jI;
zcFY?Gu)@Te7b`jS%xXv21ct7flV6%iO%uBo;HHPZFg9*KR-MVj`Z}bSIwbyStxlUo(hL_sCB&LuZnp2t^KtGG{#i(
z0SNDP^~f7q1H{(w{$??ro0MWR%-0q>qTf?f?+24w4(_>5DPf;EGV5i_cx@Tfzw7XZ
zaCYv8KidakUkTHATqHm0#*-^$ghC{va4+E>Mn?aIkyo+Rxf3-+U&o~u+{W-orR9|T$8ulmnhz;Omy_R
zJ7`jF|Nd<_<^{YIIE%rh?QFLz`v-RnOZ>>`NTYc#G1mPRMHAVfczY;+6Zlm26}jAL
zi!NTnf^|c8HzU`ks(Ef$bP?2atmI+2)9f-u9V0slP@G_6I=5QNi_i;}w$AaNQ+Fei
zQyDc(brHDD%D2GH8RliCx|J1CIDVE-pm*!F=-uyTb4=q7RPT`^GkXjS1n*^7?`b6y
z4CMxLHrd=Dhz%gb#_cY`^w={^z7p(?%C+@85*-
z7!(8q|34-^2G%AHjx>U%Kvp?BDeHf8o}>hsi2ur0u0PQPL-tX}EF=Z{2w@C7&PVd7
zH~4>vG!N@uj>mJwcl{!K-=G?q4#_B*O(;rpG0mQHRo2jW=nx~qMS91zkvL%RALahm
z0kwU#VL_|taL3}~_&vanGdDux&-2O>{i9{>n>K;K__-GAB+SoCq4%N&{pc~)FMwh#(zKOL@I0G
zEabytKKvrlBi~l_Wc;#g2gdZb$|Jrw@7~nja<@PY)(yh~^>>cO1G(IwLzMSmn$%
z?v{;abJDbhC+%`CYiy_pc96XU*>HQv%^T{cO}0~Guo8julCI1N9l}6vU2Dj>`V7f>
z$RS3hYLv3y@WLeS5XR)|6bAu!gnxyZjX>$!;~r$M&FzZ@=GhQYs-WOO5Xw;tRR{qE
zLEXovZ)xM_$|wRgy%&$LqK?Uu$5k|~wT}F05$Zl`Vn1R2kofWpFpUmWp7aV>Xcl`4
zI#_Yj36!^!UT+5WRk;