Upload
Ember Nexus supports two ways of getting a file onto an element: a direct upload, where the request body is the complete file, and a resumable upload, where the file is sent as a sequence of chunks across multiple requests. Both are started the same way, through POST /<uuid>/file or PUT /<uuid>/file, and selected via the Upload-Complete request header.
The resumable upload mechanism implements the IETF “Resumable Uploads for HTTP” draft - not the older tus.io protocol. There is no Tus-Resumable header, and Upload-Complete uses the structured boolean values ?0/?1 rather than a plain string.
Headers
Upload-Complete(required) -?1for a direct upload or the final chunk of a resumable upload,?0for a chunk that is not yet the last one.Upload-Offset(required onPATCH /upload/<uuid>) - the number of bytes already stored for the upload. On a request it must match the upload’s current offset exactly; a mismatch fails with409 Conflict, and the problem response’s details include both the expected and the provided value so the client can re-synchronize.Upload-Length(optional) - the total size in bytes the finished file will have, once all chunks are appended. Can be omitted when the total size isn’t known ahead of time. Once set on creation, it can not be changed or removed on a later request.Upload-Limit(response only) - a structured field describing the server’s current limits for the upload:max-age(seconds an unfinished upload is kept alive, counted from its first request),max-size(maximum total file size in bytes),min-append-size(minimum size of a non-final chunk) andmax-append-size(maximum size of a single chunk). Sent on every response that creates or continues a resumable upload (POST /<uuid>/file/PUT /<uuid>/filewithUpload-Complete: ?0,PATCH /upload/<uuid>,HEAD /upload/<uuid>). A direct upload’s201 Createdcarries none of these headers, since there is no ongoing upload to describe.
Lifecycle
- Create -
POSTorPUTwithUpload-Complete: ?0. Any data sent in this first request is stored as the upload’s first chunk (a zero-length body is valid, and can be used purely to create the upload and read back itsUpload-Limitbefore deciding on a chunking strategy). The response is204 No Contentwith aLocationheader pointing at the new upload’s URL under/upload/<uuid>. - Append -
PATCH /upload/<uuid>withContent-Type: application/partial-upload,Upload-Offsetequal to the current offset, andUpload-Complete: ?0. Chunks must be sent in order - the upload’s offset only ever advances by exactly the size of the chunk just accepted, so an out-of-order or overlapping chunk is rejected via the offset check rather than silently reordered. - Inspect (optional) -
HEAD /upload/<uuid>returns the currentUpload-Offset,Upload-Length,Upload-Complete,Upload-LimitandExpiresas headers with no body, e.g. after reconnecting from a dropped connection. - Complete - a
PATCHwithUpload-Complete: ?1(its body may be empty). This triggers the merge (see below); on success the upload object stops existing - subsequent requests against its URL respond404. - Cancel (optional) -
DELETE /upload/<uuid>removes an in-progress upload and its already-uploaded chunks before it completes. Explicit cancellation is optional; an upload that is simply abandoned is cleaned up automatically once it expires (see Expiry below).
Behind the scenes
Chunk storage
A resumable upload’s chunks are written to temporary storage as they arrive, and only moved to permanent storage once the upload completes successfully (see Merge on completion below) - an upload that never finishes never touches permanent storage at all. See the configuration reference for the operator-facing details behind this (the two S3 buckets involved, and why).
Merge on completion
When the completing chunk is accepted, the server does not re-upload the assembled bytes - it uses a genuine S3 multipart upload: a multipart upload is created against the target key in the storage bucket, each previously stored chunk is attached to it via a server-side part-copy (an intra-S3 copy from the upload bucket, not a client-driven re-transfer), and the multipart upload is then completed. If any part-copy or the completion step fails, the multipart upload is aborted and the request fails - the target element’s existing file (if any) is left untouched, since the replacement only takes effect once the multipart upload actually completes. Once merged, the individual chunk objects are deleted from the upload bucket, and - if this upload replaces an existing file - the previous storage object is deleted as well.
A direct upload (the request body is the complete file, Upload-Complete: ?1 on the very first request) does not go through this multipart path at all - the body is written directly to the upload bucket and then copied into the storage bucket as a single object. The chunked multipart merge only happens for resumable uploads.
Verifying integrity
The SHA-256 hash of the file is computed incrementally as chunks arrive, rather than by re-reading the assembled file from storage after the fact: each chunk is hashed locally as it’s received, and the running hash state is persisted on the upload between requests so it can be resumed on the next chunk. On the completing PATCH, the hash is finalized and, if a Repr-Digest (or Content-Digest) header was supplied, compared against it before the merge is carried out - the same RFC 9530 structured-field format used for direct uploads and downloads, described in the file reference. A mismatch fails with 400 Bad Request; the uploaded chunks are discarded and the target element keeps whatever file it had before (if any) - a failed completion never leaves an element with a partially-written file.
Limits
The chunk-size and count limits ultimately come from the storage backend and the operator’s configuration together - see Size limits in the file reference for how those two interact and are validated against each other at startup. In terms of the values actually reported to clients via Upload-Limit:
min-append-size(file.uploadMinChunkSizeInBytes, default 5 MiB - the S3 backend’s own hard minimum) - every chunk except the last must be at least this large. A smaller chunk is rejected with400 Bad Request, unless it is the completing chunk (Upload-Complete: ?1) or has zero length - a zero-length chunk is always accepted regardless of this minimum.max-append-size(file.uploadMaxChunkSizeInBytes, default ~101 MiB) - the maximum size of a single chunk.- The maximum number of chunks is derived from
file.uploadChunkDigitsLength(default 4 digits) as 10 raised to that many digits - 10,000 chunks by default, matching the S3 backend’s own hard maximum. max-size(file.maxFileSizeInBytes, default 10 GiB) - the maximum size of the finished file, the same setting described in Size limits in the file reference. A completing upload whose reassembled length exceeds this is rejected with400 Bad Request, and the upload is left incomplete.
Expiry
An upload’s expires timestamp is set once, on creation, to the first request’s time plus file.uploadExpiresInSecondsAfterFirstRequest (default 3 hours / 10800) - it is not extended by later chunks, so the entire upload, from first byte to the completing chunk, must fit inside that window regardless of how many requests it takes. Reported to the client as the Expires header and as max-age (in seconds, relative) inside Upload-Limit.
Once expires has passed, further PATCH /upload/<uuid> requests against the upload fail with 410 Gone - the upload has to be recreated from scratch via POST or PUT.
An expired upload isn’t deleted the instant it expires: file.expiredUploadCanBeDeletedAfterExpirationInSeconds (default 1 hour / 3600) is a grace period added on top of expires before the upload becomes eligible for cleanup, giving a client that was right at the boundary a little leeway. Actual deletion (freeing the chunks already stored in the upload bucket, and removing the upload) is performed by the cron:delete-expired-uploads task, which every API instance already runs automatically as part of cron - see Background tasks in the configuration reference. It skips uploads that were already removed, whether by an explicit DELETE /upload/<uuid> or because the upload’s target element was deleted in the meantime.
Access control
- Only the user who created a resumable upload can continue it, inspect it, or delete it -
PATCH /upload/<uuid>,HEAD /upload/<uuid>andDELETE /upload/<uuid>all respond404for a user other than the creator, even one with full access to the upload’s target element. - An upload is not transferable to another user.
- Should the creator lose access to the upload’s target element while the upload is still in progress, the upload is canceled and deleted as a side effect.
Concurrency
Multiple resumable uploads can target the same element in parallel (this is discouraged, but not prevented). Each proceeds independently; whichever one finishes its completing PATCH last is the one whose file ends up attached to the element, silently replacing whatever the earlier-finishing upload had just written.