Appearance
TUS Resumable Upload
Overview
ODP supports the TUS resumable upload protocol for large file uploads. TUS allows uploads to be paused and resumed, making it reliable for large files or slow connections.
The TUS endpoint is at /tus and is implemented using the @tus/server library backed by a custom TusDataStore that integrates with ODP's file system.
Protocol Summary
TUS uses a series of HTTP requests to upload a file in chunks:
- POST /tus — Create upload: sends metadata, gets back upload URL
- HEAD /tus/:id — Check upload offset (resume from last position)
- PATCH /tus/:id — Upload chunk(s) of the file
- DELETE /tus/:id — Cancel an in-progress upload
Authentication
All TUS endpoints require the caller to have either app_access or admin_access.
Authorization: Bearer <access_token>Unauthenticated requests receive 403 Forbidden.
Endpoints
OPTIONS /tus
Preflight/discovery. Returns TUS server capabilities.
Response Headers:
Tus-Resumable: 1.0.0Tus-Version: 1.0.0Tus-Max-Size: <FILES_MAX_UPLOAD_SIZE in bytes>Tus-Extension: creation,termination
POST /tus
Create a new upload.
Request Headers:
Authorization: Bearer <access_token>
Content-Length: 0
Upload-Length: 10485760
Upload-Metadata: filename d29ya2Zsb3cucGRm,type YXBwbGljYXRpb24vcGRm| Header | Description |
|---|---|
Upload-Length | Total file size in bytes |
Upload-Metadata | Base64-encoded key-value pairs (filename, type, filetype, etc.) |
Upload-Metadata Keys:
| Key | Description |
|---|---|
filename | Base64-encoded original filename |
type / filetype | MIME type of the file |
title | Base64-encoded display title |
description | Base64-encoded description |
folder | Base64-encoded folder UUID |
MIME Type Validation: If FILES_MIME_TYPE_ALLOW_LIST is set (not *), the MIME type from metadata is checked against the allow list. Rejected types return 400 Bad Request.
Response:
HTTP/1.1 201 Created
Location: https://api.example.com/api/tus/upload-uuid
Tus-Resumable: 1.0.0The Location header contains the URL for subsequent PATCH requests.
HEAD /tus/:id
Check the current upload offset (for resuming).
Request Headers:
Authorization: Bearer <access_token>
Tus-Resumable: 1.0.0Response:
HTTP/1.1 200 OK
Upload-Offset: 5242880
Upload-Length: 10485760
Tus-Resumable: 1.0.0PATCH /tus/:id
Upload a chunk of the file.
Request Headers:
Authorization: Bearer <access_token>
Content-Type: application/offset+octet-stream
Upload-Offset: 0
Content-Length: 5242880Request Body: Raw binary chunk starting at Upload-Offset.
Response (chunk accepted):
HTTP/1.1 204 No Content
Upload-Offset: 5242880
Tus-Resumable: 1.0.0Response (upload complete):
HTTP/1.1 204 No Content
Upload-Offset: 10485760
X-File-Id: <odp-file-uuid>
Tus-Resumable: 1.0.0When Upload-Offset == Upload-Length, the upload is finalized:
- The
TusDataStorewrites the assembled file to the configured storage driver - An
odp_filesrecord is created with metadata fromUpload-Metadata - The
X-File-Idresponse header contains the new file's UUID
DELETE /tus/:id
Cancel an in-progress upload.
Response: 204 No Content
Cleans up partial upload data. The corresponding odp_files record (if partially created) is also cleaned up.
Complete Upload Flow
Upload Expiration
Incomplete uploads that have not been updated within TUS_UPLOAD_EXPIRATION are automatically cleaned up by a scheduled job (cron schedule: TUS_CLEANUP_SCHEDULE).
Configuration
| Variable | Default | Description |
|---|---|---|
FILES_MAX_UPLOAD_SIZE | 10mb | Maximum file size per upload |
FILES_MIME_TYPE_ALLOW_LIST | * | Comma-separated allowed MIME types, or * for all |
FILES_MAX_UPLOAD_CONCURRENCY | 5 | Maximum concurrent uploads |
TUS_UPLOAD_EXPIRATION | 10m | Incomplete upload TTL |
TUS_CLEANUP_SCHEDULE | 0 */6 * * * | Cron schedule for cleanup job (every 6 hours) |
STORAGE_LOCAL_ROOT | ./uploads | Local storage root directory |
Example: JavaScript TUS Client
javascript
import * as tus from 'tus-js-client';
const upload = new tus.Upload(file, {
endpoint: 'https://api.example.com/api/tus',
headers: {
Authorization: 'Bearer ' + accessToken,
},
metadata: {
filename: file.name,
type: file.type,
folder: 'folder-uuid',
},
chunkSize: 5 * 1024 * 1024, // 5MB chunks
retryDelays: [0, 1000, 3000, 5000],
onSuccess: () => {
// Get file UUID from response header
const fileId = upload.url.split('/').pop();
console.log('Upload complete, file ID:', fileId);
},
onError: (error) => console.error('Upload failed:', error),
onProgress: (uploaded, total) => {
console.log(`${Math.round(uploaded / total * 100)}%`);
},
});
upload.start();