Files
Link-Log/backend/app/api/user_config.py
T

439 lines
19 KiB
Python

## Copyright © 2026 Olaf Kolkman
## SPDX-License-Identifier: GPL-3.0-or-later
import json
import warnings
from io import BytesIO
from datetime import datetime, timedelta, timezone
from uuid import uuid4
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
from PIL import Image, UnidentifiedImageError
from pydantic import BaseModel
from backend.app.api.dependencies import get_current_user
from backend.app.database import AVATARS_DIR, get_connection, hash_password, verify_password
from backend.app.services.link_service import create_label, delete_label, list_user_labels, update_label
from backend.app.services.otp_service import consume_recovery_code, create_recovery_codes, create_secret, provisioning_uri, verify_code
from backend.app.services.email_addresses import add_user_email_address, create_email_verification, list_user_email_addresses
from backend.app.services.email_service import send_verification_email, smtp_configured
from backend.app.core.config import settings
from backend.app.services.secret_store import decrypt_secret, encrypt_secret
from backend.app.services.audit_service import record_audit_event
from backend.app.core.errors import public_error, redacted_error, request_id
import logging
router = APIRouter()
logger = logging.getLogger(__name__)
MAX_AVATAR_BYTES = 2 * 1024 * 1024
MAX_AVATAR_PIXELS = 25_000_000
class UserConfigUpdate(BaseModel):
bio: str | None = None
class PasswordUpdate(BaseModel):
current_password: str
new_password: str
class OtpUpdate(BaseModel):
action: str
code: str | None = None
current_password: str | None = None
recovery_code: str | None = None
class AdditionalEmail(BaseModel):
email: str
class OtpRecovery(BaseModel):
current_password: str
recovery_code: str
class UserPluginConfigUpdate(BaseModel):
instance: str | None = None
access_token: str | None = None
post_prefix: str | None = None
hashtag: str | None = None
class LabelUpdate(BaseModel):
name: str
@router.get('/me')
def get_current_user_profile(user: dict = Depends(get_current_user)):
with get_connection() as conn:
row = conn.execute(
'SELECT * FROM users WHERE id = ?',
(user['id'],),
).fetchone()
if row is None:
raise HTTPException(status_code=404, detail='User not found')
profile = dict(row)
profile.pop('otp_secret', None)
profile.pop('password_hash', None)
return profile
@router.put('/me')
def update_current_user_profile(
payload: UserConfigUpdate,
user: dict = Depends(get_current_user),
):
with get_connection() as conn:
current = conn.execute('SELECT * FROM users WHERE id = ?', (user['id'],)).fetchone()
if current is None:
raise HTTPException(status_code=404, detail='User not found')
bio = payload.bio if payload.bio is not None else current['bio']
conn.execute(
'''
UPDATE users
SET email = ?, bio = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
''',
(current['email'], bio, user['id']),
)
conn.commit()
return {'status': 'updated'}
@router.put('/password')
def update_password(payload: PasswordUpdate, user: dict = Depends(get_current_user)):
if len(payload.new_password) < 8:
raise HTTPException(status_code=422, detail='New password must be at least 8 characters')
if not verify_password(payload.current_password, user['password_hash']):
raise HTTPException(status_code=400, detail='Current password is incorrect')
with get_connection() as conn:
conn.execute(
'UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
(hash_password(payload.new_password), user['id']),
)
conn.commit()
record_audit_event(user['id'], 'password_changed', 'user', user['id'])
return {'status': 'password_updated'}
@router.get('/otp')
def get_otp(user: dict = Depends(get_current_user)):
return {'enabled': bool(user['otp_enabled'])}
@router.post('/otp/setup')
def setup_otp(user: dict = Depends(get_current_user)):
if user['otp_enabled']:
raise HTTPException(status_code=409, detail='One-time password is already enabled')
secret = create_secret()
with get_connection() as conn:
conn.execute('UPDATE users SET otp_secret = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', (encrypt_secret(secret), user['id']))
conn.commit()
record_audit_event(user['id'], 'otp_enrolled', 'user', user['id'])
return {
'secret': secret,
'otpauth_url': provisioning_uri(secret, user['username']),
'recovery_codes': create_recovery_codes(user['id']),
}
@router.post('/otp')
def update_otp(payload: OtpUpdate, user: dict = Depends(get_current_user)):
if payload.action not in {'enable', 'disable'}:
raise HTTPException(status_code=422, detail='OTP action must be enable or disable')
if payload.action == 'disable' and not payload.current_password:
raise HTTPException(status_code=400, detail='Current password is required to disable one-time password')
if payload.action == 'disable' and not verify_password(payload.current_password, user['password_hash']):
raise HTTPException(status_code=400, detail='Current password is incorrect')
valid_code = verify_code(decrypt_secret(user['otp_secret']), payload.code)
valid_recovery_code = payload.action == 'disable' and payload.recovery_code and consume_recovery_code(user['id'], payload.recovery_code)
if not valid_code and not valid_recovery_code:
raise HTTPException(status_code=400, detail='Invalid one-time password')
with get_connection() as conn:
if payload.action == 'enable':
conn.execute('UPDATE users SET otp_enabled = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?', (user['id'],))
else:
conn.execute('UPDATE users SET otp_enabled = 0, otp_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?', (user['id'],))
conn.commit()
record_audit_event(user['id'], f'otp_{payload.action}d', 'user', user['id'])
return {'status': 'updated', 'enabled': payload.action == 'enable'}
@router.post('/otp/recover')
def recover_otp(payload: OtpRecovery, user: dict = Depends(get_current_user)):
if not verify_password(payload.current_password, user['password_hash']):
raise HTTPException(status_code=400, detail='Current password is incorrect')
if not consume_recovery_code(user['id'], payload.recovery_code):
raise HTTPException(status_code=400, detail='Recovery code is invalid or already used')
with get_connection() as conn:
conn.execute(
'UPDATE users SET otp_enabled = 0, otp_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
(user['id'],),
)
conn.commit()
record_audit_event(user['id'], 'otp_recovered', 'user', user['id'])
return {'status': 'otp_recovered', 'enabled': False}
@router.get('/emails')
def get_additional_emails(user: dict = Depends(get_current_user)):
return [{'email': user['email'], 'verified': bool(user['email_verified']), 'primary': True}] + list_user_email_addresses(user['id'])
@router.post('/emails', status_code=201)
def add_additional_email(payload: AdditionalEmail, request: Request, user: dict = Depends(get_current_user)):
email = payload.email.strip().lower()
if email == user['email'].lower():
raise HTTPException(status_code=409, detail='This is already the primary email address')
with get_connection() as conn:
if conn.execute('SELECT 1 FROM users WHERE lower(email) = ?', (email,)).fetchone():
raise HTTPException(status_code=409, detail='Email address already exists')
additional_count = conn.execute(
'SELECT COUNT(*) AS count FROM user_email_addresses WHERE user_id = ?',
(user['id'],),
).fetchone()['count']
if additional_count >= 5:
raise HTTPException(status_code=422, detail='You can add at most five additional email addresses')
try:
address = add_user_email_address(user['id'], email)
except ValueError as error:
raise HTTPException(status_code=409, detail=str(error)) from error
if smtp_configured():
email_address, token = create_email_verification(user['id'], address['id'])
verification_url = f'{settings.public_url}/api/auth/verify-additional-email?token={token}'
try:
send_verification_email(email_address, user['username'], verification_url)
except Exception as error:
logger.error('Additional email verification failed request_id=%s error=%s', request_id(request), redacted_error(error))
raise HTTPException(status_code=503, detail=public_error(request, 'Email address added but verification email could not be sent.')) from error
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''',
(f'email_verify_rate:{address["id"]}', json.dumps({'sends': 1, 'last_sent': datetime.now(timezone.utc).isoformat()})),
)
conn.commit()
return address
@router.post('/emails/{address_id}/resend')
def resend_additional_email(address_id: str, request: Request, user: dict = Depends(get_current_user)):
now = datetime.now(timezone.utc)
setting_name = f'email_verify_rate:{address_id}'
with get_connection() as conn:
row = conn.execute('SELECT value FROM app_settings WHERE name = ?', (setting_name,)).fetchone()
address = conn.execute('SELECT email, verified FROM user_email_addresses WHERE id = ? AND user_id = ?', (address_id, user['id'])).fetchone()
if address is None:
raise HTTPException(status_code=404, detail='Email address not found')
if address['verified']:
raise HTTPException(status_code=409, detail='Email address is already verified')
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:
retry_after = int((cooldown_until - now).total_seconds()) + 1
raise HTTPException(status_code=429, detail=f'Please wait {retry_after} seconds before resending verification email.', 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 resending verification email.', headers={'Retry-After': str(retry_after)})
email, token = create_email_verification(user['id'], address_id)
verification_url = f'{settings.public_url}/api/auth/verify-additional-email?token={token}'
try:
send_verification_email(email, user['username'], verification_url)
except Exception as error:
logger.error('Verification email resend failed request_id=%s error=%s', request_id(request), redacted_error(error))
raise HTTPException(status_code=503, detail=public_error(request, 'Verification email could not be sent.')) from error
sends = int(rate.get('sends', 0)) + 1
updated = {'sends': sends, 'last_sent': now.isoformat()}
if sends >= 5:
updated['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''', (setting_name, json.dumps(updated)))
conn.commit()
return {'status': 'sent', 'message': f'Verification email sent to {email}.', 'next_allowed_at': (now + timedelta(seconds=20)).isoformat()}
@router.delete('/emails/{address_id}')
def remove_additional_email(address_id: str, user: dict = Depends(get_current_user)):
with get_connection() as conn:
cursor = conn.execute('DELETE FROM user_email_addresses WHERE id = ? AND user_id = ?', (address_id, user['id']))
conn.commit()
if cursor.rowcount == 0:
raise HTTPException(status_code=404, detail='Email address not found')
record_audit_event(user['id'], 'email_address_deleted', 'email_address', address_id)
return {'status': 'deleted', 'id': address_id}
@router.post('/emails/{address_id}/make-primary')
def make_email_primary(address_id: str, user: dict = Depends(get_current_user)):
with get_connection() as conn:
address = conn.execute(
'SELECT email, verified FROM user_email_addresses WHERE id = ? AND user_id = ?',
(address_id, user['id']),
).fetchone()
if address is None:
raise HTTPException(status_code=404, detail='Email address not found')
if not address['verified']:
raise HTTPException(status_code=400, detail='Email address must be validated before it can become primary')
old_email = user['email']
conn.execute(
'DELETE FROM user_email_addresses WHERE id = ? AND user_id = ?',
(address_id, user['id']),
)
conn.execute(
'INSERT INTO user_email_addresses (id, user_id, email, verified) VALUES (?, ?, ?, 1)',
(str(uuid4()), user['id'], old_email),
)
conn.execute(
'UPDATE users SET email = ?, email_verified = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
(address['email'], user['id']),
)
conn.commit()
record_audit_event(user['id'], 'primary_email_changed', 'user', user['id'])
return {'status': 'updated', 'email': address['email']}
@router.get('/labels')
def get_labels(user: dict = Depends(get_current_user)):
return list_user_labels(user['id'])
@router.post('/labels', status_code=201)
def add_label(payload: LabelUpdate, user: dict = Depends(get_current_user)):
try:
return create_label(user['id'], payload.name)
except ValueError as error:
raise HTTPException(status_code=409, detail=str(error)) from error
@router.put('/labels/{label_id}')
def edit_label(label_id: str, payload: LabelUpdate, user: dict = Depends(get_current_user)):
try:
result = update_label(label_id, user['id'], payload.name)
except ValueError as error:
raise HTTPException(status_code=409, detail=str(error)) from error
if result is None:
raise HTTPException(status_code=404, detail='Label not found or not owned by user')
return result
@router.delete('/labels/{label_id}')
def remove_label(label_id: str, user: dict = Depends(get_current_user)):
if not delete_label(label_id, user['id']):
raise HTTPException(status_code=404, detail='Label not found or not owned by user')
record_audit_event(user['id'], 'label_deleted', 'label', label_id)
return {'status': 'deleted', 'id': label_id}
@router.post('/avatar')
async def upload_avatar(
avatar: UploadFile = File(...),
user: dict = Depends(get_current_user),
):
if avatar.content_type not in {'image/gif', 'image/jpeg', 'image/png', 'image/webp'}:
raise HTTPException(status_code=415, detail='Avatar must be a PNG, JPEG, GIF, or WebP image')
contents = await avatar.read(MAX_AVATAR_BYTES + 1)
if len(contents) > MAX_AVATAR_BYTES:
raise HTTPException(status_code=413, detail='Avatar must be 2 MB or smaller')
try:
with warnings.catch_warnings():
warnings.simplefilter('error', Image.DecompressionBombWarning)
with Image.open(BytesIO(contents)) as image:
if image.width * image.height > MAX_AVATAR_PIXELS:
raise HTTPException(status_code=413, detail='Avatar dimensions are too large')
image.verify()
with Image.open(BytesIO(contents)) as image:
image.load()
normalized = image.convert('RGBA')
except HTTPException:
raise
except (Image.DecompressionBombError, Image.DecompressionBombWarning, UnidentifiedImageError, OSError, ValueError) as error:
raise HTTPException(status_code=415, detail='Avatar content is not a valid image') from error
avatar_path = AVATARS_DIR / f'{user["id"]}.png'
normalized.save(avatar_path, format='PNG', optimize=True)
for existing_path in AVATARS_DIR.glob(f'{user["id"]}.*'):
if existing_path != avatar_path:
existing_path.unlink(missing_ok=True)
avatar_url = f'/media/{avatar_path.name}'
with get_connection() as conn:
conn.execute(
'UPDATE users SET avatar_url = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
(avatar_url, user['id']),
)
conn.commit()
record_audit_event(user['id'], 'avatar_updated', 'user', user['id'])
return {'avatar_url': avatar_url}
@router.get('/plugins/{plugin_name}')
def get_user_plugin_config(plugin_name: str, user: dict = Depends(get_current_user)):
with get_connection() as conn:
row = conn.execute(
'SELECT * FROM user_plugin_config WHERE user_id = ? AND plugin_name = ?',
(user['id'], plugin_name),
).fetchone()
if row is None:
return {}
config = json.loads(row['config']) if row['config'] else {}
if config.get('access_token'):
config.pop('access_token')
return config
@router.put('/plugins/{plugin_name}')
def update_user_plugin_config(
plugin_name: str,
payload: UserPluginConfigUpdate,
user: dict = Depends(get_current_user),
):
with get_connection() as conn:
current = conn.execute(
'SELECT * FROM user_plugin_config WHERE user_id = ? AND plugin_name = ?',
(user['id'], plugin_name),
).fetchone()
current_config = json.loads(current['config']) if current and current['config'] else {}
updates = payload.model_dump(exclude_none=True)
if updates.get('access_token'):
updates['access_token'] = encrypt_secret(updates['access_token'])
merged = {**current_config, **updates}
if current is None:
conn.execute(
'''
INSERT INTO user_plugin_config (id, user_id, plugin_name, config, created_at, updated_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
''',
(str(uuid4()), user['id'], plugin_name, json.dumps(merged, ensure_ascii=False)),
)
else:
conn.execute(
'''
UPDATE user_plugin_config
SET config = ?, updated_at = CURRENT_TIMESTAMP
WHERE user_id = ? AND plugin_name = ?
''',
(json.dumps(merged, ensure_ascii=False), user['id'], plugin_name),
)
conn.commit()
public_config = dict(merged)
public_config.pop('access_token', None)
return public_config