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
+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)