mirror of
https://github.com/Gozargah/Marzban.git
synced 2026-05-17 08:35:56 +03:00
* chore: update pydantic to version 2.10.2 and refactor model validators * refactor: simplify field validators by removing unnecessary pre and always flags * remove typing_extensions==4.9.0 from requirements.txt * refactor: remove allow_reuse flag from status field validator * refactor: simplify field validators by removing pre and always flags * refactor: update user model imports and enhance account class with abstract method * refactor: update model_config to use dictionary format in Admin and SubscriptionUserResponse classes * fix typo in UserDataResetByNext * change pre=True to mode="before" * refactor: update validation methods and model configuration in User and Proxy classes * change pre=False with mode="after" * Migrated to Pydantic V2 * fix: custom subscriptions not workong * some small changes * add missing properties to example schema * replace from_orm with model_validate --------- Co-authored-by: MahdiButcher <madibutchercoding@gmail.com> Co-authored-by: Mahdi Butcher <MahdiButcherCoding@gmail.com>
325 lines
10 KiB
Python
325 lines
10 KiB
Python
import base64
|
|
import random
|
|
import secrets
|
|
from collections import defaultdict
|
|
from datetime import datetime as dt
|
|
from datetime import timedelta
|
|
from typing import TYPE_CHECKING, List, Literal, Union
|
|
|
|
from jdatetime import date as jd
|
|
|
|
from app import xray
|
|
from app.utils.system import get_public_ip, get_public_ipv6, readable_size
|
|
|
|
from . import *
|
|
|
|
if TYPE_CHECKING:
|
|
from app.models.user import UserResponse
|
|
|
|
from config import (
|
|
ACTIVE_STATUS_TEXT,
|
|
DISABLED_STATUS_TEXT,
|
|
EXPIRED_STATUS_TEXT,
|
|
LIMITED_STATUS_TEXT,
|
|
ONHOLD_STATUS_TEXT,
|
|
)
|
|
|
|
SERVER_IP = get_public_ip()
|
|
SERVER_IPV6 = get_public_ipv6()
|
|
|
|
STATUS_EMOJIS = {
|
|
"active": "✅",
|
|
"expired": "⌛️",
|
|
"limited": "🪫",
|
|
"disabled": "❌",
|
|
"on_hold": "🔌",
|
|
}
|
|
|
|
STATUS_TEXTS = {
|
|
"active": ACTIVE_STATUS_TEXT,
|
|
"expired": EXPIRED_STATUS_TEXT,
|
|
"limited": LIMITED_STATUS_TEXT,
|
|
"disabled": DISABLED_STATUS_TEXT,
|
|
"on_hold": ONHOLD_STATUS_TEXT,
|
|
}
|
|
|
|
|
|
def generate_v2ray_links(proxies: dict, inbounds: dict, extra_data: dict, reverse: bool) -> list:
|
|
format_variables = setup_format_variables(extra_data)
|
|
conf = V2rayShareLink()
|
|
return process_inbounds_and_tags(inbounds, proxies, format_variables, conf=conf, reverse=reverse)
|
|
|
|
|
|
def generate_clash_subscription(
|
|
proxies: dict, inbounds: dict, extra_data: dict, reverse: bool, is_meta: bool = False
|
|
) -> str:
|
|
if is_meta is True:
|
|
conf = ClashMetaConfiguration()
|
|
else:
|
|
conf = ClashConfiguration()
|
|
|
|
format_variables = setup_format_variables(extra_data)
|
|
return process_inbounds_and_tags(
|
|
inbounds, proxies, format_variables, conf=conf, reverse=reverse
|
|
)
|
|
|
|
|
|
def generate_singbox_subscription(
|
|
proxies: dict, inbounds: dict, extra_data: dict, reverse: bool
|
|
) -> str:
|
|
conf = SingBoxConfiguration()
|
|
|
|
format_variables = setup_format_variables(extra_data)
|
|
return process_inbounds_and_tags(
|
|
inbounds, proxies, format_variables, conf=conf, reverse=reverse
|
|
)
|
|
|
|
|
|
def generate_outline_subscription(
|
|
proxies: dict, inbounds: dict, extra_data: dict, reverse: bool,
|
|
) -> str:
|
|
conf = OutlineConfiguration()
|
|
|
|
format_variables = setup_format_variables(extra_data)
|
|
return process_inbounds_and_tags(
|
|
inbounds, proxies, format_variables, conf=conf, reverse=reverse
|
|
)
|
|
|
|
|
|
def generate_v2ray_json_subscription(
|
|
proxies: dict, inbounds: dict, extra_data: dict, reverse: bool,
|
|
) -> str:
|
|
conf = V2rayJsonConfig()
|
|
|
|
format_variables = setup_format_variables(extra_data)
|
|
return process_inbounds_and_tags(
|
|
inbounds, proxies, format_variables, conf=conf, reverse=reverse
|
|
)
|
|
|
|
|
|
def generate_subscription(
|
|
user: "UserResponse",
|
|
config_format: Literal["v2ray", "clash-meta", "clash", "sing-box", "outline", "v2ray-json"],
|
|
as_base64: bool,
|
|
reverse: bool,
|
|
) -> str:
|
|
kwargs = {
|
|
"proxies": user.proxies,
|
|
"inbounds": user.inbounds,
|
|
"extra_data": user.__dict__,
|
|
"reverse": reverse,
|
|
}
|
|
|
|
if config_format == "v2ray":
|
|
config = "\n".join(generate_v2ray_links(**kwargs))
|
|
elif config_format == "clash-meta":
|
|
config = generate_clash_subscription(**kwargs, is_meta=True)
|
|
elif config_format == "clash":
|
|
config = generate_clash_subscription(**kwargs)
|
|
elif config_format == "sing-box":
|
|
config = generate_singbox_subscription(**kwargs)
|
|
elif config_format == "outline":
|
|
config = generate_outline_subscription(**kwargs)
|
|
elif config_format == "v2ray-json":
|
|
config = generate_v2ray_json_subscription(**kwargs)
|
|
else:
|
|
raise ValueError(f'Unsupported format "{config_format}"')
|
|
|
|
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(extra_data: dict) -> dict:
|
|
from app.models.user import UserStatus
|
|
|
|
user_status = extra_data.get("status")
|
|
expire_timestamp = extra_data.get("expire")
|
|
on_hold_expire_duration = extra_data.get("on_hold_expire_duration")
|
|
now = dt.utcnow()
|
|
now_ts = now.timestamp()
|
|
|
|
if user_status != UserStatus.on_hold:
|
|
if expire_timestamp is not None and expire_timestamp >= 0:
|
|
seconds_left = expire_timestamp - int(dt.utcnow().timestamp())
|
|
expire_datetime = dt.fromtimestamp(expire_timestamp)
|
|
expire_date = expire_datetime.date()
|
|
jalali_expire_date = jd.fromgregorian(
|
|
year=expire_date.year, month=expire_date.month, day=expire_date.day
|
|
).strftime("%Y-%m-%d")
|
|
if now_ts < expire_timestamp:
|
|
days_left = (expire_datetime - dt.utcnow()).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 is not None and on_hold_expire_duration >= 0:
|
|
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 extra_data.get("data_limit"):
|
|
data_limit = readable_size(extra_data["data_limit"])
|
|
data_left = extra_data["data_limit"] - extra_data["used_traffic"]
|
|
if data_left < 0:
|
|
data_left = 0
|
|
data_left = readable_size(data_left)
|
|
else:
|
|
data_limit = "∞"
|
|
data_left = "∞"
|
|
|
|
status_emoji = STATUS_EMOJIS.get(extra_data.get("status")) or ""
|
|
status_text = STATUS_TEXTS.get(extra_data.get("status")) or ""
|
|
|
|
format_variables = defaultdict(
|
|
lambda: "<missing>",
|
|
{
|
|
"SERVER_IP": SERVER_IP,
|
|
"SERVER_IPV6": SERVER_IPV6,
|
|
"USERNAME": extra_data.get("username", "{USERNAME}"),
|
|
"DATA_USAGE": readable_size(extra_data.get("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,
|
|
"STATUS_TEXT": status_text,
|
|
},
|
|
)
|
|
|
|
return format_variables
|
|
|
|
|
|
def process_inbounds_and_tags(
|
|
inbounds: dict,
|
|
proxies: dict,
|
|
format_variables: dict,
|
|
conf: Union[
|
|
V2rayShareLink,
|
|
V2rayJsonConfig,
|
|
SingBoxConfiguration,
|
|
ClashConfiguration,
|
|
ClashMetaConfiguration,
|
|
OutlineConfiguration
|
|
],
|
|
reverse=False,
|
|
) -> Union[List, str]:
|
|
_inbounds = []
|
|
for protocol, tags in inbounds.items():
|
|
for tag in tags:
|
|
_inbounds.append((protocol, [tag]))
|
|
index_dict = {proxy: index for index, proxy in enumerate(
|
|
xray.config.inbounds_by_tag.keys())}
|
|
inbounds = sorted(
|
|
_inbounds, key=lambda x: index_dict.get(x[1][0], float('inf')))
|
|
|
|
for protocol, tags in inbounds:
|
|
settings = proxies.get(protocol)
|
|
if not settings:
|
|
continue
|
|
|
|
format_variables.update({"PROTOCOL": protocol.name})
|
|
for tag in tags:
|
|
inbound = xray.config.inbounds_by_tag.get(tag)
|
|
if not inbound:
|
|
continue
|
|
|
|
format_variables.update({"TRANSPORT": inbound["network"]})
|
|
host_inbound = inbound.copy()
|
|
for host in xray.hosts.get(tag, []):
|
|
sni = ""
|
|
sni_list = host["sni"] or inbound["sni"]
|
|
if sni_list:
|
|
salt = secrets.token_hex(8)
|
|
sni = random.choice(sni_list).replace("*", salt)
|
|
|
|
if sids := inbound.get("sids"):
|
|
inbound["sid"] = random.choice(sids)
|
|
|
|
req_host = ""
|
|
req_host_list = host["host"] or 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 host["path"] is not None:
|
|
path = host["path"].format_map(format_variables)
|
|
else:
|
|
path = inbound.get("path", "").format_map(format_variables)
|
|
|
|
host_inbound.update(
|
|
{
|
|
"port": host["port"] or inbound["port"],
|
|
"sni": sni,
|
|
"host": req_host,
|
|
"tls": inbound["tls"] if host["tls"] is None else host["tls"],
|
|
"alpn": host["alpn"] if host["alpn"] else None,
|
|
"path": path,
|
|
"fp": host["fingerprint"] or inbound.get("fp", ""),
|
|
"ais": host["allowinsecure"]
|
|
or inbound.get("allowinsecure", ""),
|
|
"mux_enable": host["mux_enable"],
|
|
"fragment_setting": host["fragment_setting"],
|
|
"noise_setting": host["noise_setting"],
|
|
"random_user_agent": host["random_user_agent"],
|
|
}
|
|
)
|
|
|
|
conf.add(
|
|
remark=host["remark"].format_map(format_variables),
|
|
address=address.format_map(format_variables),
|
|
inbound=host_inbound,
|
|
settings=settings.model_dump()
|
|
)
|
|
|
|
return conf.render(reverse=reverse)
|
|
|
|
|
|
def encode_title(text: str) -> str:
|
|
return f"base64:{base64.b64encode(text.encode()).decode()}"
|