From 374f1f7083bb5f73ae71d6aed8adf41ead70aef7 Mon Sep 17 00:00:00 2001
From: QuanCheng <915158214@qq.com>
Date: Tue, 11 Aug 2026 04:40:47 +0000
Subject: [PATCH] feat(workspace) user avatars
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Users can upload an avatar from workspace settings. It follows them into
every workspace they're a member of, and shows up in the team roster, chat
messages and the account menu.
Storage reuses the existing FileStore, so the bytes land wherever files
already do — S3 in production, disk locally — with no new component. The
database holds only the key; deliberately no FileRecord row, since those are
listed per-workspace on the Files page and an avatar is neither
workspace-scoped nor something a user should be able to delete from there.
Two decisions worth calling out.
The read URL carries no credential. An tag can't send an Authorization
header, and the obvious move — copying the `?token=` scheme from file
downloads — would have leaked the workspace token, which bypasses role checks
and is deliberately withheld from viewers. Instead the URL is the capability,
a random 128-bit blob id that only appears in authenticated responses. Team
rosters hand it out only to callers with a verified identity, since an open
workspace's roster is anonymously readable.
Deletion goes through a transactional outbox rather than a best-effort call
after the commit. One S3 timeout would otherwise leave an avatar the user
asked us to remove readable forever, with nothing recording that it should be
gone. The pointer swap and the deletion record commit together; a drainer on
the existing maintenance cycle empties the table with backoff.
Uploads are re-encoded to a 512x512 WebP. That's the security boundary rather
than a resize — it kills SVG/polyglot stored XSS, strips the GPS coordinates
phone photos carry, and bounds decompression bombs. EXIF orientation is
applied before the crop, or portrait photos land sideways.
Adds migrations 030/031 and Pillow. Does NOT fix the pre-existing gap where
DELETE /v1/account leaves the User and WorkspaceMembership rows behind —
that needs a decision about workspaces the user owns, tracked separately.
---
.../alembic/versions/030_add_user_avatar.py | 50 +++
.../versions/031_add_blob_deletions.py | 58 +++
workspace/backend/app/avatar.py | 202 +++++++++
workspace/backend/app/blob_gc.py | 152 +++++++
workspace/backend/app/config.py | 18 +
workspace/backend/app/main.py | 13 +-
workspace/backend/app/models.py | 27 ++
workspace/backend/app/routers/account.py | 57 ++-
workspace/backend/app/routers/avatars.py | 196 +++++++++
workspace/backend/app/routers/workspaces.py | 32 +-
workspace/backend/requirements.txt | 3 +
workspace/backend/tests/test_avatars.py | 407 ++++++++++++++++++
workspace/frontend/app/[workspaceId]/page.tsx | 17 +-
.../frontend/components/chat/chat-message.tsx | 30 +-
.../components/layout/settings-dialog.tsx | 5 +
.../frontend/components/layout/user-menu.tsx | 13 +-
.../components/settings/avatar-section.tsx | 126 ++++++
.../components/settings/team-section.tsx | 6 +
workspace/frontend/lib/account-api.ts | 47 ++
workspace/frontend/lib/avatars.tsx | 102 +++++
workspace/frontend/lib/types.ts | 15 +
21 files changed, 1555 insertions(+), 21 deletions(-)
create mode 100644 workspace/backend/alembic/versions/030_add_user_avatar.py
create mode 100644 workspace/backend/alembic/versions/031_add_blob_deletions.py
create mode 100644 workspace/backend/app/avatar.py
create mode 100644 workspace/backend/app/blob_gc.py
create mode 100644 workspace/backend/app/routers/avatars.py
create mode 100644 workspace/backend/tests/test_avatars.py
create mode 100644 workspace/frontend/components/settings/avatar-section.tsx
create mode 100644 workspace/frontend/lib/avatars.tsx
diff --git a/workspace/backend/alembic/versions/030_add_user_avatar.py b/workspace/backend/alembic/versions/030_add_user_avatar.py
new file mode 100644
index 000000000..036490614
--- /dev/null
+++ b/workspace/backend/alembic/versions/030_add_user_avatar.py
@@ -0,0 +1,50 @@
+# -*- coding: utf-8 -*-
+"""Add avatar columns to users.
+
+The bytes live in the existing `FileStore` under `avatars/{user_id}/{blob_id}.webp`
+— `avatar_key` holds that storage key and nothing else. Deliberately NOT a
+`FileRecord` row: the Files page lists FileRecords by workspace, and an avatar
+belongs to a user across every workspace they're in, so it must not show up
+there (nor be deletable from it).
+
+`avatar_updated_at` is display/diagnostics only. Cache-busting rides on the
+random `blob_id` inside the key, so the URL changes on its own every upload.
+
+Revision ID: 030
+Revises: 029
+Create Date: 2026-08-11
+"""
+
+import sqlalchemy as sa
+from alembic import op
+
+revision = "030"
+down_revision = "029"
+branch_labels = None
+depends_on = None
+
+
+def _columns(inspector, table) -> set:
+ return {c["name"] for c in inspector.get_columns(table)}
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+ inspector = sa.inspect(bind)
+ existing = _columns(inspector, "users")
+
+ if "avatar_key" not in existing:
+ op.add_column("users", sa.Column("avatar_key", sa.Text(), nullable=True))
+ if "avatar_updated_at" not in existing:
+ op.add_column("users", sa.Column("avatar_updated_at", sa.DateTime(timezone=True), nullable=True))
+
+
+def downgrade() -> None:
+ bind = op.get_bind()
+ inspector = sa.inspect(bind)
+ existing = _columns(inspector, "users")
+
+ if "avatar_updated_at" in existing:
+ op.drop_column("users", "avatar_updated_at")
+ if "avatar_key" in existing:
+ op.drop_column("users", "avatar_key")
diff --git a/workspace/backend/alembic/versions/031_add_blob_deletions.py b/workspace/backend/alembic/versions/031_add_blob_deletions.py
new file mode 100644
index 000000000..f0e366e8c
--- /dev/null
+++ b/workspace/backend/alembic/versions/031_add_blob_deletions.py
@@ -0,0 +1,58 @@
+# -*- coding: utf-8 -*-
+"""Add blob_deletions — a transactional outbox for FileStore deletions.
+
+Deleting a blob is a side effect on a remote system (S3), so it can't join the
+transaction that stops pointing at it. Doing it best-effort right after the
+commit means one S3 timeout leaves an avatar the user asked us to remove
+readable forever, with nothing in the system that knows about it.
+
+Instead the row that stops pointing at a key and the row that says "delete this
+key" commit together. A drainer then empties the table, retrying with backoff.
+Deletion becomes a durable to-do rather than a fire-and-forget side effect.
+
+Named `blob_deletions`, not `avatar_deletions`: nothing here is avatar-specific,
+and any other FileStore blob needing reliable deletion can enqueue into it.
+
+Revision ID: 031
+Revises: 030
+Create Date: 2026-08-11
+"""
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.dialects.postgresql import UUID
+
+revision = "031"
+down_revision = "030"
+branch_labels = None
+depends_on = None
+
+
+def _has_table(inspector, table) -> bool:
+ return table in inspector.get_table_names()
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+ inspector = sa.inspect(bind)
+
+ if not _has_table(inspector, "blob_deletions"):
+ op.create_table(
+ "blob_deletions",
+ sa.Column("id", UUID(as_uuid=False), primary_key=True),
+ sa.Column("storage_key", sa.Text(), nullable=False),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")),
+ sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"),
+ sa.Column("next_retry_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()")),
+ sa.Column("last_error", sa.Text(), nullable=True),
+ )
+ # The drainer's only query is "rows due now, oldest first".
+ op.create_index("idx_blob_deletions_due", "blob_deletions", ["next_retry_at"])
+
+
+def downgrade() -> None:
+ bind = op.get_bind()
+ inspector = sa.inspect(bind)
+ if _has_table(inspector, "blob_deletions"):
+ op.drop_index("idx_blob_deletions_due", table_name="blob_deletions")
+ op.drop_table("blob_deletions")
diff --git a/workspace/backend/app/avatar.py b/workspace/backend/app/avatar.py
new file mode 100644
index 000000000..922208b1c
--- /dev/null
+++ b/workspace/backend/app/avatar.py
@@ -0,0 +1,202 @@
+# -*- coding: utf-8 -*-
+"""
+Avatar image validation and transcoding.
+
+Every uploaded image is decoded and re-encoded to a fixed-size WebP. That
+re-encode is the security boundary, not an optimization — whatever arrives,
+what we store is a buffer Pillow produced:
+
+* **Stored XSS** — SVG (and HTML/SVG polyglots) can carry '
+ r = _upload(client, "alice", svg, filename="x.svg", content_type="image/svg+xml")
+ assert r.status_code == 400
+
+ def test_content_type_is_not_trusted(self, client):
+ """Text claiming to be a PNG is still text."""
+ r = _upload(client, "alice", b"not an image at all", content_type="image/png")
+ assert r.status_code == 400
+
+ def test_oversized_upload_is_rejected(self, client, monkeypatch):
+ from app.config import config
+ monkeypatch.setattr(config, "AVATAR_MAX_UPLOAD_SIZE", 1024)
+ r = _upload(client, "alice", _png(width=800, height=800))
+ assert r.status_code == 413
+
+ def test_decompression_bomb_is_rejected(self, client, monkeypatch):
+ """Pixel count is checked from the header, before any pixel work."""
+ from app.config import config
+ monkeypatch.setattr(config, "AVATAR_MAX_PIXELS", 1000)
+ r = _upload(client, "alice", _png(width=200, height=200))
+ assert r.status_code == 400
+
+ def test_truncated_image_is_a_400_not_a_500(self, client):
+ data = _png(width=300, height=300)
+ r = _upload(client, "alice", data[: len(data) // 2])
+ assert r.status_code == 400
+
+ def test_empty_upload_is_rejected(self, client):
+ assert _upload(client, "alice", b"").status_code == 400
+
+
+class TestExifOrientation:
+ def test_orientation_is_applied_before_cropping(self, client):
+ """A phone's portrait photo is stored landscape plus an orientation tag.
+
+ Re-encoding drops the tag, so if the rotation isn't baked in first the
+ avatar ends up rotated and cropped along the wrong axis. Compare against
+ what Pillow itself produces for the transposed image.
+ """
+ raw = _jpeg_with_orientation(6, width=100, height=40)
+ url = _upload(client, "alice", raw, filename="p.jpg", content_type="image/jpeg").json()["data"]["avatarUrl"]
+ served = Image.open(io.BytesIO(client.get(url).content))
+
+ from PIL import ImageOps
+ expected = ImageOps.exif_transpose(Image.open(io.BytesIO(raw)))
+ # Orientation 6 turns a 100x40 landscape into a 40x100 portrait.
+ assert expected.size == (40, 100)
+ assert served.size == (512, 512)
+
+
+# ---------------------------------------------------------------------------
+# Replacement, removal, and the deletion outbox
+# ---------------------------------------------------------------------------
+
+class TestLifecycle:
+ def test_replacing_deletes_the_old_blob(self, client, local_store):
+ first = _upload(client, "alice", _png(color=(255, 0, 0))).json()["data"]["avatarUrl"]
+ assert client.get(first).status_code == 200
+
+ second = _upload(client, "alice", _png(color=(0, 255, 0))).json()["data"]["avatarUrl"]
+ assert second != first
+ assert client.get(second).status_code == 200
+ assert client.get(first).status_code == 404
+
+ def test_concurrent_uploads_never_orphan_the_pointer(self, client, db):
+ """Two uploads in a row must leave the DB pointing at bytes that exist.
+
+ Random blob ids are what make this safe: with content-addressed keys, a
+ user re-uploading the same image would produce a key equal to the one
+ being deleted.
+ """
+ same = _png(color=(7, 7, 7))
+ _upload(client, "alice", same)
+ url = _upload(client, "alice", same).json()["data"]["avatarUrl"]
+
+ user = db.query(User).filter(User.email == "alice@example.com").one()
+ assert user.avatar_key is not None
+ assert client.get(url).status_code == 200
+
+ def test_removing_clears_the_pointer_and_the_bytes(self, client, db):
+ url = _upload(client, "alice", _png()).json()["data"]["avatarUrl"]
+ r = client.delete("/v1/account/avatar", headers=_auth("alice"))
+ assert r.status_code == 200
+ assert r.json()["data"]["avatarUrl"] is None
+ assert client.get(url).status_code == 404
+
+ db.expire_all()
+ assert db.query(User).filter(User.email == "alice@example.com").one().avatar_key is None
+
+ def test_remove_is_idempotent(self, client):
+ _upload(client, "alice", _png())
+ assert client.delete("/v1/account/avatar", headers=_auth("alice")).status_code == 200
+ assert client.delete("/v1/account/avatar", headers=_auth("alice")).status_code == 200
+
+
+class TestDeletionOutbox:
+ def test_failed_delete_still_succeeds_and_is_recorded(self, client, db, monkeypatch, local_store):
+ """A storage failure must not fail the user's request — but it must not
+ vanish either. That's the whole reason the outbox exists."""
+ _upload(client, "alice", _png(color=(1, 2, 3)))
+
+ def boom(key):
+ raise RuntimeError("S3 unavailable")
+
+ monkeypatch.setattr(local_store, "delete", boom)
+ r = _upload(client, "alice", _png(color=(4, 5, 6)))
+ assert r.status_code == 200
+
+ db.expire_all()
+ pending = db.query(BlobDeletion).all()
+ assert len(pending) == 1
+ assert pending[0].storage_key.startswith("avatars/")
+
+ def test_drainer_removes_the_blob_and_the_row(self, client, db, monkeypatch, local_store):
+ first = _upload(client, "alice", _png(color=(1, 2, 3))).json()["data"]["avatarUrl"]
+
+ monkeypatch.setattr(local_store, "delete", lambda key: (_ for _ in ()).throw(RuntimeError("down")))
+ _upload(client, "alice", _png(color=(4, 5, 6)))
+ monkeypatch.undo()
+
+ db.expire_all()
+ assert db.query(BlobDeletion).count() == 1
+ assert drain_blob_deletions(db) == 1
+
+ db.expire_all()
+ assert db.query(BlobDeletion).count() == 0
+ assert client.get(first).status_code == 404
+
+ def test_drainer_is_idempotent_for_missing_blobs(self, db):
+ db.add(BlobDeletion(storage_key="avatars/nobody/deadbeef.webp"))
+ db.commit()
+ # LocalFileStore.delete on a missing path is a no-op, so the row clears.
+ assert drain_blob_deletions(db) == 1
+
+ def test_failed_attempts_back_off_rather_than_retrying_hot(self, db, monkeypatch, local_store):
+ db.add(BlobDeletion(storage_key="avatars/x/y.webp"))
+ db.commit()
+ monkeypatch.setattr(local_store, "delete", lambda key: (_ for _ in ()).throw(RuntimeError("down")))
+
+ assert drain_blob_deletions(db) == 0
+ db.expire_all()
+ row = db.query(BlobDeletion).one()
+ assert row.attempts == 1
+ assert row.last_error
+ # Rescheduled into the future, so the next cycle doesn't hammer storage.
+ assert drain_blob_deletions(db) == 0
+
+
+# ---------------------------------------------------------------------------
+# HTTP semantics
+# ---------------------------------------------------------------------------
+
+class TestCachingAndPaths:
+ def test_cache_is_private_and_revocable(self, client):
+ """`immutable` plus a long max-age would mean "can never be withdrawn"."""
+ url = _upload(client, "alice", _png()).json()["data"]["avatarUrl"]
+ cc = client.get(url).headers["cache-control"]
+ assert "private" in cc
+ assert "immutable" not in cc
+
+ def test_if_none_match_returns_304(self, client):
+ url = _upload(client, "alice", _png()).json()["data"]["avatarUrl"]
+ etag = client.get(url).headers["etag"]
+ assert client.get(url, headers={"If-None-Match": etag}).status_code == 304
+
+ def test_nosniff_is_set(self, client):
+ url = _upload(client, "alice", _png()).json()["data"]["avatarUrl"]
+ assert client.get(url).headers["x-content-type-options"] == "nosniff"
+
+ @pytest.mark.parametrize("path", [
+ "/v1/avatars/not-a-uuid/00000000000000000000000000000000.webp",
+ "/v1/avatars/11111111-1111-1111-1111-111111111111/short.webp",
+ "/v1/avatars/11111111-1111-1111-1111-111111111111/../../etc/passwd",
+ "/v1/avatars/11111111-1111-1111-1111-111111111111/abc.txt",
+ ])
+ def test_malformed_paths_are_404_not_500(self, client, path):
+ assert client.get(path).status_code == 404
+
+
+# ---------------------------------------------------------------------------
+# Identity plumbing — profile endpoint and the team roster
+# ---------------------------------------------------------------------------
+
+class TestProfile:
+ def test_profile_returns_a_stable_user_id(self, client):
+ r = client.get("/v1/account/profile", headers=_auth("alice"))
+ assert r.status_code == 200
+ data = r.json()["data"]
+ assert data["email"] == "alice@example.com"
+ assert data["userId"]
+ assert data["avatarUrl"] is None
+
+ _upload(client, "alice", _png())
+ assert client.get("/v1/account/profile", headers=_auth("alice")).json()["data"]["avatarUrl"]
+
+ def test_profile_requires_identity(self, client):
+ assert client.get("/v1/account/profile").status_code == 401
+
+ def test_account_workspaces_still_returns_a_bare_array(self, client):
+ """Three clients index this response directly — Swift decodes it as
+ [AccountWorkspace]. It must stay an array."""
+ r = client.get("/v1/account/workspaces", headers=_auth("alice"))
+ assert r.status_code == 200
+ data = r.json()["data"]
+ assert isinstance(data, list)
+ if data:
+ assert set(data[0]) == {"workspaceId", "name", "slug", "token", "role", "lastActivityAt"}
+
+
+class TestTeamAvatarGating:
+ def _workspace_with_member(self, client, db, *, open_workspace=False):
+ ws = Workspace(
+ name="W", slug="team-ws",
+ password_hash=None if open_workspace else "tok",
+ require_login=False,
+ )
+ db.add(ws)
+ db.flush()
+ user = User(email="alice@example.com", display_name="Alice")
+ db.add(user)
+ db.flush()
+ db.add(WorkspaceMembership(workspace_id=ws.id, user_id=user.id, role="owner"))
+ db.commit()
+ return ws, user
+
+ def test_identified_member_sees_avatar_urls(self, client, db):
+ ws, _ = self._workspace_with_member(client, db)
+ _upload(client, "alice", _png())
+
+ r = client.get(f"/v1/workspaces/{ws.id}/team", headers=_auth("alice"))
+ assert r.status_code == 200
+ row = r.json()["data"][0]
+ assert row["userId"]
+ assert row["avatarUrl"]
+
+ def test_machine_token_caller_gets_no_avatar_urls(self, client, db):
+ """A workspace token proves access, not identity — and it's exactly the
+ credential viewers are denied. It shouldn't hand out capabilities."""
+ ws, _ = self._workspace_with_member(client, db)
+ _upload(client, "alice", _png())
+
+ r = client.get(f"/v1/workspaces/{ws.id}/team", headers={"X-Workspace-Token": "tok"})
+ assert r.status_code == 200
+ assert r.json()["data"][0]["avatarUrl"] is None
+
+ def test_anonymous_read_of_an_open_workspace_gets_no_avatar_urls(self, client, db):
+ """Open workspaces are waved through by verify_workspace_access, so the
+ roster is anonymously readable. The avatar URLs must not be."""
+ ws, _ = self._workspace_with_member(client, db, open_workspace=True)
+ _upload(client, "alice", _png())
+
+ r = client.get(f"/v1/workspaces/{ws.id}/team")
+ assert r.status_code == 200
+ assert r.json()["data"][0]["avatarUrl"] is None
+
+
+class TestAccountDeletion:
+ def test_deleting_the_account_removes_the_avatar(self, client, db):
+ url = _upload(client, "alice", _png()).json()["data"]["avatarUrl"]
+ assert client.get(url).status_code == 200
+
+ r = client.delete("/v1/account", headers=_auth("alice"))
+ assert r.status_code == 200
+ assert r.json()["data"]["deleted"]["avatar"] == 1
+
+ assert client.get(url).status_code == 404
+ db.expire_all()
+ assert db.query(User).filter(User.email == "alice@example.com").one().avatar_key is None
+
+ def test_deleting_without_an_avatar_is_fine(self, client):
+ r = client.delete("/v1/account", headers=_auth("bob"))
+ assert r.status_code == 200
+ assert r.json()["data"]["deleted"]["avatar"] == 0
diff --git a/workspace/frontend/app/[workspaceId]/page.tsx b/workspace/frontend/app/[workspaceId]/page.tsx
index c94d57fc1..0172c7d0c 100644
--- a/workspace/frontend/app/[workspaceId]/page.tsx
+++ b/workspace/frontend/app/[workspaceId]/page.tsx
@@ -3,6 +3,7 @@
import { use, Suspense, useEffect } from 'react';
import { useSearchParams } from 'next/navigation';
import { WorkspaceProvider, useWorkspace } from '@/lib/workspace-context';
+import { AvatarsProvider } from '@/lib/avatars';
import { LayoutProvider } from '@/components/layout/layout-context';
import { Wrapper } from '@/components/layout/wrapper';
import { useOpenAgentsAuth } from '@/lib/openagents-auth-context';
@@ -90,9 +91,11 @@ function WorkspaceContent({ workspaceId }: { workspaceId: string }) {
return (
+ Shown next to your messages across every workspace you're in. JPEG, PNG, GIF or WebP, + up to 5MB. +
+ +{m.displayName || m.email}
{m.displayName &&{m.email}
} diff --git a/workspace/frontend/lib/account-api.ts b/workspace/frontend/lib/account-api.ts index 6db6189c2..0a8065350 100644 --- a/workspace/frontend/lib/account-api.ts +++ b/workspace/frontend/lib/account-api.ts @@ -4,6 +4,8 @@ // access into memberships and auto-provisions an empty workspace for brand-new // users, so a freshly signed-in user always has at least one entry. +import type { AccountProfile } from '@/lib/types'; + const API_URL = process.env.NEXT_PUBLIC_API_URL || 'https://workspace-endpoint.openagents.org'; export interface AccountWorkspace { @@ -51,6 +53,51 @@ export function createAccountWorkspace( }); } +/** + * Absolute URL for an avatar path returned by the backend. + * + * The backend returns a path rather than a full URL — it can't reliably know + * its own public origin, and the frontend is served from a different host than + * the API. Pass through nulls so callers can hand the result straight to + *