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 <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-26 01:34:40 +03:00
co-authored by Cursor
parent 7b3d8aac34
commit 7d7094bc38
13 changed files with 347 additions and 148 deletions
+56 -25
View File
@@ -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]