Private
Public Access
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:
+24
-4
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user