1a195a96bd
- Merge origin/main into branch (resolve conflict in integrations_dashboard.html) - Add defensive JSON parsing with try/except for integration.config - Wrap tester() call in try/except to prevent 500 errors from bad config - Add i18n key integrations.connection_test_failed_fallback in en.json - Reference i18n key in template JS fallback message - Update SECURITY_AUDIT.md: add fix date (2026-03-23), update doc date - Remove accidental revert.sh file - Fix missing MagicMock/patch imports in test file - Add tests for invalid JSON config and tester exception error paths Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/daebb70e-059a-4601-8864-88eef49f99cf
359 lines
13 KiB
Python
359 lines
13 KiB
Python
"""User self-service profile API.
|
|
|
|
Provides endpoints for the authenticated user to view and update their own
|
|
profile settings without requiring admin access.
|
|
|
|
Routes:
|
|
GET /api/profile — read current user's profile
|
|
PATCH /api/profile — update display name, language, theme
|
|
POST /api/profile/avatar — upload a new profile picture (JPEG/PNG/GIF/WebP, max 2 MB)
|
|
DELETE /api/profile/avatar — remove custom avatar (reverts to Gravatar)
|
|
POST /api/profile/change-password — change password (local-auth users only)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import logging
|
|
from hashlib import md5
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends, File, HTTPException, Request, Response, UploadFile, status
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.auth import require_login
|
|
from app.database import get_db
|
|
from app.models import LocalUser, UserProfile
|
|
from app.utils.i18n import SUPPORTED_LANGUAGE_CODES
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/profile", tags=["profile"])
|
|
|
|
DbSession = Annotated[Session, Depends(get_db)]
|
|
|
|
# Maximum avatar upload size: 2 MB
|
|
_MAX_AVATAR_BYTES = 2 * 1024 * 1024
|
|
|
|
# Allowed MIME types for avatar uploads
|
|
_ALLOWED_AVATAR_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
|
|
|
|
# Valid theme values
|
|
_VALID_THEMES = {"light", "dark", "system"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _get_user_id(request: Request) -> str:
|
|
"""Return the stable user identifier from the session.
|
|
|
|
Raises HTTP 401 if no user is logged in.
|
|
"""
|
|
user = request.session.get("user")
|
|
if not user or not isinstance(user, dict):
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
|
uid = user.get("sub") or user.get("preferred_username") or user.get("email") or user.get("id")
|
|
if not uid:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Cannot determine user identity")
|
|
return uid
|
|
|
|
|
|
def _gravatar_url(email: str | None) -> str:
|
|
"""Generate a Gravatar URL for *email*, falling back to identicon."""
|
|
if not email:
|
|
return "https://www.gravatar.com/avatar/?d=identicon"
|
|
# MD5 used for Gravatar URL generation only — not for security
|
|
h = md5(email.strip().lower().encode(), usedforsecurity=False).hexdigest()
|
|
return f"https://www.gravatar.com/avatar/{h}?d=identicon"
|
|
|
|
|
|
def _get_or_create_profile(db: Session, user_id: str) -> UserProfile:
|
|
"""Return the UserProfile for *user_id*, creating a stub if one doesn't exist."""
|
|
profile = db.query(UserProfile).filter(UserProfile.user_id == user_id).first()
|
|
if profile is None:
|
|
profile = UserProfile(user_id=user_id)
|
|
db.add(profile)
|
|
try:
|
|
db.commit()
|
|
db.refresh(profile)
|
|
except Exception:
|
|
db.rollback()
|
|
raise
|
|
return profile
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pydantic schemas
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class ProfileResponse(BaseModel):
|
|
"""Response body for GET /api/profile."""
|
|
|
|
user_id: str
|
|
display_name: str | None
|
|
contact_email: str | None
|
|
preferred_language: str | None
|
|
preferred_theme: str | None
|
|
default_document_language: str | None
|
|
"""ISO 639-1 code for the user's preferred document translation target language."""
|
|
avatar_url: str
|
|
"""Gravatar URL or ``data:`` URI for a custom uploaded avatar."""
|
|
is_local_user: bool
|
|
"""True when the account was created via local email/password sign-up."""
|
|
|
|
|
|
class ProfileUpdateRequest(BaseModel):
|
|
"""Request body for PATCH /api/profile."""
|
|
|
|
display_name: str | None = Field(default=None, max_length=255, description="Human-readable display name")
|
|
contact_email: str | None = Field(default=None, max_length=255, description="Contact / notification e-mail")
|
|
preferred_language: str | None = Field(default=None, description="ISO 639-1 language code, e.g. 'en', 'de'")
|
|
preferred_theme: str | None = Field(default=None, description="Colour scheme: 'light', 'dark', or 'system'")
|
|
default_document_language: str | None = Field(
|
|
default=None,
|
|
description="ISO 639-1 code for the default document translation target language, e.g. 'en', 'de'",
|
|
)
|
|
|
|
|
|
class ChangePasswordRequest(BaseModel):
|
|
"""Request body for POST /api/profile/change-password."""
|
|
|
|
current_password: str = Field(..., min_length=1, max_length=128)
|
|
new_password: str = Field(..., min_length=8, max_length=128)
|
|
new_password_confirm: str = Field(..., min_length=8, max_length=128)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("", response_model=ProfileResponse)
|
|
@require_login
|
|
async def get_profile(request: Request, db: DbSession) -> ProfileResponse:
|
|
"""Return the current user's profile settings."""
|
|
user_id = _get_user_id(request)
|
|
profile = _get_or_create_profile(db, user_id)
|
|
|
|
session_user = request.session.get("user", {})
|
|
email = session_user.get("email") if isinstance(session_user, dict) else None
|
|
|
|
# Determine avatar: prefer stored data, fall back to Gravatar
|
|
avatar_url = profile.avatar_data if profile.avatar_data else _gravatar_url(email) # type: ignore[attr-defined]
|
|
|
|
# Check whether this is a local (email/password) account
|
|
is_local = db.query(LocalUser).filter(LocalUser.username == user_id).first() is not None
|
|
|
|
return ProfileResponse(
|
|
user_id=user_id,
|
|
display_name=profile.display_name, # type: ignore[arg-type]
|
|
contact_email=profile.contact_email, # type: ignore[arg-type]
|
|
preferred_language=profile.preferred_language, # type: ignore[arg-type]
|
|
preferred_theme=profile.preferred_theme, # type: ignore[arg-type]
|
|
default_document_language=profile.default_document_language, # type: ignore[arg-type]
|
|
avatar_url=avatar_url,
|
|
is_local_user=is_local,
|
|
)
|
|
|
|
|
|
@router.patch("", response_model=ProfileResponse)
|
|
@require_login
|
|
async def update_profile(
|
|
body: ProfileUpdateRequest, request: Request, response: Response, db: DbSession
|
|
) -> ProfileResponse:
|
|
"""Update the current user's editable profile settings."""
|
|
user_id = _get_user_id(request)
|
|
profile = _get_or_create_profile(db, user_id)
|
|
|
|
# Validate language code
|
|
if body.preferred_language is not None:
|
|
lang = body.preferred_language.lower().strip()
|
|
if lang and lang not in SUPPORTED_LANGUAGE_CODES:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail=f"Unsupported language code: {lang}",
|
|
)
|
|
profile.preferred_language = lang or None # type: ignore[assignment]
|
|
|
|
# Keep session and cookie in sync so detect_language() picks up
|
|
# the new preference immediately (without a DB round-trip).
|
|
if hasattr(request, "session"):
|
|
if lang:
|
|
request.session["preferred_language"] = lang
|
|
else:
|
|
request.session.pop("preferred_language", None)
|
|
if lang:
|
|
response.set_cookie(
|
|
key="docuelevate_lang",
|
|
value=lang,
|
|
max_age=30 * 24 * 60 * 60,
|
|
httponly=False,
|
|
samesite="lax",
|
|
)
|
|
else:
|
|
response.delete_cookie(key="docuelevate_lang")
|
|
|
|
# Validate theme
|
|
if body.preferred_theme is not None:
|
|
theme = body.preferred_theme.lower().strip()
|
|
if theme and theme not in _VALID_THEMES:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail=f"Invalid theme: {theme}. Must be one of: {', '.join(sorted(_VALID_THEMES))}",
|
|
)
|
|
profile.preferred_theme = theme or None # type: ignore[assignment]
|
|
|
|
# Validate default document language
|
|
if body.default_document_language is not None:
|
|
doc_lang = body.default_document_language.lower().strip()
|
|
if doc_lang and doc_lang not in SUPPORTED_LANGUAGE_CODES:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail=f"Unsupported language code: {doc_lang}",
|
|
)
|
|
profile.default_document_language = doc_lang or None # type: ignore[assignment]
|
|
|
|
if body.display_name is not None:
|
|
profile.display_name = body.display_name.strip() or None # type: ignore[assignment]
|
|
|
|
if body.contact_email is not None:
|
|
profile.contact_email = body.contact_email.strip() or None # type: ignore[assignment]
|
|
|
|
try:
|
|
db.commit()
|
|
db.refresh(profile)
|
|
except Exception:
|
|
db.rollback()
|
|
raise
|
|
|
|
session_user = request.session.get("user", {})
|
|
email = session_user.get("email") if isinstance(session_user, dict) else None
|
|
avatar_url = profile.avatar_data if profile.avatar_data else _gravatar_url(email) # type: ignore[attr-defined]
|
|
is_local = db.query(LocalUser).filter(LocalUser.username == user_id).first() is not None
|
|
|
|
return ProfileResponse(
|
|
user_id=user_id,
|
|
display_name=profile.display_name, # type: ignore[arg-type]
|
|
contact_email=profile.contact_email, # type: ignore[arg-type]
|
|
preferred_language=profile.preferred_language, # type: ignore[arg-type]
|
|
preferred_theme=profile.preferred_theme, # type: ignore[arg-type]
|
|
default_document_language=profile.default_document_language, # type: ignore[arg-type]
|
|
avatar_url=avatar_url,
|
|
is_local_user=is_local,
|
|
)
|
|
|
|
|
|
@router.post("/avatar", status_code=status.HTTP_200_OK)
|
|
@require_login
|
|
async def upload_avatar(
|
|
request: Request,
|
|
db: DbSession,
|
|
file: UploadFile = File(..., description="Profile picture (JPEG, PNG, GIF or WebP; max 2 MB)"),
|
|
) -> dict:
|
|
"""Upload a new profile picture.
|
|
|
|
The image is stored as a base64-encoded data URL in ``UserProfile.avatar_data``.
|
|
Accepts JPEG, PNG, GIF, or WebP files up to 2 MB.
|
|
"""
|
|
user_id = _get_user_id(request)
|
|
|
|
content_type = (file.content_type or "").lower()
|
|
if content_type not in _ALLOWED_AVATAR_TYPES:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
|
detail=f"Unsupported image type '{content_type}'. Allowed: JPEG, PNG, GIF, WebP.",
|
|
)
|
|
|
|
# Check declared size first (available when the client sends a Content-Length header)
|
|
if file.size is not None and file.size > _MAX_AVATAR_BYTES:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
|
detail="Avatar image must be 2 MB or smaller.",
|
|
)
|
|
|
|
# Read up to one byte past the limit so we can detect oversized uploads
|
|
raw = await file.read(_MAX_AVATAR_BYTES + 1)
|
|
if len(raw) > _MAX_AVATAR_BYTES:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
|
detail="Avatar image must be 2 MB or smaller.",
|
|
)
|
|
|
|
b64 = base64.b64encode(raw).decode("ascii")
|
|
data_url = f"data:{content_type};base64,{b64}"
|
|
|
|
profile = _get_or_create_profile(db, user_id)
|
|
profile.avatar_data = data_url # type: ignore[assignment]
|
|
try:
|
|
db.commit()
|
|
except Exception:
|
|
db.rollback()
|
|
raise
|
|
|
|
return {"avatar_url": data_url}
|
|
|
|
|
|
@router.delete("/avatar", status_code=status.HTTP_200_OK)
|
|
@require_login
|
|
async def delete_avatar(request: Request, db: DbSession) -> dict:
|
|
"""Remove the custom avatar and revert to the Gravatar fallback."""
|
|
user_id = _get_user_id(request)
|
|
profile = _get_or_create_profile(db, user_id)
|
|
profile.avatar_data = None # type: ignore[assignment]
|
|
try:
|
|
db.commit()
|
|
except Exception:
|
|
db.rollback()
|
|
raise
|
|
|
|
session_user = request.session.get("user", {})
|
|
email = session_user.get("email") if isinstance(session_user, dict) else None
|
|
return {"avatar_url": _gravatar_url(email)}
|
|
|
|
|
|
@router.post("/change-password", status_code=status.HTTP_200_OK)
|
|
@require_login
|
|
async def change_password(body: ChangePasswordRequest, request: Request, db: DbSession) -> dict:
|
|
"""Change the password for local (email/password) accounts.
|
|
|
|
Raises 403 if the account is not a local account or the current password is wrong.
|
|
Raises 422 if the new passwords do not match.
|
|
"""
|
|
from app.utils.local_auth import hash_password, verify_password
|
|
|
|
user_id = _get_user_id(request)
|
|
|
|
local_user = db.query(LocalUser).filter(LocalUser.username == user_id).first()
|
|
if local_user is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Password change is only available for local accounts.",
|
|
)
|
|
|
|
if not verify_password(body.current_password, local_user.hashed_password):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Current password is incorrect.",
|
|
)
|
|
|
|
if body.new_password != body.new_password_confirm:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="New passwords do not match.",
|
|
)
|
|
|
|
local_user.hashed_password = hash_password(body.new_password)
|
|
try:
|
|
db.commit()
|
|
except Exception:
|
|
db.rollback()
|
|
raise
|
|
|
|
logger.info("Password changed for local user: %s", user_id)
|
|
return {"detail": "Password changed successfully."}
|