From 7d7094bc3877f334db1e47089e5eb2d145f3b8d9 Mon Sep 17 00:00:00 2001 From: orohi Date: Sun, 26 Jul 2026 01:34:40 +0300 Subject: [PATCH] Issue invite configs as subscription URLs with redeem-based lifetime. Admin picks inbound once, sets duration in days from Get config, and configures the 3x-ui /sub base URL. Co-authored-by: Cursor --- app.py | 81 ++++++++++++------ db/connection.py | 3 + db/schema.sql | 1 + db/store.py | 9 +- managers/xui_api.py | 50 +++++++++++- templates/invite.html | 83 +++++++++++-------- templates/invites.html | 176 ++++++++++++++++++++++------------------ templates/settings.html | 12 ++- translations/en.json | 16 ++++ translations/fa.json | 16 ++++ translations/fr.json | 16 ++++ translations/ru.json | 16 ++++ translations/zh.json | 16 ++++ 13 files changed, 347 insertions(+), 148 deletions(-) diff --git a/app.py b/app.py index 3a97084..3b027e2 100644 --- a/app.py +++ b/app.py @@ -1793,6 +1793,7 @@ class SyncSettings(BaseModel): xui_server_id: int = 0 xui_protocol: str = 'xray' xui_inbound_id: int = 0 + xui_sub_url: str = '' class CaptchaSettings(BaseModel): enabled: bool = False @@ -1887,7 +1888,7 @@ class InviteCreateRequest(BaseModel): server_id: int = 0 xui_inbound_id: int = 0 password: Optional[str] = None - expires_at: Optional[str] = None + duration_days: int = 0 # client lifetime after redeem; 0 = no expiry note: str = '' enabled: bool = True @@ -1901,8 +1902,7 @@ class InviteUpdateRequest(BaseModel): xui_inbound_id: Optional[int] = None password: Optional[str] = None clear_password: bool = False - expires_at: Optional[str] = None - clear_expires: bool = False + duration_days: Optional[int] = None note: Optional[str] = None enabled: Optional[bool] = None reset_used: bool = False @@ -4246,15 +4246,8 @@ def _invite_public_view(link: dict) -> dict: max_uses = int(link.get('max_uses') or 0) used = int(link.get('used_count') or 0) remaining = None if max_uses <= 0 else max(0, max_uses - used) - expired = False - if link.get('expires_at'): - try: - exp = datetime.fromisoformat(str(link['expires_at']).replace('Z', '+00:00')) - now = datetime.now(exp.tzinfo) if exp.tzinfo else datetime.now() - expired = now > exp - except Exception: - expired = False exhausted = remaining is not None and remaining <= 0 + duration_days = int(link.get('duration_days') or 0) return { 'id': link.get('id'), 'name': link.get('name') or 'Invite', @@ -4264,17 +4257,17 @@ def _invite_public_view(link: dict) -> dict: 'used_count': used, 'remaining': remaining, 'unlimited': max_uses <= 0, - 'expired': expired, + 'expired': False, 'exhausted': exhausted, 'has_password': bool(link.get('password_hash')), 'protocol': link.get('protocol') or 'xui', 'server_id': int(link.get('server_id') or 0), 'xui_inbound_id': int(link.get('xui_inbound_id') or 0), + 'duration_days': duration_days, 'user_id': link.get('user_id') or '', 'note': link.get('note') or '', - 'expires_at': link.get('expires_at'), 'created_at': link.get('created_at'), - 'available': bool(link.get('enabled', True)) and not expired and not exhausted, + 'available': bool(link.get('enabled', True)) and not exhausted, } @@ -4288,6 +4281,14 @@ def _invite_auth_ok(link: dict, request: Request) -> bool: return bool(request.session.get(f"invite_auth_{link.get('token')}")) +def _expiry_ms_from_duration_days(duration_days: int) -> int: + """3x-ui expiryTime is unix ms; 0 means no expiry. Starts now (on redeem).""" + days = int(duration_days or 0) + if days <= 0: + return 0 + return int((datetime.now().timestamp() + days * 86400) * 1000) + + async def _create_config_for_protocol( data: dict, *, @@ -4295,21 +4296,32 @@ async def _create_config_for_protocol( name: str, server_id: int = 0, xui_inbound_id: Optional[int] = None, + duration_days: int = 0, ) -> dict: - """Create VPN client; returns {client_id, config, protocol, server_id}.""" + """Create VPN client; returns {client_id, config, subscription_url, protocol, server_id}.""" protocol = protocol or 'xui' if protocol_base(protocol) == 'xui': + if not xui_inbound_id: + raise RuntimeError('Select a VLESS inbound for this invite link') from managers.xui_api import xui_create_vless_config + expiry_ms = _expiry_ms_from_duration_days(duration_days) created = await xui_create_vless_config( data.get('settings', {}), name=name, inbound_id=xui_inbound_id, + expiry_time=expiry_ms, ) return { 'client_id': created['client_id'], 'config': created.get('config') or '', + 'subscription_url': created.get('subscription_url') or '', + 'sub_id': created.get('sub_id') or '', 'protocol': 'xui', 'server_id': 0, + 'expires_at': ( + datetime.fromtimestamp(expiry_ms / 1000).isoformat() + if expiry_ms > 0 else None + ), } sid = int(server_id or 0) @@ -4336,8 +4348,10 @@ async def _create_config_for_protocol( return { 'client_id': result['client_id'], 'config': result.get('config') or '', + 'subscription_url': '', 'protocol': protocol, 'server_id': sid, + 'expires_at': None, } @@ -4356,6 +4370,8 @@ async def invites_page(request: Request): users=data.get('users', []), servers=data.get('servers', []), xui_configured=bool(((data.get('settings') or {}).get('sync') or {}).get('xui_url')), + xui_sub_url=(((data.get('settings') or {}).get('sync') or {}).get('xui_sub_url') or ''), + xui_default_inbound=int((((data.get('settings') or {}).get('sync') or {}).get('xui_inbound_id') or 0)), ) @@ -4373,9 +4389,15 @@ async def api_create_invite(request: Request, req: InviteCreateRequest): return JSONResponse({'error': 'Forbidden'}, status_code=403) if req.max_uses < 0: return JSONResponse({'error': 'max_uses must be >= 0'}, status_code=400) + if req.duration_days < 0: + return JSONResponse({'error': 'duration_days must be >= 0'}, status_code=400) data = load_data() if req.user_id and not any(u['id'] == req.user_id for u in data['users']): return JSONResponse({'error': 'User not found'}, status_code=400) + protocol = req.protocol or 'xui' + inbound_id = int(req.xui_inbound_id or 0) + if protocol_base(protocol) == 'xui' and not inbound_id: + return JSONResponse({'error': 'Select a VLESS inbound'}, status_code=400) link = { 'id': str(uuid.uuid4()), 'name': (req.name or 'Invite').strip() or 'Invite', @@ -4384,11 +4406,12 @@ async def api_create_invite(request: Request, req: InviteCreateRequest): 'max_uses': int(req.max_uses), 'used_count': 0, 'user_id': req.user_id or '', - 'protocol': req.protocol or 'xui', + 'protocol': protocol, 'server_id': int(req.server_id or 0), - 'xui_inbound_id': int(req.xui_inbound_id or 0), + 'xui_inbound_id': inbound_id, 'password_hash': hash_password(req.password) if req.password else None, - 'expires_at': req.expires_at or None, + 'expires_at': None, + 'duration_days': int(req.duration_days or 0), 'note': req.note or '', 'created_at': datetime.now().isoformat(), } @@ -4422,20 +4445,23 @@ async def api_update_invite(request: Request, invite_id: str, req: InviteUpdateR link['server_id'] = int(req.server_id) if req.xui_inbound_id is not None: link['xui_inbound_id'] = int(req.xui_inbound_id) + if req.duration_days is not None: + if req.duration_days < 0: + return JSONResponse({'error': 'duration_days must be >= 0'}, status_code=400) + link['duration_days'] = int(req.duration_days) if req.clear_password: link['password_hash'] = None elif req.password: link['password_hash'] = hash_password(req.password) - if req.clear_expires: - link['expires_at'] = None - elif req.expires_at is not None: - link['expires_at'] = req.expires_at or None if req.note is not None: link['note'] = req.note if req.enabled is not None: link['enabled'] = bool(req.enabled) if req.reset_used: link['used_count'] = 0 + # Validate inbound for xui + if protocol_base(link.get('protocol') or 'xui') == 'xui' and not int(link.get('xui_inbound_id') or 0): + return JSONResponse({'error': 'Select a VLESS inbound'}, status_code=400) save_data(data) return {'status': 'success', 'invite': _invite_public_view(link)} @@ -4556,7 +4582,8 @@ async def api_invite_create_config(token: str, req: InviteRedeemRequest, request protocol=link.get('protocol') or 'xui', name=name, server_id=int(link.get('server_id') or 0), - xui_inbound_id=link.get('xui_inbound_id') or None, + xui_inbound_id=int(link.get('xui_inbound_id') or 0) or None, + duration_days=int(link.get('duration_days') or 0), ) conn = { 'id': str(uuid.uuid4()), @@ -4572,13 +4599,17 @@ async def api_invite_create_config(token: str, req: InviteRedeemRequest, request data.setdefault('user_connections', []).append(conn) save_data(data) config = created.get('config') or '' - vpn_link = generate_vpn_link(config) if config else '' + subscription_url = created.get('subscription_url') or '' + # For subscription URLs, vpn_link is the same shareable string + vpn_link = subscription_url or (generate_vpn_link(config) if config else '') data = load_data() link = _find_invite(data, token) or link return { 'status': 'success', 'config': config, + 'subscription_url': subscription_url, 'vpn_link': vpn_link, + 'expires_at': created.get('expires_at'), 'connection': conn, 'invite': _invite_public_view(link), } @@ -4872,7 +4903,7 @@ async def api_xui_sync_now(request: Request): for key in ( 'xui_url', 'xui_username', 'xui_password', 'xui_api_token', 'xui_create_conns', 'xui_server_id', 'xui_protocol', 'xui_sync_users', 'xui_sync', - 'xui_inbound_id', + 'xui_inbound_id', 'xui_sub_url', ): if key in body and body[key] is not None: sync_cfg[key] = body[key] diff --git a/db/connection.py b/db/connection.py index 20e7752..7b57749 100644 --- a/db/connection.py +++ b/db/connection.py @@ -84,6 +84,9 @@ def init_schema(): cur.execute( "CREATE INDEX IF NOT EXISTS idx_users_xui_email ON users(xui_email)" ) + cur.execute( + "ALTER TABLE invite_links ADD COLUMN IF NOT EXISTS duration_days INTEGER NOT NULL DEFAULT 0" + ) conn.commit() _schema_ready = True logger.info('PostgreSQL schema ready') diff --git a/db/schema.sql b/db/schema.sql index bc899a8..272bd84 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -85,6 +85,7 @@ CREATE TABLE IF NOT EXISTS invite_links ( xui_inbound_id INTEGER NOT NULL DEFAULT 0, password_hash TEXT, expires_at TIMESTAMPTZ, + duration_days INTEGER NOT NULL DEFAULT 0, note TEXT, created_at TIMESTAMPTZ ); diff --git a/db/store.py b/db/store.py index 77e881f..320bedc 100644 --- a/db/store.py +++ b/db/store.py @@ -45,6 +45,7 @@ DEFAULT_SETTINGS = { 'xui_server_id': 0, 'xui_protocol': 'xray', 'xui_inbound_id': 0, + 'xui_sub_url': '', }, 'guest': { 'enabled': False, @@ -209,6 +210,7 @@ def _row_to_invite(row) -> dict: 'xui_inbound_id': int(row['xui_inbound_id'] or 0), 'password_hash': row['password_hash'], 'expires_at': _ts_iso(row['expires_at']), + 'duration_days': int(row.get('duration_days') or 0), 'note': row['note'] or '', 'created_at': _ts_iso(row['created_at']), } @@ -252,7 +254,7 @@ def load_data() -> dict: cur.execute( 'SELECT id, name, token, enabled, max_uses, used_count, user_id, ' 'protocol, server_id, xui_inbound_id, password_hash, expires_at, ' - 'note, created_at FROM invite_links ' + 'duration_days, note, created_at FROM invite_links ' 'ORDER BY created_at DESC NULLS LAST, name' ) invite_links = [_row_to_invite(r) for r in cur.fetchall()] @@ -392,8 +394,8 @@ def save_data(data: dict) -> None: 'INSERT INTO invite_links (' 'id, name, token, enabled, max_uses, used_count, user_id, ' 'protocol, server_id, xui_inbound_id, password_hash, expires_at, ' - 'note, created_at' - ') VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)', + 'duration_days, note, created_at' + ') VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)', ( _as_uuid(link['id']), link.get('name') or '', @@ -407,6 +409,7 @@ def save_data(data: dict) -> None: int(link.get('xui_inbound_id') or 0), link.get('password_hash'), _parse_ts(link.get('expires_at')), + int(link.get('duration_days') or 0), link.get('note') or '', _parse_ts(link.get('created_at')), ), diff --git a/managers/xui_api.py b/managers/xui_api.py index 4edb864..190f8ca 100644 --- a/managers/xui_api.py +++ b/managers/xui_api.py @@ -32,9 +32,21 @@ def _settings_creds(settings: dict) -> dict: 'username': (sync.get('xui_username') or '').strip(), 'password': sync.get('xui_password') or '', 'inbound_id': sync.get('xui_inbound_id'), + 'sub_url': (sync.get('xui_sub_url') or '').strip().rstrip('/'), } +def build_subscription_url(settings: dict, sub_id: str) -> str: + """Build public subscription URL from panel settings + client subId.""" + sub_id = (sub_id or '').strip() + if not sub_id: + return '' + base = _settings_creds(settings).get('sub_url') or '' + if not base: + return '' + return f"{base}/{sub_id}" + + class XuiApiError(RuntimeError): pass @@ -211,6 +223,7 @@ class XuiApi: 'email': email, 'config': vless or '', 'links': links, + 'expiry_time': expiry_time, } async def get_client_links(self, email: str) -> list: @@ -292,8 +305,14 @@ class XuiApi: raise XuiApiError(f'Failed to toggle 3x-ui client {email}') -async def xui_create_vless_config(settings: dict, *, name: str, inbound_id: Optional[int] = None) -> dict: - """Create a VLESS client on 3x-ui and return {client_id, config, links}.""" +async def xui_create_vless_config( + settings: dict, + *, + name: str, + inbound_id: Optional[int] = None, + expiry_time: int = 0, +) -> dict: + """Create a VLESS client on 3x-ui and return {client_id, config, subscription_url, links}.""" creds = _settings_creds(settings) inbound = inbound_id if inbound_id is not None else creds.get('inbound_id') try: @@ -312,16 +331,39 @@ async def xui_create_vless_config(settings: dict, *, name: str, inbound_id: Opti base = ''.join(ch if ch.isalnum() or ch in '._-+@' else '_' for ch in (name or 'user').strip()) base = base[:48] or f'user_{secrets.token_hex(4)}' email = base + created = None # Avoid collisions for _ in range(5): try: - return await api.add_vless_client(email=email, inbound_id=inbound, comment=name or email) + created = await api.add_vless_client( + email=email, + inbound_id=inbound, + comment=name or email, + expiry_time=int(expiry_time or 0), + ) + break except XuiApiError as e: if 'exist' in str(e).lower() or 'duplicate' in str(e).lower() or 'already' in str(e).lower(): email = f'{base}_{secrets.token_hex(3)}' continue raise - return await api.add_vless_client(email=email, inbound_id=inbound, comment=name or email) + if created is None: + created = await api.add_vless_client( + email=email, + inbound_id=inbound, + comment=name or email, + expiry_time=int(expiry_time or 0), + ) + + sub_url = build_subscription_url(settings, created.get('sub_id') or '') + # Prefer subscription URL as the main share string when configured + if sub_url: + created['subscription_url'] = sub_url + created['config'] = sub_url + else: + created['subscription_url'] = '' + created['inbound_id'] = inbound + return created async def xui_get_config(settings: dict, email: str) -> str: diff --git a/templates/invite.html b/templates/invite.html index b00d4c6..3ea42e0 100644 --- a/templates/invite.html +++ b/templates/invite.html @@ -3,16 +3,15 @@ {% block title_extra %} — {{ _('invite_public_title') }}{% endblock %} {% block content %} -
-
-

{{ invite.name }}

-

{{ _('invite_public_subtitle') }}

+
+
+
🔗
+

{{ invite.name }}

+

{{ _('invite_public_subtitle') }}

{% if need_password %}
-
🔐

{{ _('invite_protected_desc') }}

@@ -26,27 +25,39 @@
{% else %} -
-
- {% if invite.unlimited %} - {{ _('invite_uses_left_unlimited').replace('{}', invite.used_count|string) }} - {% elif invite.remaining is not none %} - {{ _('invite_uses_left').replace('{}', invite.remaining|string) }} - {% endif %} +
+
+
+
{{ _('invite_uses') }}
+
+ {% if invite.unlimited %}∞{% else %}{{ invite.remaining }}{% endif %} +
+
+
+
{{ _('invite_duration_short') }}
+
+ {% if invite.duration_days %}{{ invite.duration_days }} {{ _('days_short') }}{% else %}∞{% endif %} +
+
+

+ {% if invite.duration_days %} + {{ _('invite_duration_starts_hint').replace('{}', invite.duration_days|string) }} + {% else %} + {{ _('invite_get_config_hint') }} + {% endif %} +

+ {% if invite.available %} - -
{{ _('invite_get_config_hint') }}
- {% elif invite.expired %} -

{{ _('invite_expired') }}

{% elif invite.exhausted %} -

{{ _('invite_exhausted') }}

+

{{ _('invite_exhausted') }}

{% else %} -

{{ _('disabled') }}

+

{{ _('disabled') }}

{% endif %}
{% endif %} @@ -58,14 +69,15 @@
+
- +
-
@@ -130,17 +142,24 @@ document.getElementById(panels[tab]).classList.add('active'); } - function openConfigModal(name, config, vpnLink) { + function openConfigModal(name, config, vpnLink, expiresAt) { currentConfig = config; - currentVpnLink = vpnLink || ''; + currentVpnLink = vpnLink || config || ''; document.getElementById('configText').value = config; document.getElementById('vpnLinkText').textContent = currentVpnLink; document.getElementById('configModalTitle').textContent = name; - document.getElementById('downloadBtn').onclick = () => downloadFile(config, `${name}.conf`); + const banner = document.getElementById('expiresBanner'); + if (expiresAt) { + banner.textContent = _('invite_config_expires_at').replace('{}', new Date(expiresAt).toLocaleString()); + banner.classList.remove('hidden'); + } else { + banner.classList.add('hidden'); + } + document.getElementById('downloadBtn').onclick = () => downloadFile(config, `${name}.txt`); const qrContainer = document.getElementById('qrcode'); qrContainer.innerHTML = ''; new QRCode(qrContainer, { - text: config, width: 256, height: 256, + text: currentVpnLink || config, width: 256, height: 256, colorDark: '#000000', colorLight: '#ffffff', correctLevel: QRCode.CorrectLevel.L }); @@ -152,14 +171,11 @@ function copyVpnLink() { copyToClipboard(currentVpnLink); } function updateStatus(invite) { - const box = document.getElementById('statusBox'); - if (!box || !invite) return; - if (invite.unlimited) { - box.textContent = _('invite_uses_left_unlimited').replace('{}', String(invite.used_count || 0)); - } else if (invite.remaining != null) { - box.textContent = _('invite_uses_left').replace('{}', String(invite.remaining)); + const uses = document.getElementById('usesValue'); + if (uses && invite) { + uses.textContent = invite.unlimited ? '∞' : String(invite.remaining ?? 0); } - if (!invite.available) { + if (invite && !invite.available) { const btn = document.getElementById('createBtn'); if (btn) btn.style.display = 'none'; } @@ -180,7 +196,8 @@ }); const data = await res.json(); if (!res.ok || data.error) throw new Error(data.error || _('error')); - if (data.config) openConfigModal(data.connection?.name || 'VPN', data.config, data.vpn_link || ''); + const share = data.subscription_url || data.config || ''; + if (share) openConfigModal(data.connection?.name || 'VPN', share, data.vpn_link || share, data.expires_at); updateStatus(data.invite); } catch (err) { alert(`${_('error')}: ` + err.message); diff --git a/templates/invites.html b/templates/invites.html index 56f1c8f..56367b7 100644 --- a/templates/invites.html +++ b/templates/invites.html @@ -4,18 +4,30 @@ {% block content %}
-
-

- 🔗 - {{ _('invites_title') }} -

+
+
+

+ 🔗 + {{ _('invites_title') }} +

+

{{ _('invites_hint') }}

+
-

{{ _('invites_hint') }}

+ {% if not xui_sub_url and xui_configured %} +
+
+
⚠️
+
+
{{ _('invite_sub_url_missing_title') }}
+
{{ _('invite_sub_url_missing_hint') }}
+
+
+
+ {% endif %}
🔗
@@ -23,36 +35,43 @@
{{ _('invites_empty_desc') }}
-
+
{% for inv in invites %} -
-
-
🔗
+
+
-
- {{ inv.name }} - {% if inv.available %} - {{ _('invite_active') }} - {% elif inv.expired %} - {{ _('invite_expired') }} - {% elif inv.exhausted %} - {{ _('invite_exhausted') }} - {% else %} - {{ _('disabled') }} - {% endif %} +
{{ inv.name }}
+
+ {% if inv.protocol == 'xui' %}3x-ui VLESS{% else %}{{ inv.protocol }}{% endif %} + · inbound #{{ inv.xui_inbound_id or '—' }}
-
- {{ _('invite_uses') }}: - {% if inv.unlimited %}{{ inv.used_count }} / ∞{% else %}{{ inv.used_count }} / {{ inv.max_uses }}{% endif %} - - {{ inv.protocol if inv.protocol != 'xui' else '3x-ui VLESS' }} - {% if inv.has_password %}🔐{% endif %} +
+ {% if inv.available %} + {{ _('invite_active') }} + {% elif inv.exhausted %} + {{ _('invite_exhausted') }} + {% else %} + {{ _('disabled') }} + {% endif %} +
+ +
+
+
{{ _('invite_uses') }}
+
+ {% if inv.unlimited %}{{ inv.used_count }} / ∞{% else %}{{ inv.used_count }} / {{ inv.max_uses }}{% endif %} +
+
+
+
{{ _('invite_duration_short') }}
+
+ {% if inv.duration_days %}{{ inv.duration_days }} {{ _('days_short') }}{% else %}∞{% endif %}
-
- + +
+
-