Skip to content

feat(accounts): add profile avatar upload with S3-compatible object storage#249

Open
hasansezertasan wants to merge 8 commits into
litestar-org:mainfrom
hasansezertasan:feat/profile-avatar-upload
Open

feat(accounts): add profile avatar upload with S3-compatible object storage#249
hasansezertasan wants to merge 8 commits into
litestar-org:mainfrom
hasansezertasan:feat/profile-avatar-upload

Conversation

@hasansezertasan

@hasansezertasan hasansezertasan commented Apr 10, 2026

Copy link
Copy Markdown

Summary

Adds profile picture upload/download/delete endpoints leveraging advanced-alchemy's FileObject/StoredObject with S3-compatible storage (rustfs, already present in docker-compose.infra.yml).

Closes #55

Changes

  • StorageSettings — new settings class with S3 config (endpoint, access key, secret key, bucket, region, allow_http) loaded from STORAGE_S3_* env vars. Exposes BACKEND_KEY ClassVar (default "s3") used everywhere instead of a magic literal. S3_SECRET_KEY is repr=False to avoid leaking via log dumps.
  • ObstoreBackend registrationregister_storage_backend() is idempotent (storages.is_registered guard) and invoked from ApplicationCore.on_app_init, not at module import time. Tests register an in-memory backend first, and the production registration becomes a no-op.
  • User.avatar — replaced the avatar_url: str column with avatar: FileObject | None using StoredObject(backend=StorageSettings.BACKEND_KEY) (JSONB under the hood). avatar_url is now a hybrid property returning "/api/me/avatar" when an avatar is set, keeping the response schema contract stable.
  • Alembic migration — drops the old avatar_url varchar column and adds the new avatar JSONB column.
  • Service methodsUserService.upload_avatar() and UserService.remove_avatar(). The service generates the storage key server-side as avatars/{user_id}/{uuid4}.{ext} (no client-supplied filename) and re-validates content type and size for defense-in-depth so non-HTTP callers cannot bypass controller gates. Old files are cleaned up automatically by advanced-alchemy's session tracker when the FileObject is replaced or set to None.
  • Controller endpoints on ProfileController:
    • POST /api/me/avatar (multipart upload, request_max_body_size=5MB so oversize is rejected by Litestar with 413 before buffering, magic-byte sniffing of the body to derive content type — client Content-Type header is not trusted)
    • GET /api/me/avatar (streams file bytes back with the stored content type)
    • DELETE /api/me/avatar (returns 204 No Content; 404 if no avatar set)

Tests

12 integration route tests in src/py/tests/integration/accounts/routes/test_profile_avatar.py covering:

  • Auth checks (401) for all three methods
  • Happy-path upload, download (round-trip byte match), and delete (204 + empty body)
  • Validation: bytes that don't match a known image magic header (400)
  • Oversize upload > 5MB rejected at the framework boundary (413)
  • GET/DELETE on missing avatar (404)
  • /api/me profile response reflects avatarUrl state before/after upload
  • Replacement cleanup — uploads twice, lists MemoryStore keys before/after, asserts the prior object is removed

Tests use an in-memory obstore.MemoryStore registered as the storage backend via a session-scoped autouse fixture in src/py/tests/conftest.py, so they require no running S3-compatible service.

UserFactory sets avatar = None so polyfactory does not try to construct a FileObject (no provider exists for it).

Bug caught while writing tests

The initial @delete decorator on delete_avatar used a non-default status_code=200 to allow returning a Message body. After the review the handler now returns None and uses Litestar's default 204 No Content for DELETE — matching REST conventions and removing the workaround.

Why rustfs over MinIO

rustfs is already in docker-compose.infra.yml — it's a lightweight, Rust-based, fully S3-compatible alternative to MinIO. No Dockerfile changes needed.

Security notes (review hardening)

  • Path traversal / key pollution: storage keys are built server-side from uuid4().hex plus an extension derived from the sniffed MIME type. The client's filename is never used.
  • Content-type spoofing: the stored content type comes from magic-byte detection of the upload bytes (JPEG/PNG/GIF/WEBP), not from the client header.
  • OOM via large uploads: enforced by request_max_body_size on the route, so Litestar rejects oversize requests before reading them into memory. Service still validates as defense-in-depth.

Status: Draft

Currently testing locally against a running rustfs instance. Opening as draft so maintainers can see the approach early and flag any concerns before end-to-end verification.

Test plan

  • Start infra: docker compose -f tools/deploy/docker/docker-compose.infra.yml up -d
  • Run migrations: uv run app database upgrade
  • Start the app: uv run app run
  • Upload an avatar via POST /api/me/avatar with a PNG file
  • Verify GET /api/me returns "avatarUrl": "/api/me/avatar"
  • Download the avatar via GET /api/me/avatar and verify bytes match
  • Upload a replacement — verify old file is cleaned up in rustfs
  • Delete the avatar via DELETE /api/me/avatar (expect 204) — verify 404 on subsequent GET
  • Verify magic-byte validation (reject non-image payloads even with image/png Content-Type)
  • Verify size limit (reject > 5MB with 413)
  • Integration test suite passes (uv run pytest src/py/tests/integration/accounts/routes/test_profile_avatar.py) — 12/12 passing

hasansezertasan and others added 8 commits April 8, 2026 16:41
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…rnings

- Add docstrings to empty data_upgrades/data_downgrades functions
- Remove unused TYPE_CHECKING import and __all__ tuple copied from template

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Address review findings on PR litestar-org#249 (avatar upload via FileObject/StoredObject):

Security
- Generate the storage key server-side as `avatars/{user_id}/{uuid4}.{ext}`
  to remove path traversal / key pollution via client `filename`
- Sniff magic bytes (JPEG/PNG/GIF/WEBP) and persist the detected MIME instead
  of the client-supplied `Content-Type`
- Cap upload body via Litestar `request_max_body_size` so oversize requests
  are rejected before buffering (returns 413)
- Add defense-in-depth size and content-type checks in `UserService.upload_avatar`
  so non-HTTP callers cannot bypass the controller gates
- Mark `StorageSettings.S3_SECRET_KEY` as `repr=False` to avoid leaking via
  log dumps

Refactor
- Make `register_storage_backend()` idempotent (`storages.is_registered`) and
  invoke it from `ApplicationCore.on_app_init` instead of at module import
- Introduce `StorageSettings.BACKEND_KEY` ClassVar; replace the hardcoded
  `"s3"` literal in the model, service, plugins, and tests
- Drop `str(...).lower()` coercion for `allow_http`; obstore accepts bool
- Switch `DELETE /api/me/avatar` to the conventional 204 No Content (no body)

Tests
- Add `test_upload_avatar_replacement_cleans_up_old_object` to assert that
  advanced-alchemy's session tracker removes the prior object on replacement
- Update oversize test expectation to 413 (framework-enforced)
- Update delete test for 204 + empty body
- Set `UserFactory.avatar = None` so polyfactory does not try to construct a
  `FileObject` (no provider exists for it)
- Conftest fixture now relies on the lazy idempotent registration instead of
  an import-order workaround
@hasansezertasan hasansezertasan changed the title feat: add profile avatar upload with S3-compatible object storage feat(accounts): add profile avatar upload with S3-compatible object storage May 9, 2026
@hasansezertasan hasansezertasan marked this pull request as ready for review May 9, 2026 14:38
@hasansezertasan hasansezertasan requested review from a team and cofin as code owners May 9, 2026 14:38
@sonarqubecloud

sonarqubecloud Bot commented May 9, 2026

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
3.8% Duplication on New Code (required ≤ 3%)

See analysis details on SonarQube Cloud

@hasansezertasan

Copy link
Copy Markdown
Author

@cofin should the frontend wiring land in this PR, or as a follow-up?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Enhancement: Extend example to use object storage

1 participant