Add invite links with admin-configurable config creation limits.

Admins can create shareable links that allow redeeming a VPN config a set number of times (or unlimited).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-26 01:23:16 +03:00
co-authored by Cursor
parent 5386c4d40e
commit 7b3d8aac34
11 changed files with 1197 additions and 0 deletions
+19
View File
@@ -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;
+61
View File
@@ -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)