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:
- Avatar URLs are embedded in user profiles and tokens
- Avatars must be accessible without authentication (for display in UI, emails, etc.)
- Cloudflare Images validation requires public access to fetch the image
Configuration Steps (Cloudflare Dashboard):
- Navigate to R2 → Your Avatar Bucket → Settings
- Under "Public access", enable "Custom domain"
- Add your subdomain (e.g.,
avatars.qubital.space) - Configure DNS: CNAME record pointing to R2
- 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:
- Navigate to R2 → Your Avatar Bucket → Settings → Lifecycle rules
- Add rule:
- Rule name:
cleanup-tmp - Prefix filter:
tmp/ - 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:
- Constructs a Cloudflare Images URL with
format=jsonparameter - Cloudflare fetches the image from R2 via the public domain
- Cloudflare decodes the image and returns metadata as JSON
- 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-ticketwith 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/finalizewith 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:
- Efficient: Direct browser-to-R2 uploads remove the backend from heavy data transfer
- Secure: Multiple validation layers prevent abuse and unauthorized access
- Scalable: Cloudflare edge handles image validation, protecting the backend pod
- Reliable: Lifecycle rules clean up orphaned uploads automatically
- User-Friendly: Quick uploads with immediate UI feedback
For questions or issues, refer to the implementation files or contact the backend team.