gitscope
← marzneshin / v0.5.0 / app/utils/share.py
https://dev.kronoz.win/r/marzneshin.git
app/utils/share.py · Python
Blame Raw
  1import base64
  2import json
  3import random
  4import secrets
  5from collections import defaultdict
  6from datetime import datetime as dt, timedelta
  7from importlib import resources
  8from typing import Literal, Union, List, Type
  9from uuid import UUID
 10
 11from jdatetime import date as jd
 12from v2share import (
 13    V2Data,
 14    SingBoxConfig,
 15    ClashConfig,
 16    ClashMetaConfig,
 17    XrayConfig,
 18)
 19from v2share.base import BaseConfig
 20from v2share.links import LinksConfig
 21
 22from app.config.env import (
 23    XRAY_SUBSCRIPTION_TEMPLATE,
 24    SINGBOX_SUBSCRIPTION_TEMPLATE,
 25    CLASH_SUBSCRIPTION_TEMPLATE,
 26    SUBSCRIPTION_PAGE_TEMPLATE,
 27)
 28from app.db import GetDB, crud
 29from app.models.proxy import (
 30    InboundHostSecurity,
 31)
 32from app.models.settings import SubscriptionSettings
 33from app.models.user import UserResponse, UserExpireStrategy
 34from app.templates import render_template
 35from app.utils.keygen import gen_uuid, gen_password
 36from app.utils.system import get_public_ip, readable_size
 37
 38SERVER_IP = get_public_ip()
 39
 40ACTIVITY_EMOJIS = {
 41    True: "✅",
 42    False: "❌",
 43}
 44
 45subscription_handlers: dict[str, Type[BaseConfig]] = {
 46    "links": LinksConfig,
 47    "xray": XrayConfig,
 48    "clash-meta": ClashMetaConfig,
 49    "clash": ClashConfig,
 50    "sing-box": SingBoxConfig,
 51}
 52
 53handlers_templates = {
 54    LinksConfig: None,
 55    XrayConfig: XRAY_SUBSCRIPTION_TEMPLATE
 56    or resources.files("app.templates") / "xray.json",
 57    ClashConfig: CLASH_SUBSCRIPTION_TEMPLATE,
 58    ClashMetaConfig: CLASH_SUBSCRIPTION_TEMPLATE,
 59    SingBoxConfig: SINGBOX_SUBSCRIPTION_TEMPLATE
 60    or resources.files("app.templates") / "sing-box.json",
 61}
 62
 63
 64def generate_subscription_template(
 65    db_user, subscription_settings: SubscriptionSettings
 66):
 67    links = generate_subscription(
 68        user=db_user,
 69        config_format="links",
 70        use_placeholder=not db_user.is_active
 71        and subscription_settings.placeholder_if_disabled,
 72        placeholder_remark=subscription_settings.placeholder_remark,
 73        shuffle=subscription_settings.shuffle_configs,
 74    ).split()
 75    return render_template(
 76        SUBSCRIPTION_PAGE_TEMPLATE,
 77        {"user": UserResponse.model_validate(db_user), "links": links},
 78    )
 79
 80
 81def generate_subscription(
 82    user: "UserResponse",
 83    config_format: Literal["links", "xray", "clash-meta", "clash", "sing-box"],
 84    as_base64: bool = False,
 85    use_placeholder: bool = False,
 86    placeholder_remark: str = "disabled",
 87    shuffle: bool = False,
 88) -> str:
 89    extra_data = UserResponse.model_validate(user).model_dump(
 90        exclude={"subscription_url", "services", "inbounds"}
 91    )
 92    format_variables = setup_format_variables(extra_data)
 93
 94    if config_format not in subscription_handlers.keys():
 95        raise ValueError(f'Unsupported format "{config_format}"')
 96
 97    subscription_handler_class = subscription_handlers[config_format]
 98    if template_path := handlers_templates[subscription_handler_class]:
 99        subscription_handler = subscription_handler_class(
100            template_path=template_path
101        )
102    else:
103        subscription_handler = subscription_handler_class()
104
105    if use_placeholder:
106        placeholder_config = V2Data(
107            "vmess",
108            placeholder_remark.format_map(format_variables),
109            "127.0.0.1",
110            80,
111        )
112        configs = [placeholder_config]
113
114    else:
115        configs = generate_user_configs(
116            user.inbounds,
117            user.key,
118            format_variables,
119        )
120
121    subscription_handler.add_proxies(configs)
122    config = subscription_handler.render(sort=True, shuffle=shuffle)
123
124    return (
125        config if not as_base64 else base64.b64encode(config.encode()).decode()
126    )
127
128
129def format_time_left(seconds_left: int) -> str:
130    if not seconds_left or seconds_left <= 0:
131        return "∞"
132
133    minutes, seconds = divmod(seconds_left, 60)
134    hours, minutes = divmod(minutes, 60)
135    days, hours = divmod(hours, 24)
136    months, days = divmod(days, 30)
137
138    result = []
139    if months:
140        result.append(f"{months}m")
141    if days:
142        result.append(f"{days}d")
143    if hours and (days < 7):
144        result.append(f"{hours}h")
145    if minutes and not (months or days):
146        result.append(f"{minutes}m")
147    if seconds and not (months or days):
148        result.append(f"{seconds}s")
149    return " ".join(result)
150
151
152def setup_format_variables(extra_data: dict) -> dict:
153    expire_strategy = extra_data.get("expire_strategy")
154    expire_datetime = extra_data.get("expire_date")
155    usage_duration = extra_data.get("usage_duration")
156
157    if expire_strategy != UserExpireStrategy.START_ON_FIRST_USE:
158        if expire_datetime is not None:
159            seconds_left = (expire_datetime - dt.utcnow()).total_seconds()
160            expire_date = expire_datetime.date()
161            jalali_expire_date = jd.fromgregorian(
162                year=expire_date.year,
163                month=expire_date.month,
164                day=expire_date.day,
165            ).strftime("%Y-%m-%d")
166            days_left = (expire_datetime - dt.utcnow()).days + 1
167            time_left = format_time_left(seconds_left)
168        else:
169            days_left, time_left, expire_date, jalali_expire_date = "∞" * 4
170    else:
171        if usage_duration is not None and usage_duration >= 0:
172            days_left = timedelta(seconds=usage_duration).days
173            time_left = format_time_left(usage_duration)
174            expire_date = "-"
175            jalali_expire_date = "-"
176        else:
177            days_left, time_left, expire_date, jalali_expire_date = "∞" * 4
178
179    if extra_data.get("data_limit"):
180        data_limit = readable_size(extra_data["data_limit"])
181        data_left = extra_data["data_limit"] - extra_data["used_traffic"]
182        if data_left < 0:
183            data_left = 0
184        data_left = readable_size(data_left)
185    else:
186        data_limit, data_left = "∞" * 2
187
188    status_emoji = ACTIVITY_EMOJIS.get(extra_data.get("is_active")) or ""
189
190    format_variables = defaultdict(
191        lambda: "<missing>",
192        {
193            "SERVER_IP": SERVER_IP,
194            "USERNAME": extra_data.get("username", "{USERNAME}"),
195            "DATA_USAGE": readable_size(extra_data.get("used_traffic")),
196            "DATA_LIMIT": data_limit,
197            "DATA_LEFT": data_left,
198            "DAYS_LEFT": days_left,
199            "EXPIRE_DATE": expire_date,
200            "JALALI_EXPIRE_DATE": jalali_expire_date,
201            "TIME_LEFT": time_left,
202            "STATUS_EMOJI": status_emoji,
203        },
204    )
205
206    return format_variables
207
208
209def generate_user_configs(
210    inbounds: list,
211    key: str,
212    format_variables: dict,
213) -> Union[List, str]:
214
215    salt = secrets.token_hex(8)
216    configs = []
217
218    for inb in inbounds:
219        inb_id, inbound, protocol, tag = (
220            inb.id,
221            json.loads(inb.config),
222            inb.protocol,
223            inb.tag,
224        )
225
226        format_variables.update({"PROTOCOL": protocol.name})
227        if not inbound:
228            continue
229
230        format_variables.update(
231            {"TRANSPORT": inbound.get("network", "<missing>")}
232        )
233        with GetDB() as db:
234            hosts = crud.get_inbound_hosts(db, inb_id)
235
236        for host in hosts:
237            if host.is_disabled:
238                continue
239            host_snis = host.sni.split(",") if host.sni else []
240            sni_list = host_snis or inbound.get("sni", [])
241            if sni_list:
242                sni = random.choice(sni_list).replace("*", salt)
243            else:
244                sni = ""
245
246            host_hosts = host.host.split(",") if host.host else []
247            req_host_list = host_hosts or inbound.get("host", [])
248            if req_host_list:
249                req_host = random.choice(req_host_list).replace("*", salt)
250            else:
251                req_host = ""
252
253            host_tls = (
254                None
255                if host.security == InboundHostSecurity.inbound_default
256                else host.security.value
257            )
258            data = V2Data(
259                protocol.value,
260                host.remark.format_map(format_variables),
261                host.address.format_map(format_variables),
262                host.port or inbound["port"],
263                transport_type=inbound.get("network"),
264                sni=sni,
265                host=req_host,
266                tls=host_tls or inbound.get("tls"),
267                header_type=inbound.get("header_type"),
268                alpn=host.alpn if host.alpn != "none" else None,
269                path=(
270                    host.path.format_map(format_variables)
271                    if host.path
272                    else inbound.get("path")
273                ),
274                fingerprint=host.fingerprint.value or inbound.get("fp"),
275                reality_pbk=inbound.get("pbk"),
276                reality_sid=inbound.get("sid"),
277                flow=inbound.get("flow"),
278                allow_insecure=host.allowinsecure,
279                uuid=UUID(gen_uuid(key)),
280                password=gen_password(key),
281                enable_mux=host.mux,
282                http_headers={
283                    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.3"
284                },
285                shadowsocks_method="chacha20-ietf-poly1305",
286                weight=host.weight,
287            )
288            if host.fragment:
289                data.fragment = True
290                data.fragment_packets = host.fragment["packets"]
291                data.fragment_length = host.fragment["length"]
292                data.fragment_interval = host.fragment["interval"]
293            configs.append(data)
294
295    return configs
296
297
298def encode_title(text: str) -> str:
299    return f"base64:{base64.b64encode(text.encode()).decode()}"