Files
Link-Log/backend/app/api/user_config.py
T
2026-08-25 08:39:46 +02:00

210 lines
6.7 KiB
Python

## Copyright © 2026 Olaf Kolkman
## SPDX-License-Identifier: GPL-3.0-or-later
import json
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
from backend.app.services.link_service import create_label, delete_label, list_user_labels, update_label
router = APIRouter()
class UserConfigUpdate(BaseModel):
email: str | None = None
bio: str | None = None
class PasswordUpdate(BaseModel):
current_password: str
new_password: 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')
return dict(row)
@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')
email = payload.email or current['email']
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 = ?
''',
(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 hash_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('/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