## 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, File, HTTPException, UploadFile 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 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 router = APIRouter() 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 class AdditionalEmail(BaseModel): email: 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 = ?', (secret, user['id'])) conn.commit() return {'secret': secret, 'otpauth_url': provisioning_uri(secret, user['username'])} @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 not verify_code(user['otp_secret'], payload.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.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), ): allowed_types = { 'image/gif': '.gif', 'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp', } suffix = allowed_types.get(avatar.content_type or '') if suffix is None: raise HTTPException(status_code=415, detail='Avatar must be a PNG, JPEG, GIF, or WebP image') contents = await avatar.read(2 * 1024 * 1024 + 1) if len(contents) > 2 * 1024 * 1024: raise HTTPException(status_code=413, detail='Avatar must be 2 MB or smaller') avatar_path = AVATARS_DIR / f'{user["id"]}{suffix}' for existing_path in AVATARS_DIR.glob(f'{user["id"]}.*'): if existing_path != avatar_path: existing_path.unlink(missing_ok=True) avatar_path.write_bytes(contents) 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 {} 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) 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() return merged