Skip to content

Avatar Update Flow - Technical Documentation

This document describes the complete avatar update flow. The flow uses a presigned URL pattern that enables direct browser-to-storage uploads, offloading heavy data transfer from the backend server.

Architecture Overview

Why Presigned URLs?

A presigned URL is a temporary, secure web link that grants limited, time-bound access to a private file or object (like in Amazon S3) without needing permanent user credentials, allowing anyone with the URL to perform a specific action, such as downloading (GET) or uploading (PUT), until it expiresThe avatar upload flow is designed to minimize backend resource usage.

Instead of the traditional approach where the frontend uploads the image to the backend, which then forwards it to storage, we use presigned URLs to enable direct uploads from the browser to Cloudflare R2.

Traditional Flow (Not Used):

Browser → Backend → R2
         (2MB transfer through backend)

Presigned URL Flow (Implemented):

Browser → R2 (direct upload via presigned URL)
         (Backend only handles small JSON requests)

Key Architecture Decisions

Decision Rationale
Direct-to-R2 uploads Backend runs on a small server; transferring 2MB images would consume memory and bandwidth
Cloudflare Images validation Image decoding is CPU-intensive; offload to Cloudflare edge to protect the backend pod
Two-step flow Separates upload (heavy) from validation (light) for better resource management
Short-lived presigned URLs 120-second expiry minimizes the window for abuse
Temporary prefix with lifecycle tmp/ folder auto-deletes after 1 day, cleaning up orphaned uploads
Server-derived final key Prevents clients from choosing arbitrary destination paths

Preliminar Cloudflare R2 Configuration

Dedicated Avatar Bucket

A dedicated R2 bucket has been created specifically for avatar storage. This separation provides:

  • Isolation: Avatar files are isolated from other application data
  • Custom domain: Public access via a vanity URL
  • Lifecycle policies: Automated cleanup of temporary files
  • Cache optimization: Avatar-specific caching rules

Bucket Structure

avatars-bucket/
├── tmp/                    # Temporary uploads (auto-deleted after 1 day)
│   └── {userId}/
│       └── {uuid}.{ext}
└── avatars/                # Permanent, validated avatars
    └── {userId}/
        └── {uuid}.{ext}

Public Domain Configuration

The bucket is configured with a custom public domain to serve avatar URLs publicly. This is necessary because:

  1. Avatar URLs are embedded in user profiles and tokens
  2. Avatars must be accessible without authentication (for display in UI, emails, etc.)
  3. Cloudflare Images validation requires public access to fetch the image

Configuration Steps (Cloudflare Dashboard):

  1. Navigate to R2 → Your Avatar Bucket → Settings
  2. Under "Public access", enable "Custom domain"
  3. Add your subdomain (e.g., avatars.qubital.space)
  4. Configure DNS: CNAME record pointing to R2
  5. Enable "Cloudflare Images" for the domain (required for validation)

Example Domain Setup: - Bucket endpoint: https://<account-id>.r2.cloudflarestorage.com/avatars-bucket - Public domain: https://avatars.qubital.space - Avatar URL format: https://avatars.qubital.space/avatars/{userId}/{uuid}.webp

Lifecycle Rules

Configure a lifecycle rule to automatically delete orphaned temporary uploads:

  1. Navigate to R2 → Your Avatar Bucket → Settings → Lifecycle rules
  2. Add rule:
  3. Rule name: cleanup-tmp
  4. Prefix filter: tmp/
  5. Action: Delete objects after 1 day

This handles cases where users: - Close the browser before finalizing - Experience network errors during finalize - Abandon the upload flow

Environment Variables

# R2 Configuration (S3-compatible API)
R2_ACCESS_KEY=your_r2_access_key
R2_SECRET_KEY=your_r2_secret_key
R2_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
R2_BUCKET=avatars-bucket

# Public URL for serving avatars (custom domain)
R2_PUBLIC_BASE_URL=https://avatars.qubital.space

Flow Diagram

sequenceDiagram
    autonumber
    participant User
    participant Frontend
    participant Backend
    participant R2 as Cloudflare R2
    participant CFImages as Cloudflare Images
    participant WorkOS

    Note over User,WorkOS: Step 1: User Selects Image
    User->>Frontend: Selects image file
    Frontend->>Frontend: Validate file type (jpeg/png/webp)
    Frontend->>Frontend: Show crop dialog (square crop)
    Frontend->>Frontend: Compress image (max 2MB)

    Note over User,WorkOS: Step 2: Request Upload Ticket
    Frontend->>Backend: POST /auth/avatar/upload-ticket<br/>{ contentType: "image/webp" }
    Backend->>Backend: Validate content type
    Backend->>Backend: Generate UUID for object key
    Backend->>R2: Generate presigned PUT URL (120s expiry)
    R2-->>Backend: Presigned URL
    Backend-->>Frontend: { uploadUrl, tmpKey, expiresInSeconds }

    Note over User,WorkOS: Step 3: Direct Upload to R2
    Frontend->>R2: PUT {presignedUrl}<br/>Content-Type: image/webp<br/>[binary image data]
    R2-->>Frontend: 200 OK

    Note over User,WorkOS: Step 4: Finalize Upload
    Frontend->>Backend: POST /auth/avatar/finalize<br/>{ tmpKey: "tmp/user123/abc.webp" }
    Backend->>Backend: Security check: tmpKey belongs to user
    Backend->>R2: HEAD tmp/user123/abc.webp
    R2-->>Backend: { size, contentType }
    Backend->>Backend: Validate file size (max ~2.25MB)

    Note over User,WorkOS: Step 5: Cloudflare Images Validation
    Backend->>CFImages: GET /cdn-cgi/image/format=json/tmp/user123/abc.webp
    CFImages->>R2: Fetch image internally
    R2-->>CFImages: Image bytes
    CFImages->>CFImages: Decode & validate image
    CFImages-->>Backend: { original: { format, width, height } }
    Backend->>Backend: Validate dimensions (128-1024px, square)

    Note over User,WorkOS: Step 6: Promote & Update Profile
    Backend->>R2: COPY tmp/... → avatars/...
    R2-->>Backend: Copy complete
    Backend->>R2: DELETE tmp/user123/abc.webp
    Backend->>WorkOS: Update user metadata<br/>{ avatar_url: "https://..." }
    WorkOS-->>Backend: Updated user
    Backend-->>Frontend: { avatarUrl: "https://avatars.qubital.space/avatars/..." }

    Note over User,WorkOS: Step 7: Update UI
    Frontend->>Frontend: Update avatar display immediately
    Frontend->>User: Show success notification

Backend Implementation

Project Structure

internal/
├── features/user/
│   ├── api/
│   │   ├── avatar_upload_ticket.go    # Handler for Step 2
│   │   └── avatar_finalize.go         # Handler for Step 4-6
│   ├── service/
│   │   └── avatar.go                  # Business logic
│   └── models/
│       └── avatar.go                  # Request/Response DTOs
└── platform/cloudflare/r2/
    └── client.go                      # R2 SDK wrapper

Key Components

1. R2 Client (internal/platform/cloudflare/r2/client.go)

The R2 client provides an abstraction over the AWS S3-compatible SDK for Cloudflare R2 operations.

Interface Definition:

type Client interface {
    // PresignPutObject generates a presigned PUT URL for direct client uploads.
    // Uses the S3 API endpoint (not custom domain) as required by Cloudflare R2.
    PresignPutObject(ctx context.Context, key string, contentType string, expiresIn time.Duration) (string, error)

    // HeadObject retrieves object metadata (size, content-type) without downloading.
    HeadObject(ctx context.Context, key string) (*ObjectMetadata, error)

    // CopyObject copies an object from srcKey to dstKey with cache headers.
    CopyObject(ctx context.Context, srcKey, dstKey, contentType string) error

    // DeleteObject removes an object from the bucket.
    DeleteObject(ctx context.Context, key string) error

    // GetPublicURL returns the public URL for an object key.
    GetPublicURL(key string) string
}

Why These Methods:

Method Purpose in Avatar Flow
PresignPutObject Generates the signed URL for frontend direct upload
HeadObject Checks if upload exists and validates file size before expensive CF Images call
CopyObject Promotes validated avatar from tmp/ to avatars/ with proper cache headers
DeleteObject Cleans up temporary files and rolls back on errors
GetPublicURL Constructs the final avatar URL using the custom domain

2. Avatar Service (internal/features/user/service/avatar.go)

Contains the core business logic for avatar uploads.

Service Methods:

// CreateAvatarUploadTicket generates a presigned PUT URL for direct avatar upload.
// Returns the upload URL, temporary key, and expiry time.
func (s *userServiceImpl) CreateAvatarUploadTicket(
    ctx context.Context,
    userId string,
    contentType string,
) (*models.AvatarUploadTicketResponse, error)

// FinalizeAvatarUpload validates an uploaded avatar and promotes it to permanent storage.
// Updates WorkOS user metadata with the new avatar URL.
func (s *userServiceImpl) FinalizeAvatarUpload(
    ctx context.Context,
    userId string,
    tmpKey string,
) (string, error)

Constants Defined:

const (
    avatarMaxBytes    = 2<<20 + 256<<10  // ~2.25 MiB (2MB + overhead)
    avatarMinPx       = 128              // Minimum dimension
    avatarMaxPx       = 1024             // Maximum dimension
    avatarUploadExpiry = 120 * time.Second
    avatarTmpPrefix   = "tmp"
    avatarFinalPrefix = "avatars"
)

3. Cloudflare Images Validation

The validateImageWithCloudflare function validates images without downloading or decoding them on the backend.

How It Works:

  1. Constructs a Cloudflare Images URL with format=json parameter
  2. Cloudflare fetches the image from R2 via the public domain
  3. Cloudflare decodes the image and returns metadata as JSON
  4. Backend validates the metadata (format, dimensions)

Example Cloudflare Images Request:

GET https://avatars.qubital.space/cdn-cgi/image/format=json,anim=false/tmp/user123/abc.webp

Example Success Response:

{
  "original": {
    "format": "webp",
    "width": 512,
    "height": 512
  }
}

Example Failure Header:

Cf-Resized: err=9404

Error codes indicate invalid images (corrupt file, not an image, unsupported format, etc.).

4. Request/Response Models (internal/features/user/models/avatar.go)

Upload Ticket Request:

type AvatarUploadTicketRequest struct {
    ContentType string `json:"contentType" validate:"required,oneof=image/jpeg image/png image/webp"`
}

Upload Ticket Response:

type AvatarUploadTicketResponse struct {
    UploadURL        string `json:"uploadUrl"`        // Presigned S3 PUT URL
    TmpKey           string `json:"tmpKey"`           // tmp/{userId}/{uuid}.{ext}
    ExpiresInSeconds int    `json:"expiresInSeconds"` // 120
}

Finalize Request:

type AvatarFinalizeRequest struct {
    TmpKey string `json:"tmpKey" validate:"required"` // tmp/{userId}/{uuid}.{ext}
}

Finalize Response:

{
  "code": 200,
  "message": "OK",
  "avatarUrl": "https://avatars.qubital.space/avatars/user123/abc.webp"
}


API Endpoints

POST /auth/avatar/upload-ticket

Description: Generates a presigned URL for direct avatar upload to R2.

Authentication: Required (Bearer token via HttpOnly cookie)

Request:

{
  "contentType": "image/webp"
}

Allowed Content Types: - image/jpeg - image/png - image/webp

Success Response (200):

{
  "code": 200,
  "message": "OK",
  "data": {
    "uploadUrl": "https://<account>.r2.cloudflarestorage.com/bucket/tmp/user123/abc.webp?X-Amz-Algorithm=...",
    "tmpKey": "tmp/user123/550e8400-e29b-41d4-a716-446655440000.webp",
    "expiresInSeconds": 120
  }
}

Error Responses:

Status Condition
400 Unsupported content type
401 Missing or invalid authentication
422 Request validation failed
500 Failed to generate presigned URL

POST /auth/avatar/finalize

Description: Validates and promotes an uploaded avatar, updates user profile.

Authentication: Required (Bearer token via HttpOnly cookie)

Request:

{
  "tmpKey": "tmp/user123/550e8400-e29b-41d4-a716-446655440000.webp"
}

Success Response (200):

{
  "code": 200,
  "message": "OK",
  "avatarUrl": "https://avatars.qubital.space/avatars/user123/550e8400-e29b-41d4-a716-446655440000.webp"
}

Error Responses:

Status Condition
400 Invalid image (corrupt, wrong format, wrong dimensions)
401 Missing or invalid authentication
403 tmpKey doesn't belong to authenticated user
404 Upload not found (expired or never uploaded)
422 Request validation failed
500 Internal error (R2 operation failed, WorkOS update failed)

Frontend Implementation Guidelines

Overview

The frontend implementation follows a three-phase approach: preparation, upload, and finalization. The client is responsible for all user-facing interactions including file selection, image manipulation, and UI updates, while the backend only handles authorization and validation.

Phase 1: File Selection and Pre-Processing

  • File Type Validation: Accept only JPEG, PNG, and WebP formats. Validate the file type immediately after selection and reject unsupported formats before any processing begins.

  • Image Cropping: Implement a cropping interface that enforces a 1:1 aspect ratio (square). Users must be able to select and adjust the crop area before proceeding. Consider using established libraries like react-image-crop, cropperjs, or react-easy-crop for reliable cropping functionality.

  • Image Compression: After cropping, compress the image to meet backend constraints (max 1024x1024px, ~2MB file size). WebP format is recommended for optimal compression-to-quality ratio. Compression can be handled with canvas APIs or libraries like browser-image-compression or compressorjs.

Phase 2: Upload to Storage

  • Request Upload Ticket: Call POST /auth/avatar/upload-ticket with the intended content type. The response contains a presigned URL, a temporary key, and expiration time (120 seconds).

  • Direct Upload: Use the presigned URL to upload the processed image directly to R2 storage via a PUT request. Include the correct Content-Type header and the binary image data. The upload bypasses the backend entirely.

  • Time Sensitivity: The presigned URL expires in 120 seconds. Ensure the upload completes within this window or request a new ticket.

Phase 3: Finalization and Profile Update

  • Finalize Upload: Call POST /auth/avatar/finalize with the temporary key received from the upload ticket. The backend validates the image and promotes it to permanent storage.

  • Update UI: Upon successful finalization, update the user's avatar display with the new URL returned in the response. Update any cached user data to reflect the change.

  • Loading States: Provide clear feedback during each phase (ticket request, upload, finalization) to keep users informed of progress.

Error Handling Strategy

Implement graceful error handling for common failure scenarios:

  • Invalid file type: Show a clear message about supported formats
  • File too large: Guide users to select a smaller image or adjust compression
  • Invalid dimensions: Inform users about the 128-1024px range requirement
  • Upload expired: Allow users to retry the upload process
  • Network failures: Provide retry options for failed requests
  • Generic errors: Display a user-friendly message and log details for debugging

Security Considerations

  • Always include authentication credentials (HttpOnly cookies) in API requests
  • Never expose presigned URLs to unauthorized users
  • Validate file types client-side before uploading to prevent unnecessary API calls
  • Handle authentication errors by redirecting to login when tokens expire

Validation Rules

Client-Side (Frontend)

Rule Value Enforcement
File type JPEG, PNG, WebP File input accept attribute + validation
Aspect ratio 1:1 (square) Crop tool with locked aspect ratio
Dimensions 128-1024px Resize during compression
File size < 2MB Compression quality adjustment

Server-Side (Backend)

Rule Value Implementation
Content type image/jpeg, image/png, image/webp Validated on ticket request
File size < 2.25MB HEAD request before validation
Minimum dimensions 128x128px Cloudflare Images metadata
Maximum dimensions 1024x1024px Cloudflare Images metadata
Aspect ratio 1:1 Width == Height check
Valid image Must decode Cloudflare Images validation

Error Handling

Backend Error Flow

Error occurs → Log with context → Delete temp file (if exists) → Return HTTP error

Example (file too large):

if meta.Size > avatarMaxBytes {
    err := errors.New("file too large")
    logger.ErrorServiceCtx(ctx, "Avatar finalize: file too large", err, []slog.Attr{
        logger.UserID(userId),
        slog.Int64("size_bytes", meta.Size),
        slog.Int64("max_bytes", avatarMaxBytes),
    })
    _ = s.r2Client.DeleteObject(ctx, tmpKey) // Cleanup
    return "", httperror.NewStatusBadRequestError(httperror.ErrorConfig{
        Cause: err,
    })
}

Rollback on WorkOS Failure

If updating WorkOS metadata fails after the avatar is copied:

_, err = s.userClient.UpdateUser(ctx, userId, map[string]string{"avatar_url": avatarURL})
if err != nil {
    logger.ErrorServiceCtx(ctx, "Avatar finalize: failed to update WorkOS metadata", err, nil)
    // Rollback: delete the final object to avoid orphaned files
    _ = s.r2Client.DeleteObject(ctx, finalKey)
    return "", err
}

Security Considerations

1. Key Ownership Validation

The backend validates that tmpKey belongs to the authenticated user:

expectedTmpPrefix := fmt.Sprintf("%s/%s/", avatarTmpPrefix, userId)
if !strings.HasPrefix(tmpKey, expectedTmpPrefix) {
    // Reject - user trying to finalize someone else's upload
    return "", httperror.NewStatusForbiddenError(...)
}

2. Server-Derived Final Key

The client cannot choose the destination path. The final key is derived server-side:

finalKey := strings.Replace(tmpKey, avatarTmpPrefix+"/", avatarFinalPrefix+"/", 1)

3. Short-Lived Presigned URLs

URLs expire after 120 seconds, minimizing the abuse window:

avatarUploadExpiry = 120 * time.Second

4. Content-Type Enforcement

The presigned URL includes the content type, which R2 validates:

presignedReq, err := c.presignClient.PresignPutObject(ctx, &s3.PutObjectInput{
    ContentType: aws.String(contentType), // Enforced by R2
    ...
})

5. Image Validation

Cloudflare Images validates that the uploaded file is actually an image, preventing: - Executable files disguised as images - HTML/SVG with embedded scripts - Corrupt or malformed files

6. Cache Headers

Finalized avatars are served with immutable cache headers to prevent cache poisoning:

CacheControl: aws.String("public, max-age=31536000, immutable")

Summary

The avatar update flow provides a secure, efficient, and scalable solution for user avatar management:

  1. Efficient: Direct browser-to-R2 uploads remove the backend from heavy data transfer
  2. Secure: Multiple validation layers prevent abuse and unauthorized access
  3. Scalable: Cloudflare edge handles image validation, protecting the backend pod
  4. Reliable: Lifecycle rules clean up orphaned uploads automatically
  5. User-Friendly: Quick uploads with immediate UI feedback

For questions or issues, refer to the implementation files or contact the backend team.