Add optional expiration that starts on first config use.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1488,6 +1488,8 @@ class AddUserRequest(BaseModel):
|
||||
protocol: Optional[str] = None
|
||||
connection_name: Optional[str] = None
|
||||
expiration_date: Optional[str] = None
|
||||
expire_after_first_use: bool = False
|
||||
expiration_days: int = 0
|
||||
telemt_quota: Optional[str] = None
|
||||
telemt_max_ips: Optional[int] = None
|
||||
telemt_expiry: Optional[str] = None
|
||||
@@ -1564,6 +1566,8 @@ class UpdateUserRequest(BaseModel):
|
||||
traffic_limit: Optional[float] = 0
|
||||
traffic_reset_strategy: Optional[str] = None
|
||||
expiration_date: Optional[str] = None
|
||||
expire_after_first_use: Optional[bool] = None
|
||||
expiration_days: Optional[int] = None
|
||||
password: Optional[str] = None
|
||||
|
||||
|
||||
@@ -1703,6 +1707,10 @@ async def startup():
|
||||
migrated = True
|
||||
if 'expiration_date' not in u:
|
||||
u['expiration_date'] = None
|
||||
if 'expire_after_first_use' not in u:
|
||||
u['expire_after_first_use'] = False
|
||||
if 'expiration_days' not in u:
|
||||
u['expiration_days'] = 0
|
||||
migrated = True
|
||||
if 'xui_email' not in u:
|
||||
u['xui_email'] = None
|
||||
@@ -3346,6 +3354,8 @@ async def api_list_users(request: Request, search: str = '', page: int = 1, size
|
||||
'traffic_reset_strategy': u.get('traffic_reset_strategy', 'never'),
|
||||
'last_reset_at': u.get('last_reset_at'),
|
||||
"expiration_date": u.get("expiration_date"),
|
||||
"expire_after_first_use": bool(u.get("expire_after_first_use")),
|
||||
"expiration_days": int(u.get("expiration_days") or 0),
|
||||
'share_enabled': u.get('share_enabled', False),
|
||||
'share_token': u.get('share_token'),
|
||||
'has_share_password': bool(u.get('share_password_hash')),
|
||||
@@ -3389,7 +3399,9 @@ async def api_add_user(request: Request, req: AddUserRequest):
|
||||
'traffic_used': 0,
|
||||
'traffic_total': 0,
|
||||
'last_reset_at': datetime.now().isoformat(),
|
||||
'expiration_date': req.expiration_date,
|
||||
'expiration_date': None if req.expire_after_first_use else req.expiration_date,
|
||||
'expire_after_first_use': bool(req.expire_after_first_use),
|
||||
'expiration_days': int(req.expiration_days or 0) if req.expire_after_first_use else 0,
|
||||
'enabled': True,
|
||||
'created_at': datetime.now().isoformat(),
|
||||
'remnawave_uuid': None,
|
||||
@@ -3398,6 +3410,8 @@ async def api_add_user(request: Request, req: AddUserRequest):
|
||||
'share_token': secrets.token_urlsafe(16),
|
||||
'share_password_hash': None,
|
||||
}
|
||||
if new_user['expire_after_first_use'] and new_user['expiration_days'] <= 0:
|
||||
return JSONResponse({'error': 'expiration_days must be > 0 when start-after-first-use is enabled'}, status_code=400)
|
||||
data['users'].append(new_user)
|
||||
save_data(data)
|
||||
|
||||
@@ -3472,8 +3486,29 @@ async def api_update_user(request: Request, user_id: str, req: UpdateUserRequest
|
||||
user['last_reset_at'] = datetime.now().isoformat()
|
||||
|
||||
req_fields = getattr(req, 'model_fields_set', getattr(req, '__fields_set__', set()))
|
||||
if 'expiration_date' in req_fields:
|
||||
if 'expire_after_first_use' in req_fields or 'expiration_days' in req_fields or 'expiration_date' in req_fields:
|
||||
after_first = bool(req.expire_after_first_use) if req.expire_after_first_use is not None else bool(user.get('expire_after_first_use'))
|
||||
days = int(req.expiration_days) if req.expiration_days is not None else int(user.get('expiration_days') or 0)
|
||||
if after_first:
|
||||
if days <= 0:
|
||||
return JSONResponse({'error': 'expiration_days must be > 0 when start-after-first-use is enabled'}, status_code=400)
|
||||
user['expire_after_first_use'] = True
|
||||
user['expiration_days'] = days
|
||||
# Keep existing absolute date if countdown already started; otherwise wait for first use
|
||||
if not user.get('expiration_date'):
|
||||
user['expiration_date'] = None
|
||||
elif 'expiration_date' in req_fields and req.expiration_date:
|
||||
# Admin can still override absolute end date after start
|
||||
user['expiration_date'] = req.expiration_date or None
|
||||
else:
|
||||
user['expire_after_first_use'] = False
|
||||
user['expiration_days'] = 0
|
||||
if 'expiration_date' in req_fields:
|
||||
user['expiration_date'] = req.expiration_date or None
|
||||
elif 'expiration_date' in req_fields:
|
||||
user['expiration_date'] = req.expiration_date or None
|
||||
user['expire_after_first_use'] = False
|
||||
user['expiration_days'] = 0
|
||||
|
||||
if req.password:
|
||||
user['password_hash'] = hash_password(req.password)
|
||||
@@ -3735,6 +3770,12 @@ async def api_share_config(token: str, connection_id: str, request: Request):
|
||||
return JSONResponse({'error': 'Not found'}, status_code=404)
|
||||
|
||||
try:
|
||||
from managers.user_expiration import maybe_start_user_expiration, user_is_expired
|
||||
if user_is_expired(user):
|
||||
return JSONResponse({'error': 'Subscription expired'}, status_code=403)
|
||||
if maybe_start_user_expiration(data, user['id']):
|
||||
save_data(data)
|
||||
|
||||
if protocol_base(conn.get('protocol', '')) == 'xui':
|
||||
return JSONResponse({'error': '3x-ui support removed'}, status_code=410)
|
||||
|
||||
@@ -3749,7 +3790,7 @@ async def api_share_config(token: str, connection_id: str, request: Request):
|
||||
config = _manager_call(manager, 'get_client_config', conn['protocol'], conn['client_id'], server['host'], port)
|
||||
ssh.disconnect()
|
||||
vpn_link = generate_vpn_link(config) if config else ''
|
||||
return {'config': config, 'vpn_link': vpn_link}
|
||||
return {'config': config, 'vpn_link': vpn_link, 'expires_at': user.get('expiration_date')}
|
||||
except Exception as e:
|
||||
logger.exception("Error getting shared config")
|
||||
return JSONResponse({'error': str(e)}, status_code=500)
|
||||
@@ -3873,6 +3914,12 @@ async def api_guest_config(token: str, connection_id: str, request: Request):
|
||||
if not conn:
|
||||
return JSONResponse({'error': 'Not found'}, status_code=404)
|
||||
try:
|
||||
from managers.user_expiration import maybe_start_user_expiration, user_is_expired
|
||||
if user_is_expired(holder):
|
||||
return JSONResponse({'error': 'Subscription expired'}, status_code=403)
|
||||
if maybe_start_user_expiration(data, holder['id']):
|
||||
save_data(data)
|
||||
|
||||
if protocol_base(conn.get('protocol', '')) == 'xui':
|
||||
return JSONResponse({'error': '3x-ui support removed'}, status_code=410)
|
||||
|
||||
@@ -3886,7 +3933,7 @@ async def api_guest_config(token: str, connection_id: str, request: Request):
|
||||
config = _manager_call(manager, 'get_client_config', conn['protocol'], conn['client_id'], server['host'], port)
|
||||
ssh.disconnect()
|
||||
vpn_link = generate_vpn_link(config) if config else ''
|
||||
return {'config': config, 'vpn_link': vpn_link}
|
||||
return {'config': config, 'vpn_link': vpn_link, 'expires_at': holder.get('expiration_date')}
|
||||
except Exception as e:
|
||||
logger.exception("Error getting guest config")
|
||||
return JSONResponse({'error': str(e)}, status_code=500)
|
||||
@@ -3909,6 +3956,10 @@ async def api_guest_create(token: str, req: GuestCreateRequest, request: Request
|
||||
name = f"{name}_{secrets.token_hex(3)}"
|
||||
|
||||
try:
|
||||
from managers.user_expiration import maybe_start_user_expiration, user_is_expired
|
||||
if user_is_expired(holder):
|
||||
return JSONResponse({'error': 'Subscription expired'}, status_code=403)
|
||||
|
||||
sid = int(guest.get('create_server_id') or 0)
|
||||
if sid >= len(data['servers']):
|
||||
return JSONResponse({'error': 'Guest server not found'}, status_code=400)
|
||||
@@ -3944,6 +3995,7 @@ async def api_guest_create(token: str, req: GuestCreateRequest, request: Request
|
||||
async with DATA_LOCK:
|
||||
data = load_data()
|
||||
data['user_connections'].append(conn)
|
||||
maybe_start_user_expiration(data, holder['id'])
|
||||
save_data(data)
|
||||
|
||||
config = result.get('config') or ''
|
||||
@@ -3955,6 +4007,7 @@ async def api_guest_create(token: str, req: GuestCreateRequest, request: Request
|
||||
'config': config,
|
||||
'subscription_url': subscription_url,
|
||||
'vpn_link': vpn_link,
|
||||
'expires_at': next((u.get('expiration_date') for u in data.get('users', []) if u.get('id') == holder['id']), None),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.exception("Error creating guest config")
|
||||
@@ -4323,7 +4376,14 @@ async def api_my_connection_config(request: Request, connection_id: str):
|
||||
if not user:
|
||||
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
||||
try:
|
||||
from managers.user_expiration import maybe_start_user_expiration, user_is_expired
|
||||
data = load_data()
|
||||
panel_user = next((u for u in data.get('users', []) if u['id'] == user['id']), None)
|
||||
if panel_user and user_is_expired(panel_user):
|
||||
return JSONResponse({'error': 'Subscription expired'}, status_code=403)
|
||||
if panel_user and maybe_start_user_expiration(data, user['id']):
|
||||
save_data(data)
|
||||
|
||||
conn = next(
|
||||
(c for c in data.get('user_connections', []) if c['id'] == connection_id and c['user_id'] == user['id']),
|
||||
None
|
||||
@@ -4347,7 +4407,11 @@ async def api_my_connection_config(request: Request, connection_id: str):
|
||||
config = _manager_call(manager, 'get_client_config', conn['protocol'], conn['client_id'], server['host'], port)
|
||||
ssh.disconnect()
|
||||
vpn_link = generate_vpn_link(config) if config else ''
|
||||
return {'config': config, 'vpn_link': vpn_link}
|
||||
expires_at = None
|
||||
panel_user = next((u for u in data.get('users', []) if u['id'] == user['id']), None)
|
||||
if panel_user:
|
||||
expires_at = panel_user.get('expiration_date')
|
||||
return {'config': config, 'vpn_link': vpn_link, 'expires_at': expires_at}
|
||||
except Exception as e:
|
||||
logger.exception("Error getting my connection config")
|
||||
return JSONResponse({'error': str(e)}, status_code=500)
|
||||
|
||||
Reference in New Issue
Block a user