439 lines
19 KiB
Python
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
|