diff --git a/app.py b/app.py index c06cf76..3a97084 100644 --- a/app.py +++ b/app.py @@ -1879,6 +1879,39 @@ class GuestCreateRequest(BaseModel): name: str = 'Guest VPN' +class InviteCreateRequest(BaseModel): + name: str = 'Invite' + max_uses: int = 1 # 0 = unlimited + user_id: str = '' + protocol: str = 'xui' + server_id: int = 0 + xui_inbound_id: int = 0 + password: Optional[str] = None + expires_at: Optional[str] = None + note: str = '' + enabled: bool = True + + +class InviteUpdateRequest(BaseModel): + name: Optional[str] = None + max_uses: Optional[int] = None + user_id: Optional[str] = None + protocol: Optional[str] = None + server_id: Optional[int] = None + xui_inbound_id: Optional[int] = None + password: Optional[str] = None + clear_password: bool = False + expires_at: Optional[str] = None + clear_expires: bool = False + note: Optional[str] = None + enabled: Optional[bool] = None + reset_used: bool = False + + +class InviteRedeemRequest(BaseModel): + name: str = 'Invite VPN' + + class TunnelStartRequest(BaseModel): authtoken: Optional[str] = None @@ -4207,6 +4240,363 @@ async def api_guest_regenerate_token(request: Request): return {'status': 'success', 'token': guest['token']} +# ======================== Invite links (limited config creation) ======================== + +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 + return { + 'id': link.get('id'), + 'name': link.get('name') or 'Invite', + 'token': link.get('token'), + 'enabled': bool(link.get('enabled', True)), + 'max_uses': max_uses, + 'used_count': used, + 'remaining': remaining, + 'unlimited': max_uses <= 0, + 'expired': expired, + '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), + '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, + } + + +def _find_invite(data: dict, token: str) -> Optional[dict]: + return next((x for x in data.get('invite_links', []) if x.get('token') == token), None) + + +def _invite_auth_ok(link: dict, request: Request) -> bool: + if not link.get('password_hash'): + return True + return bool(request.session.get(f"invite_auth_{link.get('token')}")) + + +async def _create_config_for_protocol( + data: dict, + *, + protocol: str, + name: str, + server_id: int = 0, + xui_inbound_id: Optional[int] = None, +) -> dict: + """Create VPN client; returns {client_id, config, protocol, server_id}.""" + protocol = protocol or 'xui' + if protocol_base(protocol) == 'xui': + from managers.xui_api import xui_create_vless_config + created = await xui_create_vless_config( + data.get('settings', {}), + name=name, + inbound_id=xui_inbound_id, + ) + return { + 'client_id': created['client_id'], + 'config': created.get('config') or '', + 'protocol': 'xui', + 'server_id': 0, + } + + sid = int(server_id or 0) + if sid >= len(data['servers']): + raise RuntimeError('Server not found') + server = data['servers'][sid] + proto_info = server.get('protocols', {}).get(protocol, {}) + port = proto_info.get('port', '55424') + ssh = get_ssh(server) + await asyncio.to_thread(ssh.connect) + try: + manager = get_protocol_manager(ssh, protocol) + if protocol_base(protocol) == 'wireguard': + result = await asyncio.to_thread(manager.add_client, name, server['host']) + else: + result = await asyncio.to_thread( + _manager_call, manager, 'add_client', + protocol, name, server['host'], port, + ) + finally: + await asyncio.to_thread(ssh.disconnect) + if not result.get('client_id'): + raise RuntimeError('Failed to create config') + return { + 'client_id': result['client_id'], + 'config': result.get('config') or '', + 'protocol': protocol, + 'server_id': sid, + } + + +@app.get('/invites', response_class=HTMLResponse, tags=["System Templates"]) +async def invites_page(request: Request): + user = get_current_user(request) + if not user: + return RedirectResponse(url='/login', status_code=302) + if user['role'] not in ('admin', 'support'): + return RedirectResponse(url='/my', status_code=302) + data = load_data() + return tpl( + request, + 'invites.html', + invites=[_invite_public_view(x) for x in data.get('invite_links', [])], + users=data.get('users', []), + servers=data.get('servers', []), + xui_configured=bool(((data.get('settings') or {}).get('sync') or {}).get('xui_url')), + ) + + +@app.get('/api/invites', tags=["Invites"]) +async def api_list_invites(request: Request): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + data = load_data() + return {'invites': [_invite_public_view(x) for x in data.get('invite_links', [])]} + + +@app.post('/api/invites', tags=["Invites"]) +async def api_create_invite(request: Request, req: InviteCreateRequest): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + if req.max_uses < 0: + return JSONResponse({'error': 'max_uses 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) + link = { + 'id': str(uuid.uuid4()), + 'name': (req.name or 'Invite').strip() or 'Invite', + 'token': secrets.token_urlsafe(16), + 'enabled': bool(req.enabled), + 'max_uses': int(req.max_uses), + 'used_count': 0, + 'user_id': req.user_id or '', + 'protocol': req.protocol or 'xui', + 'server_id': int(req.server_id or 0), + 'xui_inbound_id': int(req.xui_inbound_id or 0), + 'password_hash': hash_password(req.password) if req.password else None, + 'expires_at': req.expires_at or None, + 'note': req.note or '', + 'created_at': datetime.now().isoformat(), + } + data.setdefault('invite_links', []).append(link) + save_data(data) + view = _invite_public_view(link) + return {'status': 'success', 'invite': view, 'url': f"/invite/{link['token']}"} + + +@app.put('/api/invites/{invite_id}', tags=["Invites"]) +async def api_update_invite(request: Request, invite_id: str, req: InviteUpdateRequest): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + data = load_data() + link = next((x for x in data.get('invite_links', []) if x.get('id') == invite_id), None) + if not link: + return JSONResponse({'error': 'Not found'}, status_code=404) + if req.name is not None: + link['name'] = req.name.strip() or link['name'] + if req.max_uses is not None: + if req.max_uses < 0: + return JSONResponse({'error': 'max_uses must be >= 0'}, status_code=400) + link['max_uses'] = int(req.max_uses) + if req.user_id is not None: + 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) + link['user_id'] = req.user_id + if req.protocol is not None: + link['protocol'] = req.protocol + if req.server_id is not None: + 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.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 + save_data(data) + return {'status': 'success', 'invite': _invite_public_view(link)} + + +@app.delete('/api/invites/{invite_id}', tags=["Invites"]) +async def api_delete_invite(request: Request, invite_id: str): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + data = load_data() + before = len(data.get('invite_links', [])) + data['invite_links'] = [x for x in data.get('invite_links', []) if x.get('id') != invite_id] + if len(data['invite_links']) == before: + return JSONResponse({'error': 'Not found'}, status_code=404) + save_data(data) + return {'status': 'success'} + + +@app.get('/invite/{token}', response_class=HTMLResponse, tags=["System Templates"]) +async def invite_public_page(token: str, request: Request): + data = load_data() + link = _find_invite(data, token) + lang = request.cookies.get('lang', 'ru') + if not link: + return HTMLResponse( + f"
{_t('invite_not_found_desc', lang)}
", + status_code=404, + ) + view = _invite_public_view(link) + need_password = bool(link.get('password_hash')) and not _invite_auth_ok(link, request) + return tpl( + request, + 'invite.html', + invite=view, + need_password=need_password, + token=token, + ) + + +@app.post('/api/invite/{token}/auth', tags=["Invites"]) +async def api_invite_auth(token: str, req: ShareAuthRequest, request: Request): + data = load_data() + link = _find_invite(data, token) + if not link or not link.get('enabled'): + return JSONResponse({'error': 'Link expired or disabled'}, status_code=404) + if not link.get('password_hash'): + request.session[f'invite_auth_{token}'] = True + return {'status': 'success'} + if verify_password(req.password, link.get('password_hash') or ''): + request.session[f'invite_auth_{token}'] = True + return {'status': 'success'} + lang = request.cookies.get('lang', 'ru') + return JSONResponse({'error': _t('wrong_invite_password', lang)}, status_code=401) + + +@app.get('/api/invite/{token}/info', tags=["Invites"]) +async def api_invite_info(token: str, request: Request): + data = load_data() + link = _find_invite(data, token) + if not link: + return JSONResponse({'error': 'Not found'}, status_code=404) + if not _invite_auth_ok(link, request): + return JSONResponse({'error': 'Unauthorized'}, status_code=401) + view = _invite_public_view(link) + # Don't leak admin note / ids beyond what's needed + return { + 'name': view['name'], + 'available': view['available'], + 'enabled': view['enabled'], + 'expired': view['expired'], + 'exhausted': view['exhausted'], + 'unlimited': view['unlimited'], + 'remaining': view['remaining'], + 'max_uses': view['max_uses'], + 'used_count': view['used_count'], + 'protocol': view['protocol'], + } + + +@app.post('/api/invite/{token}/create', tags=["Invites"]) +async def api_invite_create_config(token: str, req: InviteRedeemRequest, request: Request): + data = load_data() + link = _find_invite(data, token) + if not link: + return JSONResponse({'error': 'Not found'}, status_code=404) + if not _invite_auth_ok(link, request): + return JSONResponse({'error': 'Unauthorized'}, status_code=401) + + view = _invite_public_view(link) + if not view['available']: + if view['expired']: + return JSONResponse({'error': 'Invite link expired'}, status_code=403) + if view['exhausted']: + return JSONResponse({'error': 'Invite link has no remaining uses'}, status_code=403) + return JSONResponse({'error': 'Invite link is disabled'}, status_code=403) + + holder_id = link.get('user_id') or '' + if not holder_id or not any(u['id'] == holder_id for u in data['users']): + return JSONResponse({'error': 'Invite holder user is not configured'}, status_code=400) + + # Reserve a use slot under lock + async with DATA_LOCK: + data = load_data() + link = _find_invite(data, token) + if not link: + return JSONResponse({'error': 'Not found'}, status_code=404) + view = _invite_public_view(link) + if not view['available']: + return JSONResponse({'error': 'Invite link is no longer available'}, status_code=403) + link['used_count'] = int(link.get('used_count') or 0) + 1 + save_data(data) + + name = (req.name or 'Invite VPN').strip() or 'Invite VPN' + name = f"{name}_{secrets.token_hex(3)}" + try: + data = load_data() + created = await _create_config_for_protocol( + data, + 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, + ) + conn = { + 'id': str(uuid.uuid4()), + 'user_id': holder_id, + 'server_id': created['server_id'], + 'protocol': created['protocol'], + 'client_id': created['client_id'], + 'name': name, + 'created_at': datetime.now().isoformat(), + } + async with DATA_LOCK: + data = load_data() + data.setdefault('user_connections', []).append(conn) + save_data(data) + config = created.get('config') or '' + vpn_link = generate_vpn_link(config) if config else '' + data = load_data() + link = _find_invite(data, token) or link + return { + 'status': 'success', + 'config': config, + 'vpn_link': vpn_link, + 'connection': conn, + 'invite': _invite_public_view(link), + } + except Exception as e: + # Roll back reserved use + try: + async with DATA_LOCK: + data = load_data() + link = _find_invite(data, token) + if link and int(link.get('used_count') or 0) > 0: + link['used_count'] = int(link['used_count']) - 1 + save_data(data) + except Exception: + logger.exception('Failed to roll back invite use count') + logger.exception('Error creating invite config') + return JSONResponse({'error': str(e)}, status_code=500) + + @app.post('/api/my/connections/{connection_id}/config', tags=["Self-service"]) async def api_my_connection_config(request: Request, connection_id: str): user = get_current_user(request) @@ -4703,6 +5093,11 @@ async def api_backup_restore(request: Request, file: UploadFile = File(...)): if not isinstance(backup_data['servers'], list) or not isinstance(backup_data['users'], list): return JSONResponse({'error': 'Invalid structure: servers and users must be lists'}, status_code=400) + backup_data.setdefault('user_connections', []) + backup_data.setdefault('api_tokens', []) + backup_data.setdefault('invite_links', []) + backup_data.setdefault('settings', {}) + # Save the new data async with DATA_LOCK: save_data(backup_data) diff --git a/db/schema.sql b/db/schema.sql index 853a582..bc899a8 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -72,5 +72,24 @@ CREATE TABLE IF NOT EXISTS tunnel_state ( data JSONB NOT NULL DEFAULT '{}'::jsonb ); +CREATE TABLE IF NOT EXISTS invite_links ( + id UUID PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + token TEXT NOT NULL UNIQUE, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + max_uses INTEGER NOT NULL DEFAULT 1, + used_count INTEGER NOT NULL DEFAULT 0, + user_id UUID, + protocol TEXT NOT NULL DEFAULT 'xui', + server_id INTEGER NOT NULL DEFAULT 0, + xui_inbound_id INTEGER NOT NULL DEFAULT 0, + password_hash TEXT, + expires_at TIMESTAMPTZ, + note TEXT, + created_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_invite_links_token ON invite_links(token); + INSERT INTO settings (id, data) VALUES (1, '{}'::jsonb) ON CONFLICT (id) DO NOTHING; diff --git a/db/store.py b/db/store.py index 4e7c515..77e881f 100644 --- a/db/store.py +++ b/db/store.py @@ -195,6 +195,25 @@ def _row_to_token(row) -> dict: } +def _row_to_invite(row) -> dict: + return { + 'id': str(row['id']), + 'name': row['name'] or '', + 'token': row['token'] or '', + 'enabled': bool(row['enabled']), + 'max_uses': int(row['max_uses'] or 0), + 'used_count': int(row['used_count'] or 0), + 'user_id': str(row['user_id']) if row['user_id'] else '', + 'protocol': row['protocol'] or 'xui', + 'server_id': int(row['server_id'] or 0), + 'xui_inbound_id': int(row['xui_inbound_id'] or 0), + 'password_hash': row['password_hash'], + 'expires_at': _ts_iso(row['expires_at']), + 'note': row['note'] or '', + 'created_at': _ts_iso(row['created_at']), + } + + def load_data() -> dict: init_schema() pool = get_pool() @@ -230,6 +249,14 @@ def load_data() -> dict: ) api_tokens = [_row_to_token(r) for r in cur.fetchall()] + 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 ' + 'ORDER BY created_at DESC NULLS LAST, name' + ) + invite_links = [_row_to_invite(r) for r in cur.fetchall()] + cur.execute('SELECT data FROM settings WHERE id = 1') settings_row = cur.fetchone() settings = _merge_settings(settings_row['data'] if settings_row else None) @@ -239,6 +266,7 @@ def load_data() -> dict: 'users': users, 'user_connections': user_connections, 'api_tokens': api_tokens, + 'invite_links': invite_links, 'settings': settings, } @@ -250,17 +278,23 @@ def save_data(data: dict) -> None: users = data.get('users') or [] connections = data.get('user_connections') or [] tokens = data.get('api_tokens') or [] + invite_links = data.get('invite_links') or [] settings = _merge_settings(data.get('settings')) # Keep only connections whose user still exists user_ids = {str(u.get('id')) for u in users if u.get('id')} connections = [c for c in connections if str(c.get('user_id')) in user_ids] tokens = [t for t in tokens if str(t.get('user_id')) in user_ids] + # Clear holder if user was deleted + for link in invite_links: + if link.get('user_id') and str(link['user_id']) not in user_ids: + link['user_id'] = '' pool = get_pool() with pool.connection() as conn: with conn.cursor() as cur: # Order matters for FKs: children first on delete, parents first on insert + cur.execute('DELETE FROM invite_links') cur.execute('DELETE FROM api_tokens') cur.execute('DELETE FROM user_connections') cur.execute('DELETE FROM users') @@ -352,6 +386,32 @@ def save_data(data: dict) -> None: ), ) + for link in invite_links: + uid = link.get('user_id') or None + cur.execute( + '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)', + ( + _as_uuid(link['id']), + link.get('name') or '', + link.get('token') or '', + bool(link.get('enabled', True)), + int(link.get('max_uses') or 0), + int(link.get('used_count') or 0), + _as_uuid(uid) if uid else None, + link.get('protocol') or 'xui', + int(link.get('server_id') or 0), + int(link.get('xui_inbound_id') or 0), + link.get('password_hash'), + _parse_ts(link.get('expires_at')), + link.get('note') or '', + _parse_ts(link.get('created_at')), + ), + ) + cur.execute( 'INSERT INTO settings (id, data) VALUES (1, %s) ' 'ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data', @@ -393,6 +453,7 @@ def import_from_json_file(path: str | os.PathLike, *, backup: bool = True) -> bo data.setdefault('users', []) data.setdefault('user_connections', []) data.setdefault('api_tokens', []) + data.setdefault('invite_links', []) data.setdefault('settings', {}) save_data(data) diff --git a/templates/base.html b/templates/base.html index 8f5e7f4..e256479 100644 --- a/templates/base.html +++ b/templates/base.html @@ -45,6 +45,7 @@ {% if current_user.role in ['admin', 'support'] %} {{ _('nav_servers') }} {{ _('nav_users') }} + {{ _('nav_invites') }} {% endif %} {% if current_user.role == 'admin' %} {{ _('nav_settings') }} diff --git a/templates/invite.html b/templates/invite.html new file mode 100644 index 0000000..b00d4c6 --- /dev/null +++ b/templates/invite.html @@ -0,0 +1,207 @@ +{% extends "base.html" %} + +{% block title_extra %} — {{ _('invite_public_title') }}{% endblock %} + +{% block content %} +{{ _('invite_public_subtitle') }}
+{{ _('invite_protected_desc') }}
+ +{{ _('invite_expired') }}
+ {% elif invite.exhausted %} +{{ _('invite_exhausted') }}
+ {% else %} +{{ _('disabled') }}
+ {% endif %} +{{ _('invites_hint') }}
+ +