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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user