Feature: Implement Cloud Storage Service for Media Uploads
Problem
FluentMeet requires persistent cloud storage for user-generated media — primarily profile pictures, and potentially meeting recordings in the future. Currently no storage abstraction exists, meaning individual endpoints would need to call cloud provider SDKs directly, coupling business logic to a specific vendor. There is no unified interface for uploading, retrieving, or deleting files, and no handling for upload validation, size limits, or provider failover.
Note: Transactional email dispatch (verification emails, password resets) is handled separately in the Email Service issue, which uses Mailgun via Kafka. This issue covers cloud storage only.
Proposed Solution
Implement a provider-agnostic StorageService interface that abstracts file upload and retrieval operations. Two concrete providers will be supported: Cloudinary (primary, recommended for image-specific workloads due to its built-in CDN and on-the-fly transformations) and AWS S3 via aioboto3 (as an alternative for raw file storage). The active provider is selected via a STORAGE_PROVIDER environment variable, making it swappable without code changes.
User Stories
- As a user, I want to upload a profile picture when editing my profile, so other participants in a meeting can identify me visually.
- As a user, I want my profile picture to load quickly, so the meeting experience feels responsive.
- As a developer, I want a single
StorageService interface regardless of the underlying cloud provider, so I can switch providers or add new ones without touching business logic.
- As a DevOps engineer, I want all cloud credentials stored as environment variables and never logged or exposed in error responses, so our storage accounts are not compromised.
Acceptance Criteria
- A
StorageService abstract interface is defined in app/services/storage_service.py with the following methods:
upload_file(file: UploadFile, folder: str) -> str — uploads the file and returns a public URL.
delete_file(file_url: str) -> None — deletes the file at the given URL.
- A
CloudinaryStorageProvider is implemented and used when STORAGE_PROVIDER=cloudinary.
- An
S3StorageProvider is implemented using aioboto3 and used when STORAGE_PROVIDER=s3.
- The active provider is injected via a FastAPI dependency (
get_storage_service) and resolved from settings at startup.
- Profile picture upload is integrated into the
PATCH /api/v1/users/me endpoint:
- Accepts
multipart/form-data with an avatar file field.
- Validates file type (JPEG, PNG, WebP only) and size (max 5 MB) before uploading.
- Stores the returned public URL in the
User.avatar_url column.
- The old profile picture is deleted from storage when a new one is uploaded.
- All provider credentials (
CLOUDINARY_*, AWS_*) are sourced from environment variables and are never hardcoded or logged.
- Upload failures return a
503 Service Unavailable response using the standard error schema (code: "STORAGE_UNAVAILABLE").
- Unit tests verify upload, delete, and validation logic for both providers (using mocked SDK clients).
Proposed Technical Details
- Cloudinary SDK:
cloudinary Python SDK (already in requirements.txt). Uploads via cloudinary.uploader.upload() with folder, resource_type="image", and transformation options for automatic resizing.
- S3 SDK:
aioboto3 for async uploads to a configurable bucket (AWS_S3_BUCKET). Files are stored under avatars/{user_id}/{filename} and made publicly readable via a bucket policy or pre-signed URL.
- New/Modified Files:
app/services/storage_service.py — abstract interface + provider implementations [NEW]
app/core/config.py — add STORAGE_PROVIDER, CLOUDINARY_CLOUD_NAME, CLOUDINARY_API_KEY, CLOUDINARY_API_SECRET, AWS_S3_BUCKET, AWS_REGION settings
app/models/user.py — add avatar_url: Mapped[str | None] column
app/schemas/user.py — add avatar_url to UserResponse
app/api/v1/endpoints/users.py — update PATCH /me to accept file upload [MODIFY]
- Validation: File type checked via MIME type (
python-magic or filetype library), not just file extension.
- Alembic Migration: A new migration to add
avatar_url column to the users table.
Tasks
Open Questions/Considerations
- Should Cloudinary be the sole provider for image uploads given its native CDN and transformation capabilities (auto-resize, format conversion), with S3 reserved for non-image files like future recording exports?
- Should uploaded profile pictures be served through a CDN URL directly (Cloudinary's CDN), or proxied through the API to avoid exposing provider URLs to clients?
- What image transformations should be applied on upload — should we enforce a maximum resolution (e.g., 512×512) and convert to WebP for optimal delivery?
- How should we handle existing users who have no
avatar_url — should a default avatar URL be stored or should null be handled gracefully on the frontend?
Feature: Implement Cloud Storage Service for Media Uploads
Problem
FluentMeet requires persistent cloud storage for user-generated media — primarily profile pictures, and potentially meeting recordings in the future. Currently no storage abstraction exists, meaning individual endpoints would need to call cloud provider SDKs directly, coupling business logic to a specific vendor. There is no unified interface for uploading, retrieving, or deleting files, and no handling for upload validation, size limits, or provider failover.
Proposed Solution
Implement a provider-agnostic
StorageServiceinterface that abstracts file upload and retrieval operations. Two concrete providers will be supported: Cloudinary (primary, recommended for image-specific workloads due to its built-in CDN and on-the-fly transformations) and AWS S3 viaaioboto3(as an alternative for raw file storage). The active provider is selected via aSTORAGE_PROVIDERenvironment variable, making it swappable without code changes.User Stories
StorageServiceinterface regardless of the underlying cloud provider, so I can switch providers or add new ones without touching business logic.Acceptance Criteria
StorageServiceabstract interface is defined inapp/services/storage_service.pywith the following methods:upload_file(file: UploadFile, folder: str) -> str— uploads the file and returns a public URL.delete_file(file_url: str) -> None— deletes the file at the given URL.CloudinaryStorageProvideris implemented and used whenSTORAGE_PROVIDER=cloudinary.S3StorageProvideris implemented usingaioboto3and used whenSTORAGE_PROVIDER=s3.get_storage_service) and resolved from settings at startup.PATCH /api/v1/users/meendpoint:multipart/form-datawith anavatarfile field.User.avatar_urlcolumn.CLOUDINARY_*,AWS_*) are sourced from environment variables and are never hardcoded or logged.503 Service Unavailableresponse using the standard error schema (code: "STORAGE_UNAVAILABLE").Proposed Technical Details
cloudinaryPython SDK (already inrequirements.txt). Uploads viacloudinary.uploader.upload()withfolder,resource_type="image", andtransformationoptions for automatic resizing.aioboto3for async uploads to a configurable bucket (AWS_S3_BUCKET). Files are stored underavatars/{user_id}/{filename}and made publicly readable via a bucket policy or pre-signed URL.app/services/storage_service.py— abstract interface + provider implementations [NEW]app/core/config.py— addSTORAGE_PROVIDER,CLOUDINARY_CLOUD_NAME,CLOUDINARY_API_KEY,CLOUDINARY_API_SECRET,AWS_S3_BUCKET,AWS_REGIONsettingsapp/models/user.py— addavatar_url: Mapped[str | None]columnapp/schemas/user.py— addavatar_urltoUserResponseapp/api/v1/endpoints/users.py— updatePATCH /meto accept file upload [MODIFY]python-magicorfiletypelibrary), not just file extension.avatar_urlcolumn to theuserstable.Tasks
STORAGE_PROVIDER,CLOUDINARY_*, andAWS_*config fields toapp/core/config.pyand.env.example.StorageServiceinterface inapp/services/storage_service.py.CloudinaryStorageProviderwithupload_fileanddelete_filemethods.S3StorageProviderusingaioboto3withupload_fileanddelete_filemethods.get_storage_serviceFastAPI dependency that returns the active provider.avatar_urlcolumn toapp/models/user.pyand generate an Alembic migration.PATCH /api/v1/users/meto acceptmultipart/form-data, validate the file, upload viaStorageService, and persist the URL.StorageUnavailableExceptiontoapp/core/exceptions.pyand register its handler.CloudinaryStorageProviderandS3StorageProviderwith mocked SDK clients.Open Questions/Considerations
avatar_url— should a default avatar URL be stored or shouldnullbe handled gracefully on the frontend?