Overview
The File Upload API provides endpoints for uploading, managing, and deleting images in TendSocial. All endpoints require JWT authentication and enforce multi-tenant isolation.
Base URL: /api/upload
Authentication
All endpoints require a valid JWT token in the Authorization header:
Authorization: Bearer YOUR_JWT_TOKENThe token must contain:
userId- User IDcompanyId- Company ID (for tenant isolation)
Endpoints
1. Generate Presigned URL
Generate a presigned URL for client-side upload (recommended for large files).
Endpoint: POST /api/upload/presigned-url
Request Body:
{
"filename": "logo.png",
"contentType": "image/png",
"fileSize": 50000
}Response (200 OK):
{
"uploadUrl": "https://account.r2.cloudflarestorage.com/...",
"key": "company-123/images/user-456/1732713600000-abc123-logo.png",
"imageId": "img-uuid-123"
}Error Responses:
400 Bad Request- Invalid file type, size, or filename401 Unauthorized- Missing or invalid JWT token500 Internal Server Error- Failed to generate presigned URL
Client Usage:
// 1. Get presigned URL
const response = await fetch('/api/upload/presigned-url', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
filename: file.name,
contentType: file.type,
fileSize: file.size
})
});
const { uploadUrl, imageId } = await response.json();
// 2. Upload file directly to R2
await fetch(uploadUrl, {
method: 'PUT',
headers: {
'Content-Type': file.type
},
body: file
});
// 3. Request a viewUrl when you need to display the image
const viewResponse = await fetch(`/api/assets/${imageId}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
const { viewUrl } = await viewResponse.json();
console.log('Image ready to display:', viewUrl);2. Direct Upload
Upload a file directly through the API server (for smaller files).
Endpoint: POST /api/upload/direct
Request: Multipart form data with file field
Example:
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/upload/direct', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData
});
const { imageId } = await response.json();Response (200 OK):
{
"imageId": "img-uuid-123",
"filename": "logo.png"
}Error Responses:
400 Bad Request- No file uploaded or invalid file401 Unauthorized- Missing or invalid JWT token500 Internal Server Error- Upload failed
3. List Images
Retrieve a paginated list of uploaded images for the current user.
Endpoint: GET /api/upload/images
Query Parameters:
limit(optional, default: 50, max: 100) - Number of images to returnoffset(optional, default: 0) - Pagination offset
Example:
const response = await fetch('/api/upload/images?limit=20&offset=0', {
headers: {
'Authorization': `Bearer ${token}`
}
});
const { images, total, limit, offset } = await response.json();Response (200 OK):
{
"images": [
{
"id": "img-uuid-123",
"filename": "logo.png",
"format": "png",
"width": 1200,
"height": 630,
"createdAt": "2024-11-27T12:00:00.000Z"
}
],
"total": 1,
"limit": 50,
"offset": 0
}Note: To display an image, fetch GET /api/assets/:id and use its short-lived viewUrl. Never persist that URL.
Error Responses:
401 Unauthorized- Missing or invalid JWT token500 Internal Server Error- Failed to retrieve images
4. Delete Image
Delete an uploaded image (removes from both R2 storage and database).
Endpoint: DELETE /api/upload/images/:id
Path Parameters:
id- Image ID (UUID)
Example:
const response = await fetch(`/api/upload/images/${imageId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
});
const { success, message } = await response.json();Response (200 OK):
{
"success": true,
"message": "Image deleted successfully"
}Error Responses:
401 Unauthorized- Missing or invalid JWT token404 Not Found- Image not found or access denied500 Internal Server Error- Failed to delete image
Security: Users can only delete images they uploaded within their company.
File Validation
Allowed File Types
image/jpeg,image/jpgimage/pngimage/gifimage/webpimage/svg+xml
File Size Limits
- Maximum: 10MB per file
- Minimum: Greater than 0 bytes
Filename Restrictions
- Cannot contain
..,/, or\ - Maximum length: 255 characters
File Organization
Files are organized with multi-tenant isolation:
{bucket}/
├── {companyId-1}/
│ └── images/
│ ├── {userId-1}/
│ │ ├── {timestamp}-{uuid}-{filename}.{ext}
│ │ └── ...
│ └── {userId-2}/
│ └── ...
└── {companyId-2}/
└── ...Private Media Delivery (Story 6.10.1)
⚠️ Important: TendSocial enforces private-only media delivery. API clients receive asset IDs and short-lived viewUrl values, never storage references.
Asset access paths:
- In-app rendering: Presigned
viewUrl(15-min TTL, authenticated, never persisted) - External platform delivery: Media proxy with opaque, revocable lease tokens
- Server-side processing: Direct R2 access via
downloadFromS3/getObjectStream
The bucket remains private: no r2.dev public access, no custom domains enabled.
Rate Limiting
Upload endpoints are subject to the global rate limit:
- 100 requests per minute per IP address
Exceeding this limit returns 429 Too Many Requests.
Error Handling
All error responses follow this format:
{
"error": "Error Type",
"message": "Human-readable error message",
"statusCode": 400
}Common Error Codes:
400- Validation error (invalid file type, size, etc.)401- Authentication required404- Resource not found429- Rate limit exceeded500- Server error
Best Practices
For Client-Side Uploads (Presigned URLs)
- Always validate files on the client before requesting presigned URL
- Show upload progress to improve UX
- Handle errors gracefully (network failures, timeouts)
- Use presigned URLs immediately (they expire after 1 hour)
For Direct Uploads
- Use for smaller files (< 1MB recommended)
- Show upload progress using
XMLHttpRequestor similar - Compress images on the client before upload if possible
General
- Store
imageIdin your database to track which images are used where - Request fresh viewUrl each time you need to display an image (presigned URLs expire)
- Delete unused images to save storage costs
- Implement retry logic for failed uploads
- Never share presigned URLs with untrusted clients; they are authenticated to the logged-in user
Examples
React Component (Presigned URL)
import { useState } from 'react';
function ImageUpload() {
const [uploading, setUploading] = useState(false);
const [imageUrl, setImageUrl] = useState<string | null>(null);
const handleUpload = async (file: File) => {
setUploading(true);
try {
// 1. Get presigned URL
const presignedResponse = await fetch('/api/upload/presigned-url', {
method: 'POST',
headers: {
'Authorization': `Bearer ${getToken()}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
filename: file.name,
contentType: file.type,
fileSize: file.size
})
});
const { uploadUrl, imageId } = await presignedResponse.json();
// 2. Upload to R2
await fetch(uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': file.type },
body: file
});
// 3. Request a viewUrl to display the image
const viewResponse = await fetch(`/api/assets/${imageId}`, {
headers: { 'Authorization': `Bearer ${getToken()}` }
});
const { viewUrl } = await viewResponse.json();
setImageUrl(viewUrl);
} catch (error) {
console.error('Upload failed:', error);
} finally {
setUploading(false);
}
};
return (
<div>
<input
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && handleUpload(e.target.files[0])}
disabled={uploading}
/>
{uploading && <p>Uploading...</p>}
{imageUrl && <img src={imageUrl} alt="Uploaded" />}
</div>
);
}See Also
- Storage - Private storage operations