Skip to content

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:

  1. POST /tus — Create upload: sends metadata, gets back upload URL
  2. HEAD /tus/:id — Check upload offset (resume from last position)
  3. PATCH /tus/:id — Upload chunk(s) of the file
  4. 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.0
  • Tus-Version: 1.0.0
  • Tus-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
HeaderDescription
Upload-LengthTotal file size in bytes
Upload-MetadataBase64-encoded key-value pairs (filename, type, filetype, etc.)

Upload-Metadata Keys:

KeyDescription
filenameBase64-encoded original filename
type / filetypeMIME type of the file
titleBase64-encoded display title
descriptionBase64-encoded description
folderBase64-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.0

The 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.0

Response:

HTTP/1.1 200 OK
Upload-Offset: 5242880
Upload-Length: 10485760
Tus-Resumable: 1.0.0

PATCH /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: 5242880

Request Body: Raw binary chunk starting at Upload-Offset.

Response (chunk accepted):

HTTP/1.1 204 No Content
Upload-Offset: 5242880
Tus-Resumable: 1.0.0

Response (upload complete):

HTTP/1.1 204 No Content
Upload-Offset: 10485760
X-File-Id: <odp-file-uuid>
Tus-Resumable: 1.0.0

When Upload-Offset == Upload-Length, the upload is finalized:

  1. The TusDataStore writes the assembled file to the configured storage driver
  2. An odp_files record is created with metadata from Upload-Metadata
  3. The X-File-Id response 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

VariableDefaultDescription
FILES_MAX_UPLOAD_SIZE10mbMaximum file size per upload
FILES_MIME_TYPE_ALLOW_LIST*Comma-separated allowed MIME types, or * for all
FILES_MAX_UPLOAD_CONCURRENCY5Maximum concurrent uploads
TUS_UPLOAD_EXPIRATION10mIncomplete upload TTL
TUS_CLEANUP_SCHEDULE0 */6 * * *Cron schedule for cleanup job (every 6 hours)
STORAGE_LOCAL_ROOT./uploadsLocal 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();

ODP Internal API Documentation