392 lines
15 KiB
Python
392 lines
15 KiB
Python
## 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,
|
|
}
|