## Copyright © 2026 Olaf Kolkman ## SPDX-License-Identifier: GPL-3.0-or-later import json from datetime import datetime, timedelta, timezone 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 from backend.app.services.email_service import ( get_smtp_settings, save_smtp_settings, send_test_email, send_verification_email, smtp_configured, ) from backend.app.services.email_verification import create_verification_token from backend.app.services.theme_service import THEMES, get_enabled_themes, save_enabled_themes from backend.app.services.secret_store import encrypt_secret from backend.app.core.config import settings from backend.app.services.audit_service import record_audit_event 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 class AdminSmtpUpdate(BaseModel): smtp_host: str smtp_port: int = 587 smtp_username: str = '' smtp_password: str = '' smtp_from: str smtp_use_tls: bool = True class AdminThemesUpdate(BaseModel): themes: list[str] def validate_smtp_values(payload: AdminSmtpUpdate, current: dict | None = None) -> dict: smtp_host = payload.smtp_host.strip() smtp_from = payload.smtp_from.strip() if not smtp_host or not smtp_from: raise HTTPException(status_code=422, detail='SMTP host and sender address are required') if not 1 <= payload.smtp_port <= 65535: raise HTTPException(status_code=422, detail='SMTP port must be between 1 and 65535') return { 'smtp_host': smtp_host, 'smtp_port': payload.smtp_port, 'smtp_username': payload.smtp_username.strip(), 'smtp_password': payload.smtp_password or (current or {}).get('smtp_password', ''), 'smtp_from': smtp_from, 'smtp_use_tls': payload.smtp_use_tls, } 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'], 'email_verified': bool(row['email_verified']), } @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, email_verified FROM users ORDER BY username' ).fetchall() return [public_user(row) for row in rows] @router.post('/users/{user_id}/otp/reset') def reset_user_otp(user_id: str, current_user: dict = Depends(require_admin)): with get_connection() as conn: target = conn.execute('SELECT id FROM users WHERE id = ?', (user_id,)).fetchone() if target is None: raise HTTPException(status_code=404, detail='User not found') conn.execute( 'UPDATE users SET otp_enabled = 0, otp_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?', (user_id,), ) conn.execute('DELETE FROM otp_recovery_codes WHERE user_id = ?', (user_id,)) conn.commit() record_audit_event(current_user['id'], 'otp_reset', 'user', user_id) return {'status': 'otp_reset', 'enabled': False, 'user_id': user_id} @router.post('/users', status_code=201) def create_user(payload: AdminUserCreate, current_user: 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, email_verified) VALUES (?, ?, ?, ?, ?, 0) ''', (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, email_verified FROM users WHERE rowid = last_insert_rowid()' ).fetchone() token = create_verification_token(row['id']) verification_url = f'{settings.public_url}/api/auth/verify-email?token={token}' if smtp_configured(): try: send_verification_email(row['email'], row['username'], verification_url) except Exception as error: raise HTTPException(status_code=503, detail=f'User created but verification email could not be sent: {error}') from error record_audit_event(current_user['id'], 'user_created', 'user', row['id'], details={'is_admin': bool(payload.is_admin)}) return public_user(row) def public_smtp_settings(values: dict) -> dict: return { 'smtp_host': values['smtp_host'], 'smtp_port': values['smtp_port'], 'smtp_username': values['smtp_username'], 'smtp_from': values['smtp_from'], 'smtp_use_tls': values['smtp_use_tls'], 'password_configured': bool(values['smtp_password']), } @router.get('/smtp') def get_admin_smtp_settings(_: dict = Depends(require_admin)): return public_smtp_settings(get_smtp_settings()) @router.get('/themes') def get_admin_themes(_: dict = Depends(require_admin)): return {'themes': THEMES, 'enabled': get_enabled_themes()} @router.put('/themes') def update_admin_themes(payload: AdminThemesUpdate, current_user: dict = Depends(require_admin)): try: enabled = save_enabled_themes(payload.themes) except ValueError as error: raise HTTPException(status_code=422, detail=str(error)) from error record_audit_event(current_user['id'], 'themes_updated', 'application', details={'themes': enabled}) return {'themes': THEMES, 'enabled': enabled} @router.put('/smtp') def update_admin_smtp_settings(payload: AdminSmtpUpdate, current_user: dict = Depends(require_admin)): current = get_smtp_settings() values = validate_smtp_values(payload, current) save_smtp_settings(values) record_audit_event(current_user['id'], 'smtp_settings_updated', 'application', details={'host': values['smtp_host'], 'port': values['smtp_port'], 'username': values['smtp_username'], 'tls': values['smtp_use_tls']}) return public_smtp_settings(values) @router.post('/smtp/test') def validate_admin_smtp(payload: AdminSmtpUpdate, current_user: dict = Depends(require_admin)): values = validate_smtp_values(payload, get_smtp_settings()) now = datetime.now(timezone.utc) with get_connection() as conn: row = conn.execute('SELECT value FROM app_settings WHERE name = ?', ('admin_smtp_mail_rate',)).fetchone() rate = json.loads(row['value']) if row else {} last_sent = datetime.fromisoformat(rate['last_sent']) if rate.get('last_sent') else None cooldown_until = datetime.fromisoformat(rate['cooldown_until']) if rate.get('cooldown_until') else None if cooldown_until and now >= cooldown_until: rate = {} last_sent = None cooldown_until = None if cooldown_until and now < cooldown_until: retry_after = int((cooldown_until - now).total_seconds()) + 1 raise HTTPException(status_code=429, detail=f'SMTP validation limit reached. Try again in {retry_after} seconds.', headers={'Retry-After': str(retry_after)}) if last_sent and now - last_sent < timedelta(seconds=20): retry_after = int((timedelta(seconds=20) - (now - last_sent)).total_seconds()) + 1 raise HTTPException(status_code=429, detail=f'Please wait {retry_after} seconds before sending another validation email.', headers={'Retry-After': str(retry_after)}) try: send_test_email(current_user['email'], values) except Exception as error: raise HTTPException(status_code=503, detail=f'SMTP validation failed: {error}') from error sends = int(rate.get('sends', 0)) + 1 updated_rate = {'sends': sends, 'last_sent': now.isoformat()} if sends >= 5: updated_rate['cooldown_until'] = (now + timedelta(minutes=2)).isoformat() with get_connection() as conn: conn.execute( '''INSERT INTO app_settings (name, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(name) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP''', ('admin_smtp_mail_rate', json.dumps(updated_rate)), ) conn.commit() next_allowed = datetime.fromisoformat(updated_rate.get('cooldown_until')) if sends >= 5 else now + timedelta(seconds=20) return { 'status': 'sent', 'message': f'SMTP validation email sent to {current_user["email"]}.', 'sends_remaining': max(0, 5 - sends), 'cooldown_seconds': 120 if sends >= 5 else 0, 'next_allowed_at': next_allowed.isoformat(), } @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, email_verified FROM users WHERE id = ?', (user_id,), ).fetchone() record_audit_event(current_user['id'], 'user_privileges_updated', 'user', user_id, details={'is_admin': bool(payload.is_admin)}) 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() record_audit_event(current_user['id'], 'user_deleted', 'user', user_id) return {'status': 'deleted', 'id': user_id} @router.delete('/labels/{label_id}') def admin_delete_label(label_id: str, current_user: dict = Depends(require_admin)): if not delete_label(label_id, is_admin=True): raise HTTPException(status_code=404, detail='Label not found') record_audit_event(current_user['id'], 'label_deleted', 'label', label_id) 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, current_user: 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) for secret_name in ('access_token', 'client_secret', 'smtp_password'): if config.get(secret_name): config[secret_name] = encrypt_secret(config[secret_name]) 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() record_audit_event(current_user['id'], 'plugin_updated', 'plugin', plugin_name, details={'enabled': enabled}) return { 'name': plugin_name, 'enabled': enabled, 'config': config, }