feat(accounts): add profile avatar upload with S3-compatible object storage#249
Open
hasansezertasan wants to merge 8 commits into
Open
feat(accounts): add profile avatar upload with S3-compatible object storage#249hasansezertasan wants to merge 8 commits into
hasansezertasan wants to merge 8 commits into
Conversation
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
|
Author
|
@cofin should the frontend wiring land in this PR, or as a follow-up? |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Summary
Adds profile picture upload/download/delete endpoints leveraging advanced-alchemy's
FileObject/StoredObjectwith S3-compatible storage (rustfs, already present indocker-compose.infra.yml).Closes #55
Changes
StorageSettings— new settings class with S3 config (endpoint, access key, secret key, bucket, region, allow_http) loaded fromSTORAGE_S3_*env vars. ExposesBACKEND_KEYClassVar (default"s3") used everywhere instead of a magic literal.S3_SECRET_KEYisrepr=Falseto avoid leaking via log dumps.ObstoreBackendregistration —register_storage_backend()is idempotent (storages.is_registeredguard) and invoked fromApplicationCore.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 theavatar_url: strcolumn withavatar: FileObject | NoneusingStoredObject(backend=StorageSettings.BACKEND_KEY)(JSONB under the hood).avatar_urlis now a hybrid property returning"/api/me/avatar"when an avatar is set, keeping the response schema contract stable.avatar_urlvarchar column and adds the newavatarJSONB column.UserService.upload_avatar()andUserService.remove_avatar(). The service generates the storage key server-side asavatars/{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 theFileObjectis replaced or set toNone.ProfileController:POST /api/me/avatar(multipart upload,request_max_body_size=5MBso oversize is rejected by Litestar with 413 before buffering, magic-byte sniffing of the body to derive content type — clientContent-Typeheader 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.pycovering:GET/DELETEon missing avatar (404)/api/meprofile response reflectsavatarUrlstate before/after uploadMemoryStorekeys before/after, asserts the prior object is removedTests use an in-memory
obstore.MemoryStoreregistered as the storage backend via a session-scoped autouse fixture insrc/py/tests/conftest.py, so they require no running S3-compatible service.UserFactorysetsavatar = Noneso polyfactory does not try to construct aFileObject(no provider exists for it).Bug caught while writing tests
The initial
@deletedecorator ondelete_avatarused a non-defaultstatus_code=200to allow returning aMessagebody. After the review the handler now returnsNoneand 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)
uuid4().hexplus an extension derived from the sniffed MIME type. The client'sfilenameis never used.request_max_body_sizeon 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
docker compose -f tools/deploy/docker/docker-compose.infra.yml up -duv run app database upgradeuv run app runPOST /api/me/avatarwith a PNG fileGET /api/mereturns"avatarUrl": "/api/me/avatar"GET /api/me/avatarand verify bytes matchDELETE /api/me/avatar(expect 204) — verify 404 on subsequent GETimage/pngContent-Type)uv run pytest src/py/tests/integration/accounts/routes/test_profile_avatar.py) — 12/12 passing