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_public_subtitle') }}
+