## Copyright © 2026 Olaf Kolkman ## SPDX-License-Identifier: GPL-3.0-or-later import json from uuid import uuid4 from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from backend.app.api.dependencies import require_admin from backend.app.database import get_connection, hash_password from backend.app.services.link_service import delete_label router = APIRouter() class AdminPluginUpdate(BaseModel): enabled: bool | None = None config: dict | None = None class AdminUserCreate(BaseModel): username: str email: str password: str is_admin: bool = False class AdminUserUpdate(BaseModel): is_admin: bool def public_user(row): return { 'id': row['id'], 'username': row['username'], 'email': row['email'], 'is_admin': bool(row['is_admin']), 'avatar_url': row['avatar_url'], 'bio': row['bio'], 'created_at': row['created_at'], } @router.get('/users') def list_users(_: dict = Depends(require_admin)): with get_connection() as conn: rows = conn.execute( 'SELECT id, username, email, is_admin, avatar_url, bio, created_at FROM users ORDER BY username' ).fetchall() return [public_user(row) for row in rows] @router.post('/users', status_code=201) def create_user(payload: AdminUserCreate, _: dict = Depends(require_admin)): username = payload.username.strip() email = payload.email.strip() if not username or not email or len(payload.password) < 8: raise HTTPException(status_code=422, detail='Username, email, and a password of at least 8 characters are required') with get_connection() as conn: try: cursor = conn.execute( ''' INSERT INTO users (id, username, email, password_hash, is_admin) VALUES (?, ?, ?, ?, ?) ''', (str(uuid4()), username, email, hash_password(payload.password), int(payload.is_admin)), ) conn.commit() except Exception as error: if 'UNIQUE constraint failed' in str(error): raise HTTPException(status_code=409, detail='Username or email already exists') from error raise row = conn.execute( 'SELECT id, username, email, is_admin, avatar_url, bio, created_at FROM users WHERE rowid = last_insert_rowid()' ).fetchone() return public_user(row) @router.put('/users/{user_id}') def update_user_privileges( user_id: str, payload: AdminUserUpdate, current_user: dict = Depends(require_admin), ): if user_id == current_user['id']: raise HTTPException(status_code=400, detail='You cannot change your own administrator status') with get_connection() as conn: target = conn.execute( 'SELECT id, is_admin FROM users WHERE id = ?', (user_id,), ).fetchone() if target is None: raise HTTPException(status_code=404, detail='User not found') if target['is_admin'] and not payload.is_admin: admin_count = conn.execute( 'SELECT COUNT(*) AS count FROM users WHERE is_admin = 1' ).fetchone()['count'] if admin_count <= 1: raise HTTPException(status_code=400, detail='At least one administrator is required') conn.execute( 'UPDATE users SET is_admin = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', (int(payload.is_admin), user_id), ) conn.commit() row = conn.execute( 'SELECT id, username, email, is_admin, avatar_url, bio, created_at FROM users WHERE id = ?', (user_id,), ).fetchone() return public_user(row) @router.delete('/users/{user_id}') def delete_user(user_id: str, current_user: dict = Depends(require_admin)): if user_id == current_user['id']: raise HTTPException(status_code=400, detail='You cannot delete your own account') with get_connection() as conn: target = conn.execute('SELECT id, is_admin FROM users WHERE id = ?', (user_id,)).fetchone() if target is None: raise HTTPException(status_code=404, detail='User not found') if target['is_admin']: admins = conn.execute('SELECT COUNT(*) AS count FROM users WHERE is_admin = 1').fetchone()['count'] if admins <= 1: raise HTTPException(status_code=400, detail='Cannot delete the last administrator') conn.execute('DELETE FROM tokens WHERE user_id = ?', (user_id,)) conn.execute('DELETE FROM user_plugin_config WHERE user_id = ?', (user_id,)) conn.execute('DELETE FROM links WHERE user_id = ?', (user_id,)) conn.execute('DELETE FROM users WHERE id = ?', (user_id,)) conn.commit() return {'status': 'deleted', 'id': user_id} @router.delete('/labels/{label_id}') def admin_delete_label(label_id: str, _: dict = Depends(require_admin)): if not delete_label(label_id, is_admin=True): raise HTTPException(status_code=404, detail='Label not found') return {'status': 'deleted', 'id': label_id} @router.get('/labels') def admin_list_labels(_: dict = Depends(require_admin)): with get_connection() as conn: rows = conn.execute( ''' SELECT tags.id, tags.name, tags.created_by, users.username AS creator FROM tags LEFT JOIN users ON users.id = tags.created_by ORDER BY tags.name ''' ).fetchall() return [dict(row) for row in rows] @router.get('/plugins') def list_plugins(_: dict = Depends(require_admin)): with get_connection() as conn: rows = conn.execute( 'SELECT id, name, version, enabled, config FROM plugins ORDER BY name' ).fetchall() return [ { 'id': row['id'], 'name': row['name'], 'version': row['version'], 'enabled': bool(row['enabled']), 'config': row['config'], } for row in rows ] @router.get('/plugins/{plugin_name}') def get_plugin(plugin_name: str, _: dict = Depends(require_admin)): with get_connection() as conn: row = conn.execute( 'SELECT id, name, version, enabled, config FROM plugins WHERE name = ?', (plugin_name,), ).fetchone() if row is None: raise HTTPException(status_code=404, detail='Plugin not found') return { 'id': row['id'], 'name': row['name'], 'version': row['version'], 'enabled': bool(row['enabled']), 'config': json.loads(row['config']) if row['config'] else {}, } @router.put('/plugins/{plugin_name}') def update_plugin( plugin_name: str, payload: AdminPluginUpdate, _: dict = Depends(require_admin), ): with get_connection() as conn: current = conn.execute( 'SELECT id, name, version, enabled, config FROM plugins WHERE name = ?', (plugin_name,), ).fetchone() if current is None: raise HTTPException(status_code=404, detail='Plugin not found') enabled = payload.enabled if payload.enabled is not None else bool(current['enabled']) config = json.loads(current['config']) if current['config'] else {} if payload.config is not None: config.update(payload.config) conn.execute( ''' UPDATE plugins SET enabled = ?, config = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ? ''', (1 if enabled else 0, json.dumps(config, ensure_ascii=False), plugin_name), ) conn.commit() return { 'name': plugin_name, 'enabled': enabled, 'config': config, }