Add user profile management: display name + avatar upload (Gitea issue #12)

Users can set a display name (shown instead of username in the message
list, room member list, TopBar, and admin Users tab) and upload a real
avatar, replacing the generated color-initial avatars everywhere a user
appears. Avatars are square-cropped and downscaled to 512px, reusing
app/storage.py's upload primitives from image uploads with a new square
option.

Two deliberate divergences from message-image handling, documented in
backend/README.md: the previous avatar file is deleted on replace/remove
(safe since it's strictly one file per user), and avatar serving is not
room-gated and uses a short cache (identity-addressed and mutable, unlike
a message image's permanent content-addressed URL).

Frontend: new ProfileModal reachable from the TopBar account menu;
AuthContext gains updateUser() so a profile change reflects instantly
everywhere without a refetch.
This commit is contained in:
2026-08-14 16:59:56 -06:00
parent c6f90d49fc
commit 8ca3e2e23d
28 changed files with 689 additions and 41 deletions
+2 -1
View File
@@ -11,7 +11,7 @@ from redis.asyncio import Redis
from starlette.middleware.sessions import SessionMiddleware
from app.config import settings
from app.routers import admin, auth, bots, health, invites, push, rooms, webhooks
from app.routers import admin, auth, bots, health, invites, push, rooms, users, webhooks
from app.ws.broadcaster import RoomBroadcaster
from app.ws.chat import router as ws_router
from app.ws.connection_manager import ConnectionManager
@@ -73,6 +73,7 @@ def create_app() -> FastAPI:
app.include_router(health.router)
app.include_router(auth.router)
app.include_router(rooms.router)
app.include_router(users.router)
app.include_router(invites.router)
app.include_router(push.router)
app.include_router(admin.router)
+3
View File
@@ -17,6 +17,9 @@ class User(Base):
is_bot: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_site_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
display_name: Mapped[str | None] = mapped_column(String(50))
avatar_filename: Mapped[str | None] = mapped_column(String(64))
avatar_content_type: Mapped[str | None] = mapped_column(String(50))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
+77 -2
View File
@@ -1,16 +1,27 @@
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi import APIRouter, Depends, File, HTTPException, Request, Response, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import get_current_user
from app.models import User
from app.schemas.auth import LoginRequest
from app.schemas.user import UserRead
from app.schemas.user import ProfileUpdate, UserRead
from app.services.auth_service import (
AccountDeactivatedError,
InvalidCredentialsError,
authenticate_user,
)
from app.storage import (
ALLOWED_IMAGE_CONTENT_TYPES,
ImageTooLargeError,
InvalidImageError,
delete_image,
process_image,
read_capped,
save_image,
)
AVATAR_MAX_DIMENSION = 512
# No POST /register here: this is an invite-only site. Accounts are created
# by an operator via `python -m app.cli create-user` (see app/cli.py), not
@@ -45,3 +56,67 @@ async def logout(request: Request) -> Response:
@router.get("/me", response_model=UserRead)
async def me(current_user: User = Depends(get_current_user)) -> User:
return current_user
@router.patch("/me", response_model=UserRead)
async def update_profile(
data: ProfileUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> User:
display_name = data.display_name.strip() if data.display_name else None
current_user.display_name = display_name or None
await db.commit()
await db.refresh(current_user)
return current_user
@router.post("/me/avatar", response_model=UserRead)
async def upload_avatar(
file: UploadFile = File(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> User:
if file.content_type not in ALLOWED_IMAGE_CONTENT_TYPES:
raise HTTPException(status_code=400, detail="Unsupported image type")
try:
data = await read_capped(file)
except ImageTooLargeError:
raise HTTPException(status_code=413, detail="Image exceeds 8 MB limit")
try:
data, ext = process_image(
data, file.content_type, square=True, max_dimension=AVATAR_MAX_DIMENSION
)
except InvalidImageError:
raise HTTPException(status_code=400, detail="File is not a valid image")
previous_filename = current_user.avatar_filename
storage_filename = save_image(data, ext)
current_user.avatar_filename = storage_filename
current_user.avatar_content_type = file.content_type
await db.commit()
await db.refresh(current_user)
if previous_filename:
delete_image(previous_filename)
return current_user
@router.delete("/me/avatar", response_model=UserRead)
async def remove_avatar(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> User:
previous_filename = current_user.avatar_filename
current_user.avatar_filename = None
current_user.avatar_content_type = None
await db.commit()
await db.refresh(current_user)
if previous_filename:
delete_image(previous_filename)
return current_user
+2
View File
@@ -63,6 +63,8 @@ async def accept_invite_endpoint(
return RoomMemberRead(
user_id=membership.user_id,
username=current_user.username,
display_name=current_user.display_name,
avatar_filename=current_user.avatar_filename,
role=membership.role,
joined_at=membership.joined_at,
)
+8 -1
View File
@@ -214,7 +214,12 @@ async def list_room_members_endpoint(
memberships = await list_room_members(db, room_id)
return [
RoomMemberRead(
user_id=m.user_id, username=m.user.username, role=m.role, joined_at=m.joined_at
user_id=m.user_id,
username=m.user.username,
display_name=m.user.display_name,
avatar_filename=m.user.avatar_filename,
role=m.role,
joined_at=m.joined_at,
)
for m in memberships
]
@@ -260,6 +265,8 @@ async def change_member_role_endpoint(
return RoomMemberRead(
user_id=membership.user_id,
username=membership.user.username,
display_name=membership.user.display_name,
avatar_filename=membership.user.avatar_filename,
role=membership.role,
joined_at=membership.joined_at,
)
+34
View File
@@ -0,0 +1,34 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import get_current_user
from app.models import User
from app.storage import UPLOADS_DIR
router = APIRouter(prefix="/api/users", tags=["users"])
@router.get("/{user_id}/avatar")
async def get_user_avatar_endpoint(
user_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
user = await db.get(User, user_id)
if user is None or not user.avatar_filename:
raise HTTPException(status_code=404, detail="No avatar set")
return FileResponse(
UPLOADS_DIR / user.avatar_filename,
media_type=user.avatar_content_type,
# Unlike message images (content-addressed, immutable once posted),
# an avatar URL is identity-addressed and its content can change on
# re-upload -- a short cache instead of `immutable` so a stale copy
# doesn't linger. Not room-membership-gated: avatar visibility
# matches username visibility (anyone logged in), unlike room-scoped
# message content.
headers={"Cache-Control": "private, max-age=300"},
)
+2
View File
@@ -13,6 +13,8 @@ class AdminUserRead(BaseModel):
is_bot: bool
is_site_admin: bool
is_active: bool
display_name: str | None
avatar_filename: str | None
created_at: datetime
+2
View File
@@ -39,6 +39,8 @@ class MyRoomItem(RoomRead):
class RoomMemberRead(BaseModel):
user_id: uuid.UUID
username: str
display_name: str | None
avatar_filename: str | None
role: RoomRole
joined_at: datetime
+6
View File
@@ -18,4 +18,10 @@ class UserRead(BaseModel):
email: EmailStr
is_bot: bool
is_site_admin: bool
display_name: str | None
avatar_filename: str | None
created_at: datetime
class ProfileUpdate(BaseModel):
display_name: str | None = Field(default=None, max_length=50)
+24 -4
View File
@@ -48,12 +48,21 @@ async def read_capped(file, cap: int = MAX_IMAGE_BYTES) -> bytes:
return b"".join(chunks)
def process_image(data: bytes, content_type: str) -> tuple[bytes, str]:
def process_image(
data: bytes,
content_type: str,
*,
square: bool = False,
max_dimension: int | None = None,
) -> tuple[bytes, str]:
"""Confirms `data` is a genuinely decodable image (not just a spoofed
Content-Type header) and downscales it so its longer side is
<=2000px -- except GIF, left untouched so animation isn't collapsed to
a single frame. Returns (final_bytes, storage_extension)."""
<=max_dimension (default 2000px) -- except GIF, left untouched so
animation isn't collapsed to a single frame. When `square` is set
(avatars), center-crops to the shorter side first. Returns
(final_bytes, storage_extension)."""
ext, pillow_format = ALLOWED_IMAGE_CONTENT_TYPES[content_type]
dimension_cap = max_dimension or _MAX_DIMENSION
try:
with Image.open(io.BytesIO(data)) as probe:
@@ -69,7 +78,12 @@ def process_image(data: bytes, content_type: str) -> tuple[bytes, str]:
image.load()
if pillow_format == "JPEG" and image.mode in ("RGBA", "P"):
image = image.convert("RGB")
image.thumbnail((_MAX_DIMENSION, _MAX_DIMENSION))
if square:
side = min(image.width, image.height)
left = (image.width - side) // 2
top = (image.height - side) // 2
image = image.crop((left, top, left + side, top + side))
image.thumbnail((dimension_cap, dimension_cap))
out = io.BytesIO()
image.save(out, format=pillow_format)
return out.getvalue(), ext
@@ -80,3 +94,9 @@ def save_image(data: bytes, ext: str) -> str:
storage_filename = f"{uuid.uuid4()}{ext}"
(UPLOADS_DIR / storage_filename).write_bytes(data)
return storage_filename
def delete_image(storage_filename: str) -> None:
"""Best-effort delete -- a missing file (already gone, or never
written) is not an error."""
(UPLOADS_DIR / storage_filename).unlink(missing_ok=True)