Profile Photos API
The Profile Photos API provides secure file upload and management for user profile images. Built on OpenLift's enterprise-grade object storage infrastructure, it supports both local storage and cloud providers with automatic retention management.
Overview
Profile photos are stored using OpenLift's ObjectStorageService with the following features:
- Multiple Storage Backends: Local filesystem or Cloudflare R2
- Automatic Retention: Keeps 5 most recent photos, archives older ones
- Signed URLs: Time-limited secure access to images
- MIME Type Validation: Only approved image formats accepted
- File Size Limits: 5MB maximum for profile photos
Upload Profile Photo
Upload a new profile photo for the authenticated user.
Endpoint
POST /api/storage/profile-photo/upload
Authentication
Requires valid JWT token in Authorization header.
Request Format
This endpoint uses multipart/form-data for file uploads.
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
file | File | Yes | Image file (JPEG, PNG, WebP, GIF, HEIC, HEIF) |
assetContext | String | No | Defaults to PROFILE_PICTURE |
Supported File Types
- JPEG (.jpg, .jpeg)
- PNG (.png)
- WebP (.webp)
- GIF (.gif)
- HEIC (.heic) - iOS photos
- HEIF (.heif) - iOS photos
File Size Limits
- Profile Photos: 5MB maximum
- File validation includes both MIME type and file extension checking
Response
- Success (201)
- Validation Error (400)
- Unsupported File Type (415)
{
"success": true,
"data": {
"fileKey": "users/64abc123def456789/profile-picture/f47ac10b-58cc-4372-a567-0e02b2c3d479.jpg",
"uploadUrl": "https://api.openlift.app/api/storage/signed/user-assets/users/64abc123def456789/profile-picture/f47ac10b-58cc-4372-a567-0e02b2c3d479.jpg?token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
"fileName": "profile-photo.jpg",
"contentType": "image/jpeg",
"size": 245760,
"assetId": "64def789abc123456",
"uploadedAt": "2025-09-23T10:30:00.000Z",
"expiresAt": "2025-09-23T11:30:00.000Z"
}
}
{
"success": false,
"error": {
"code": "VALIDATION_FAILED",
"message": "File size 7340032 exceeds maximum 5242880 bytes for profile photos"
}
}
{
"success": false,
"error": {
"code": "VALIDATION_FAILED",
"message": "Content type image/bmp not allowed. Allowed types: jpg, jpeg, png, gif, webp, heic, heif"
}
}
Example Usage
- cURL
- JavaScript
- Dart/Flutter
curl -X POST \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: multipart/form-data" \
-F "file=@profile-photo.jpg" \
"https://api.openlift.app/api/storage/profile-photo/upload"
const formData = new FormData();
formData.append('file', fileInput.files[0]);
const response = await fetch('/api/storage/profile-photo/upload', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData
});
const result = await response.json();
import 'package:http/http.dart' as http;
Future<Map<String, dynamic>> uploadProfilePhoto(File imageFile, String token) async {
var request = http.MultipartRequest(
'POST',
Uri.parse('https://api.openlift.app/api/storage/profile-photo/upload')
);
request.headers['Authorization'] = 'Bearer $token';
request.files.add(await http.MultipartFile.fromPath('file', imageFile.path));
var response = await request.send();
var responseBody = await response.stream.bytesToString();
return json.decode(responseBody);
}
Get Profile Photo URL
Retrieve the current profile photo URL for a user via GraphQL.
GraphQL Query
query GetUserProfile($userId: ID!) {
user(id: $userId) {
id
username
profilePhotoUrl
profilePhotoUploadedAt
}
}
Variables
{
"userId": "64abc123def456789"
}
Response
{
"data": {
"user": {
"id": "64abc123def456789",
"username": "johndoe",
"profilePhotoUrl": "https://api.openlift.app/api/storage/signed/user-assets/users/64abc123def456789/profile-picture/f47ac10b-58cc-4372-a567-0e02b2c3d479.jpg?token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
"profilePhotoUploadedAt": "2025-09-23T10:30:00.000Z"
}
}
}
Notes
profilePhotoUrlreturns a signed URL valid for 1 hour- Returns
nullif no profile photo has been uploaded profilePhotoUploadedAtindicates when the current photo was uploaded
Signed URL Access
Profile photos are served through signed URLs that provide secure, time-limited access without requiring authentication.
URL Format
https://api.openlift.app/api/storage/signed/{bucket}/{filePath}?token={signedToken}
URL Components
- bucket: Storage bucket (
user-assetsfor profile photos) - filePath: Complete file path including user ID and filename
- signedToken: HMAC-signed token containing access permissions
Token Security
- Expiration: URLs expire after 1 hour by default
- Operation Validation: Tokens are bound to specific operations (GET only)
- Path Validation: Tokens validate against the exact file path
- HMAC Signature: Prevents token tampering using server secret
Error Responses
- Expired Token (403)
- Invalid Token (403)
- File Not Found (404)
{
"success": false,
"error": {
"code": "FORBIDDEN",
"message": "Token has expired"
}
}
{
"success": false,
"error": {
"code": "FORBIDDEN",
"message": "Token verification failed: Invalid token signature"
}
}
{
"success": false,
"error": {
"code": "NOT_FOUND",
"message": "File not found"
}
}
Automatic Retention Management
The system automatically manages profile photo retention:
Retention Policy
- Active Photos: 5 most recent uploads remain immediately accessible
- Archived Photos: Older photos are marked as archived but not deleted
- Automatic Cleanup: Happens immediately after each new upload
Retention Process
- New photo uploaded successfully
- System counts active profile photos for user
- If count exceeds 5, oldest photos are marked as
archived: true - Archived photos remain in database and storage but don't appear in API responses
Data Preservation
- No Data Loss: Photos are archived, never deleted
- Full Audit Trail: Complete upload history maintained in database
- Recovery Possible: Archived photos can be restored if needed
Client Integration Guide
⚠️ CRITICAL: THIN CLIENT IMPLEMENTATION ONLY
What Clients SHOULD do:
- ✅ Handle file selection and upload UI
- ✅ Display profile photos from signed URLs
- ✅ Manage upload progress and error states
- ✅ Cache signed URLs according to expiration
What Clients MUST NOT do:
- ❌ Implement file storage logic
- ❌ Generate signed URLs locally
- ❌ Handle retention policies
- ❌ Validate file types beyond basic UI feedback
Implementation Checklist
File Upload Flow
- File Selection: Allow users to select image files
- Client Validation: Basic file type and size checking for UX
- Upload Request: POST to
/api/storage/profile-photo/upload - Progress Tracking: Show upload progress to user
- Error Handling: Display validation errors clearly
- Success Handling: Update UI with new profile photo URL
Display Flow
- Query Profile: Fetch user profile via GraphQL
- Check URL: Verify
profilePhotoUrlis not null - Display Image: Load image from signed URL
- Handle Expiration: Refresh URL when it expires (1 hour)
- Fallback: Show default avatar if no photo exists
Error Handling
- Network Errors: Retry with exponential backoff
- Validation Errors: Show specific error messages to user
- Expired URLs: Refetch user profile to get new signed URL
- File Too Large: Guide user to resize image
Flutter Integration Example
class ProfilePhotoService {
// Upload new profile photo
Future<ProfilePhotoUpload> uploadProfilePhoto(File imageFile) async {
final request = http.MultipartRequest(
'POST',
Uri.parse('$baseUrl/api/storage/profile-photo/upload'),
);
request.headers['Authorization'] = 'Bearer $token';
request.files.add(
await http.MultipartFile.fromPath('file', imageFile.path)
);
final response = await request.send();
final responseBody = await response.stream.bytesToString();
if (response.statusCode == 201) {
return ProfilePhotoUpload.fromJson(jsonDecode(responseBody));
} else {
throw ApiException.fromResponse(response.statusCode, responseBody);
}
}
// Get current profile photo URL
Future<String?> getProfilePhotoUrl(String userId) async {
const query = '''
query GetUserProfile(\$userId: ID!) {
user(id: \$userId) {
profilePhotoUrl
}
}
''';
final result = await graphqlClient.query(
QueryOptions(
document: gql(query),
variables: {'userId': userId},
),
);
return result.data?['user']?['profilePhotoUrl'];
}
}
Storage Configuration
Local Storage Provider
For self-hosted instances using local storage:
# Local storage configuration
OBJECT_STORAGE_PROVIDER=LOCAL
LOCAL_STORAGE_BASE_PATH=./data/object-storage
LOCAL_STORAGE_BASE_URL=https://your-domain.com/api/storage
LOCAL_STORAGE_SECRET=your-signing-secret-32-chars-min
LOCAL_STORAGE_ALLOWED_EXTENSIONS=jpg,jpeg,png,gif,webp,heic,heif
LOCAL_STORAGE_MAX_FILE_SIZE=5242880 # 5MB
Cloudflare R2 Provider
For cloud storage with R2:
# R2 configuration
OBJECT_STORAGE_PROVIDER=R2
R2_ACCOUNT_ID=your-account-id
R2_ACCESS_KEY_ID=your-access-key
R2_SECRET_ACCESS_KEY=your-secret-key
R2_USER_ASSETS_BUCKET=openlift-user-assets
R2_APP_ASSETS_BUCKET=openlift-app-assets
Rate Limiting
Profile photo uploads are subject to standard API rate limits:
- Burst Limit: 100 requests per minute
- Sustained Rate: 1000 requests per hour
- File Size Impact: Larger files consume more rate limit budget
Security Considerations
File Validation
- Double Validation: Both MIME type and file extension checked
- Magic Number Verification: Planned for future release
- Content Scanning: Can be integrated with external services
Access Control
- Upload Authentication: Requires valid JWT token
- User Isolation: Users can only upload to their own profile
- URL Security: Signed URLs prevent unauthorized access
Privacy
- Data Ownership: Users own their uploaded photos
- Export Support: All photos included in data export
- Deletion Rights: Users can request photo deletion (GDPR compliance)
Troubleshooting
Common Upload Issues
File Too Large
- Profile photos limited to 5MB
- Compress images before upload
- Check file size in client before attempting upload
Unsupported File Type
- Only image formats accepted
- Convert videos/documents to supported image types
- Check file extension matches content type
Network Timeouts
- Large files may timeout on slow connections
- Implement retry logic with exponential backoff
- Consider image compression for faster uploads
Signed URL Issues
URL Expired
- Signed URLs expire after 1 hour
- Refetch user profile to get new URL
- Implement automatic refresh logic
Token Invalid
- Don't modify signed URLs
- URLs are tied to specific files and operations
- Generate new URLs rather than reusing old ones
For questions about the Profile Photos API, check our GitHub Discussions or open an issue.