## 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, 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 router = APIRouter() 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() 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() 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() 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() 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, 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: raise HTTPException(status_code=503, detail=f'Email address added but verification email could not be sent: {error}') 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, 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: raise HTTPException(status_code=503, detail=f'Verification email could not be sent: {error}') 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') 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() 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') 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() 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