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