Skip to content

External Service Integrations (Email & Storage) #10

@aniebietafia

Description

@aniebietafia

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

  1. 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.
  2. A CloudinaryStorageProvider is implemented and used when STORAGE_PROVIDER=cloudinary.
  3. An S3StorageProvider is implemented using aioboto3 and used when STORAGE_PROVIDER=s3.
  4. The active provider is injected via a FastAPI dependency (get_storage_service) and resolved from settings at startup.
  5. 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.
  6. The old profile picture is deleted from storage when a new one is uploaded.
  7. All provider credentials (CLOUDINARY_*, AWS_*) are sourced from environment variables and are never hardcoded or logged.
  8. Upload failures return a 503 Service Unavailable response using the standard error schema (code: "STORAGE_UNAVAILABLE").
  9. 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

  • Add STORAGE_PROVIDER, CLOUDINARY_*, and AWS_* config fields to app/core/config.py and .env.example.
  • Define the abstract StorageService interface in app/services/storage_service.py.
  • Implement CloudinaryStorageProvider with upload_file and delete_file methods.
  • Implement S3StorageProvider using aioboto3 with upload_file and delete_file methods.
  • Implement get_storage_service FastAPI dependency that returns the active provider.
  • Add avatar_url column to app/models/user.py and generate an Alembic migration.
  • Update PATCH /api/v1/users/me to accept multipart/form-data, validate the file, upload via StorageService, and persist the URL.
  • Add a StorageUnavailableException to app/core/exceptions.py and register its handler.
  • Write unit tests for CloudinaryStorageProvider and S3StorageProvider with mocked SDK clients.
  • Write unit tests for file type and size validation logic.

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?

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions