Skip to content

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_TOKEN

The token must contain:

  • userId - User ID
  • companyId - 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:

json
{
  "filename": "logo.png",
  "contentType": "image/png",
  "fileSize": 50000
}

Response (200 OK):

json
{
  "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 filename
  • 401 Unauthorized - Missing or invalid JWT token
  • 500 Internal Server Error - Failed to generate presigned URL

Client Usage:

javascript
// 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:

javascript
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):

json
{
  "imageId": "img-uuid-123",
  "filename": "logo.png"
}

Error Responses:

  • 400 Bad Request - No file uploaded or invalid file
  • 401 Unauthorized - Missing or invalid JWT token
  • 500 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 return
  • offset (optional, default: 0) - Pagination offset

Example:

javascript
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):

json
{
  "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 token
  • 500 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:

javascript
const response = await fetch(`/api/upload/images/${imageId}`, {
  method: 'DELETE',
  headers: {
    'Authorization': `Bearer ${token}`
  }
});

const { success, message } = await response.json();

Response (200 OK):

json
{
  "success": true,
  "message": "Image deleted successfully"
}

Error Responses:

  • 401 Unauthorized - Missing or invalid JWT token
  • 404 Not Found - Image not found or access denied
  • 500 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/jpg
  • image/png
  • image/gif
  • image/webp
  • image/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:

  1. In-app rendering: Presigned viewUrl (15-min TTL, authenticated, never persisted)
  2. External platform delivery: Media proxy with opaque, revocable lease tokens
  3. 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:

json
{
  "error": "Error Type",
  "message": "Human-readable error message",
  "statusCode": 400
}

Common Error Codes:

  • 400 - Validation error (invalid file type, size, etc.)
  • 401 - Authentication required
  • 404 - Resource not found
  • 429 - Rate limit exceeded
  • 500 - Server error

Best Practices

For Client-Side Uploads (Presigned URLs)

  1. Always validate files on the client before requesting presigned URL
  2. Show upload progress to improve UX
  3. Handle errors gracefully (network failures, timeouts)
  4. Use presigned URLs immediately (they expire after 1 hour)

For Direct Uploads

  1. Use for smaller files (< 1MB recommended)
  2. Show upload progress using XMLHttpRequest or similar
  3. Compress images on the client before upload if possible

General

  1. Store imageId in your database to track which images are used where
  2. Request fresh viewUrl each time you need to display an image (presigned URLs expire)
  3. Delete unused images to save storage costs
  4. Implement retry logic for failed uploads
  5. Never share presigned URLs with untrusted clients; they are authenticated to the logged-in user

Examples

React Component (Presigned URL)

tsx
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

TendSocial Documentation